diff --git a/.github/workflows/build-test-images.yml b/.github/workflows/build-test-images.yml index 7ef25babf..4ad317d0c 100644 --- a/.github/workflows/build-test-images.yml +++ b/.github/workflows/build-test-images.yml @@ -61,16 +61,16 @@ jobs: - name: Checkout repository uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 - name: Login to GitHub Container Registry - uses: docker/login-action@v3 + uses: docker/login-action@c94ce9fb468520275223c153574b00df6fe4bcc9 # v3 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - name: Set up Docker Buildx if: ${{ inputs.useBuildx }} - uses: docker/setup-buildx-action@v3 + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 - name: Build dockerfile and push image - uses: docker/build-push-action@v6 + uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6 with: push: ${{ inputs.publish }} context: Tests/TestImages/${{ inputs.image }} diff --git a/.github/workflows/containerization-build-template.yml b/.github/workflows/containerization-build-template.yml index beb3dad87..3e6084d8e 100644 --- a/.github/workflows/containerization-build-template.yml +++ b/.github/workflows/containerization-build-template.yml @@ -15,17 +15,81 @@ on: description: Version of containerization default: test -jobs: - buildAndTest: +jobs: + swift-version: + name: Determine Swift version + if: github.repository == 'apple/containerization' + runs-on: ubuntu-24.04 + outputs: + image: ${{ steps.version.outputs.image }} + steps: + - name: Checkout .swift-version + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + sparse-checkout: .swift-version + sparse-checkout-cone-mode: false + + - name: Read Swift version + id: version + run: echo "image=swift:$(cat .swift-version)-noble" >> "$GITHUB_OUTPUT" + + # Build the guest initfs (vminitd/vmexec compiled static-musl, then packed + # into initfs.ext4 + a rootfs tar) inside a GitHub-native Swift Linux + # container (Docker-backed) — NOT via apple/container, which isn't available + # on GitHub runners. The macOS job below consumes these as an artifact and + # creates the vminit:latest image natively with cctl. The container job is + # unprivileged, so build-initfs.sh uses its `mke2fs -d` fallback (no loop + # mount / no CAP_SYS_ADMIN needed). + buildGuest: + name: Build guest initfs + if: github.repository == 'apple/containerization' + needs: swift-version + timeout-minutes: 30 + runs-on: ubuntu-24.04 + container: ${{ needs.swift-version.outputs.image }} + steps: + - name: Checkout repository + uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6 + with: + fetch-depth: 0 + + - name: Install system dependencies + run: apt-get update && apt-get install -y curl make e2fsprogs libarchive-dev libbz2-dev liblzma-dev libssl-dev + + - name: Install Static Linux SDK + run: make -C vminitd linux-sdk + + - name: Build vminitd (static musl, aarch64) + # Force aarch64 even though this runner is x86_64: the macOS buildAndTest + # job boots arm64 VZ VMs, so vminitd (PID 1) must be an aarch64 binary or + # the guest fails to exec and the integration boot hangs. The Static Linux + # SDK cross-targets both arches from any host (same mechanism dist-x86_64 + # uses in reverse). + run: make -C vminitd MUSL_ARCH=aarch64 BUILD_CONFIGURATION=${{ inputs.release && 'release' || 'debug' }} + + - name: Build initfs.ext4 + run: ./scripts/build-initfs.sh --vminitd vminitd/bin/vminitd --vmexec vminitd/bin/vmexec --ext4 bin/initfs.ext4 --tar bin/init.rootfs.tar.gz + + - name: Upload guest initfs + uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 + with: + name: initfs + path: | + bin/initfs.ext4 + bin/init.rootfs.tar.gz + if-no-files-found: error + + buildAndTest: name: Build and Test repo if: github.repository == 'apple/containerization' + needs: buildGuest timeout-minutes: 60 runs-on: [self-hosted, macos, tahoe, ARM64] permissions: contents: read packages: write env: - DEVELOPER_DIR: "/Applications/Xcode-latest.app/Contents/Developer" + DEVELOPER_DIR: "/Applications/Xcode_swift_6.3.app/Contents/Developer" steps: - name: Checkout repository @@ -33,11 +97,6 @@ jobs: with: fetch-depth: 0 - - name: Activate Swiftly - run: | - source ~/.swiftly/env.sh - cat ~/.swiftly/env.sh - - name: Check formatting run: | ./scripts/install-hawkeye.sh @@ -50,18 +109,22 @@ jobs: make protos if ! git diff --quiet ; then echo the following files require formatting or license headers: ; git diff --name-only ; false ; fi - - name: Make containerization and docs + - name: Make containerization, examples, and docs run: | - make clean containerization docs + make clean containerization examples docs tar cfz _site.tgz _site env: BUILD_CONFIGURATION: ${{ inputs.release && 'release' || 'debug' }} - - name: Make vminitd image + - name: Download guest initfs + uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 + with: + name: initfs + path: bin + + - name: Make vminit image run: | - source ~/.swiftly/env.sh - make -C vminitd swift linux-sdk - make init + make init-image env: BUILD_CONFIGURATION: ${{ inputs.release && 'release' || 'debug' }} @@ -113,7 +176,7 @@ jobs: steps: - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 - name: Download a single artifact uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7 diff --git a/.github/workflows/docs-release.yaml b/.github/workflows/docs-release.yaml index d59b6763e..e1295d6cd 100644 --- a/.github/workflows/docs-release.yaml +++ b/.github/workflows/docs-release.yaml @@ -43,4 +43,4 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e3cc7277..7f211c710 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -34,7 +34,7 @@ jobs: steps: - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 release: if: startsWith(github.ref, 'refs/tags/') @@ -47,7 +47,7 @@ jobs: packages: read steps: - name: Create release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: token: ${{ github.token }} name: ${{ github.ref_name }}-prerelease diff --git a/.gitignore b/.gitignore index 63669d43c..d6f0c5081 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,7 @@ DerivedData/ workdir/ installer/ .venv/ +.vscode/ test_results/ *.pid *.log @@ -20,8 +21,9 @@ test_results/ *.swp *.tar.gz *.tar.xz -vmlinux +vmlinux* # API docs for local preview only. _site/ _serve/ +kernel/vmlinuz-x86_64 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..2289c916c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,91 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Build / Test / Format + +The project is built via `make`, not directly with `swift build`. Two Swift packages live in this repo: the root package (Containerization libraries + `cctl` + macOS-only integration binary) and `vminitd/` (the Linux guest init system, compiled as a static musl binary inside the Linux dev container via the apple/`container` CLI — see `make vminitd`). + +- `make all` — build everything (`containerization` + `vminitd` + `init.ext4` rootfs in `bin/`). Default `BUILD_CONFIGURATION=debug`; pass `release` (or use `make release`) for optimized builds. +- `make containerization` — build just the host-side Swift package (skips vminitd). +- `make vminitd` — build vminitd / vmexec only. On macOS this runs `swift build --swift-sdk …-swift-linux-musl` *inside the Linux dev container* via the `container` CLI (the cloud-hypervisor build model), producing static musl binaries at `vminitd/bin/`; no host Swiftly/SDK needed. `make linux-build LIBC=glibc` builds via a Linux dev container. +- `make test` — unit tests with code coverage. `make coverage` regenerates the coverage report. +- `make integration` — runs `bin/containerization-integration`. Requires an in-repo kernel under `bin/` (`bin/vmlinux-arm64` on arm64, `bin/vmlinuz-x86_64` or `bin/vmlinux-x86_64` on x86_64); if absent, run `make fetch-default-kernel` to download the Kata-provided kernel for the host arch. +- Single test: `swift test --filter ContainerizationOCITests.ReferenceTests/testParsing` (Swift Testing / XCTest filter syntax). Targets are listed in `Package.swift`. +- `make linux-test` — runs `swift test` inside the Linux dev container (requires the `container` CLI from apple/container). +- `make linux-build` — builds the host-side Swift package (incl. `cctl`, `Containerization`, and `CloudHypervisor`) inside the same Linux dev container. Use this to validate Linux portability of host-side code; the resulting `cctl` is what the cloud-hypervisor backend ships behind. +- `make linux-integration` — runs the cross-platform integration suite against a real cloud-hypervisor VM inside the dev container (nested virt via apple/container's `--virtualization`). Requires a KVM-capable kernel at `kernel/vmlinux-arm64` (or `kernel/vmlinuz-x86_64` on x86_64 hosts) — build via `make -C kernel`; the kata-fetched kernel doesn't include KVM. Also requires `make fetch-cloud-hypervisor` and `make linux-build` to have been run first. Linux runs only the cross-platform subset (`process true`/`false`/`echo hi`); the macOS suite is unchanged. +- `make fetch-cloud-hypervisor` — downloads the static `cloud-hypervisor` v52.0 (aarch64) binary into `bin/cloud-hypervisor` for the Linux integration tests. +- `make build-cloud-hypervisor` / `make build-virtiofsd` — build patched `cloud-hypervisor` / `virtiofsd` from sources you have cloned into `.local/cloud-hypervisor` and `.local/virtiofsd` respectively. There is no fetch target — clone the upstream repos at the revision you want pinned. `build-virtiofsd` applies `scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch` and is idempotent. Both run inside the same Linux dev container as `linux-integration` so the resulting binaries are aarch64-linux-gnu. +- `make dist-x86_64` — assembles `bin/containerization-x86_64-.tar.gz` (cctl + cloud-hypervisor + virtiofsd + initfs.ext4 + kernel) for x86_64 Linux deployment, cross-compiled inside the aarch64 dev container via the Static Linux SDK (Swift) and `cargo zigbuild` (Rust). Prereqs: `.local/cloud-hypervisor` and `.local/virtiofsd` source checkouts (clone deliberately — no fetch target), and an x86_64 kernel built via `make -C kernel TARGET_ARCH=x86_64`. Per-stage rebuild env vars: `REBUILD_VMINITD=1`, `REBUILD_INITFS=1`, `REBUILD_CH=1`, `REBUILD_VIRTIOFSD=1`; cctl x86 always rebuilds. **Full pipeline, toolchain rationale, and troubleshooting in `docs/x86_64-build.md`.** The orchestrator is `scripts/build-dist-x86_64.sh`. +- `make fmt` — applies `.swift-format` and refreshes license headers via hawkeye. +- `make check` — formatting + license-header lint (this is what the pre-commit hook runs). Uses `.swift-format-nolint` for stricter linting. +- `make pre-commit` — installs `scripts/pre-commit.fmt` as a git pre-commit hook. +- `make protos` — regenerates `Sources/Containerization/SandboxContext/SandboxContext.{pb,grpc}.swift` from the `.proto`. Touch this whenever the proto changes; never hand-edit the generated files. +- `make init` / `make init-image` — `init` compiles the guest and builds `bin/initfs.ext4` (+ a rootfs tar) inside the dev container via `scripts/build-initfs.sh` (mkfs + loop mount, with a `mke2fs -d` fallback), then `init-image` creates the `vminit:latest` OCI image from the tar with the native `cctl` (`cctl rootfs create --rootfs --image vminit:latest`). CI splits these: a Linux container job builds the initfs artifact, the macOS job runs `init-image`. Building the guest on macOS requires the apple/`container` CLI — there is no host Swiftly / Static Linux SDK setup step anymore. + +`WARNINGS_AS_ERRORS=true` is the default for both packages. Don't disable it casually — CI builds with it on. + +## Architecture + +This is a **Swift library package** (not a CLI tool) that lets applications run Linux containers on Apple silicon by spawning a lightweight VM per container via `Virtualization.framework`. The corresponding end-user CLI lives in [`apple/container`](https://github.com/apple/container) and is **not** part of this repo. `cctl` here is a playground/example binary, not the shipping product. + +### The host ↔ guest split + +Every Linux container runs inside its own VM. The boundary between host (macOS) and guest (Linux) is the central architectural fact: + +- **Host side** (`Sources/`, `macOS` platform): orchestrates VMs through `Virtualization.framework` (`VZVirtualMachineInstance.swift`, `VZVirtualMachine+Helpers.swift`). The user-facing entry points are `LinuxContainer` (one container per VM) and `LinuxPod` (multiple containers in one VM, experimental). These build a `VMConfiguration`, boot the VM with the chosen `Kernel` and a rootfs containing `vminitd`, then drive the guest via gRPC. +- **Guest side** (`vminitd/`, Linux platform): `vminitd` is PID 1 inside the VM. It exposes a gRPC service over **vsock** (default port `1024`) defined by `Sources/Containerization/SandboxContext/SandboxContext.proto`. `VminitdCore` implements that service: launching container processes, handling stdio over vsock, signal/event delivery, cgroups, mounts, and process lifecycle. By default it launches workloads via `vmexec` (a small helper that runs a single process inside the guest namespace); `runc` is used only when an OCI runtime path is supplied. + +The proto is the contract between the two halves. **The `.pb.swift` and `.grpc.swift` files in `SandboxContext/` are generated** — regenerate via `make protos` after changing `SandboxContext.proto`. Both host and guest depend on the same generated Swift via the path-dependency wiring in `vminitd/Package.swift` (`containerization` is a sibling path package). + +### VMM backends + +`Containerization` abstracts the VMM behind `VirtualMachineManager` / `VirtualMachineInstance`. Two backends ship in this repo, both inside the same `Containerization` target but gated by `#if`: + +- **macOS**: `VZVirtualMachineManager` / `VZVirtualMachineInstance` (`VZ*` files, `#if os(macOS)`). Drives `Virtualization.framework` directly. +- **Linux**: `CHVirtualMachineManager` / `CHVirtualMachineInstance` (`CH*` files plus `CHProcess`, `VirtiofsdProcess`, `Vsock+Linux`, all `#if os(Linux)`). One `cloud-hypervisor` subprocess per VM, REST-on-UDS control plane via the standalone [`CloudHypervisor`](./Sources/CloudHypervisor) Swift package, virtio-blk / virtio-fs (one `virtiofsd` per share) / TAP / vsock for the data plane. Same `Vminitd` guest contract as VZ — only the host-side VMM differs. + +The `CloudHypervisor` library is a thin NIO-based HTTP/1.1-over-UDS client targeting cloud-hypervisor's REST API. It compiles on both platforms (so it can be unit-tested on macOS without a real cloud-hypervisor binary), but is only consumed by the Linux backend at runtime. + +**Sandbox env vars.** `CHProcess` and `VirtiofsdProcess` default to the upstream-secure spawn flags. Per-component opt-outs: + +- `CONTAINERIZATION_NO_CH_SECCOMP=1` — launch cloud-hypervisor with `--seccomp false`. +- `CONTAINERIZATION_NO_VIRTIOFSD_SANDBOX=1` — launch virtiofsd with `--sandbox none`. + +Both flags emit a one-line `logger.warning` at start so a relaxed-sandbox VM is loud in the host log. The legacy alias `CONTAINERIZATION_RELAXED_SANDBOX=1` continues to flip both at once. These are required inside apple/container's `--virtualization` dev container, where the host seccomp profile SIGSYS-kills both binaries; `make linux-integration` sets the legacy alias automatically. Leave them unset in production deployments where the host policy lets CH/virtiofsd run unmolested. + +### Library targets (`Sources/`) + +These are independently consumable Swift modules. Keep their dependencies narrow: + +- `Containerization` — the top-level orchestration layer (`LinuxContainer`, `LinuxPod`, `VMConfiguration`, `Vminitd` gRPC client wrapper, mounts, networking, sockets, image unpacking). Hosts both the macOS (VZ) and Linux (CH) VMM backends behind `#if os(...)`. +- `CloudHypervisor` — standalone NIO-based HTTP/1.1-over-UDS client targeting cloud-hypervisor's REST API. Cross-platform (compiles on macOS for unit tests; consumed at runtime only by the Linux side of `Containerization`). +- `ContainerizationOCI` — OCI image spec types, registry client (push/pull/auth), local OCI layout, content store. Used host-side for image management. +- `ContainerizationEXT4` — pure-Swift ext4 reader/formatter; used to build container rootfs blocks (`bin/initfs.ext4`). +- `ContainerizationArchive` — Swift wrapper around vendored libarchive headers (`Sources/ContainerizationArchive/CArchive`, refreshable via `make update-libarchive-source`). Links system `libarchive`, `lzma`, `bz2`, `z`, plus zstd via SwiftPM. +- `ContainerizationNetlink` — netlink socket bindings (used by vminitd for in-guest network configuration). +- `ContainerizationOS` — POSIX/Darwin/Linux platform shims (`Command`, `Terminal`, `Socket`, signal handling, mount syscalls, keychain). Cross-platform. +- `ContainerizationIO` — small NIO-flavored stream/reader utilities. +- `ContainerizationExtras`, `ContainerizationError`, `CShim` — shared helpers and a tiny C bridge. + +`Sources/Integration/` is the macOS-only `containerization-integration` binary (the integration test runner; it is not a `testTarget`, it's an `executableTarget` that's invoked by `make integration`). Unit `testTarget`s live under `Tests/`. + +### vminitd internals (`vminitd/Sources/`) + +- `VminitdCore/Server+GRPC.swift` is the bulk of the guest agent — it implements every RPC declared in `SandboxContext.proto`. +- `ManagedContainer.swift` / `ManagedProcess.swift` launch container processes via `vmexec` by default; `VminitdCore/Runc/` plus `RuncProcess.swift` shell out to `runc` only when an OCI runtime path is supplied. `ProcessSupervisor` reaps and dispatches exit events. +- `Cgroup/` handles cgroup v2 setup. `LCShim/` and `CVersion/` are small C bridges (the latter injects `GIT_COMMIT`/`GIT_TAG`/`BUILD_TIME` at compile time). +- `vmexec` runs a single container process inside the guest namespace and is what `vminitd` execs to launch container workloads. + +## Conventions + +- **License headers are required** on every Swift file. `make check-licenses` runs hawkeye against `scripts/license-header.txt`. New files: run `make update-licenses` (or `make fmt`) before committing. +- **Formatting**: `.swift-format` (line length 180, 4-space indent). The lint config (`.swift-format-nolint`) is what CI enforces. `NeverForceUnwrap`, `NeverUseForceTry`, and `NeverUseImplicitlyUnwrappedOptionals` are all on — don't introduce `!` / `try!`. +- **Package isolation**: prefer adding code to the smallest applicable module. Don't pull `Containerization` into `ContainerizationOCI` or similar — the leaf modules are intentionally light so they can be consumed standalone. +- **`SandboxContext.proto` is excluded from the `Containerization` target** (see `Package.swift`). The generated `.pb.swift` / `.grpc.swift` files are checked in. +- **Squash-and-merge**: PRs land as a single commit, so the PR title/body becomes the commit message — write it accordingly. Commits must be signed (per `CONTRIBUTING.md`). + +## Requirements + +Apple silicon Mac, macOS 26, Xcode 26. The host-side build uses Xcode's Swift toolchain (`/usr/bin/swift`); the Linux guest is built inside the dev container, so the apple/`container` CLI is required (see the README). The pinned Swift version (`.swift-version`, currently `6.3.0`) tags the dev image and the CI Swift Linux container. Older macOS releases are not supported. diff --git a/Makefile b/Makefile index b44d7aef5..c79d6995d 100644 --- a/Makefile +++ b/Makefile @@ -15,10 +15,39 @@ # Build configuration variables BUILD_CONFIGURATION ?= debug WARNINGS_AS_ERRORS ?= true -SWIFT_CONFIGURATION := $(if $(filter-out false,$(WARNINGS_AS_ERRORS)),-Xswiftc -warnings-as-errors) --disable-automatic-resolution + +# Allow for a custom build cache directory +# By default this is left unset, and swift uses the default directory as `./.build` +# The `linux_run` target exports SCRATCH_ROOT inside of the container +SCRATCH_ROOT ?= +SCRATCH_PATH ?= $(if $(SCRATCH_ROOT),$(SCRATCH_ROOT)/build-containerization) +SWIFT_SCRATCH_FLAGS := $(if $(SCRATCH_PATH),--scratch-path $(SCRATCH_PATH)) +SWIFT_CONFIGURATION := $(if $(filter-out false,$(WARNINGS_AS_ERRORS)),-Xswiftc -warnings-as-errors) --disable-automatic-resolution $(SWIFT_SCRATCH_FLAGS) # Commonly used locations UNAME_S := $(shell uname -s) +UNAME_M := $(shell uname -m) +KERNEL_ARCH := $(if $(filter $(UNAME_M),aarch64 arm64),arm64,$(UNAME_M)) +# Candidate kernel filenames in bin/ (compiled vmlinuz first, kata-fetched vmlinux fallback). +ifeq ($(KERNEL_ARCH),x86_64) +KERNEL_CANDIDATES := bin/vmlinuz-x86_64 bin/vmlinux-x86_64 +else +KERNEL_CANDIDATES := bin/vmlinux-$(KERNEL_ARCH) +endif +# In-repo KVM-capable kernel built by `make -C kernel` (vmlinuz for x86_64 bzImage, +# vmlinux for arm64 Image). linux-integration requires this; the kata-fetched +# kernel under bin/ does not enable KVM. +ifeq ($(KERNEL_ARCH),x86_64) +LINUX_INTEGRATION_KERNEL := kernel/vmlinuz-x86_64 +else +LINUX_INTEGRATION_KERNEL := kernel/vmlinux-$(KERNEL_ARCH) +endif + +# Optional test-name filter for `make linux-integration`, e.g. +# make linux-integration FILTER="pod hotplug" +# Comma-separated; a test is kept if its name contains ANY of the substrings. +FILTER ?= +linux_integration_filter = $(if $(strip $(FILTER)),--filter '$(strip $(FILTER))') ifeq ($(UNAME_S),Darwin) SWIFT ?= /usr/bin/swift else @@ -26,7 +55,7 @@ SWIFT ?= swift endif ROOT_DIR := $(shell git rev-parse --show-toplevel) -BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path) +BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_SCRATCH_FLAGS) --show-bin-path) COV_DATA_DIR = $(shell $(SWIFT) test --show-coverage-path | xargs dirname) COV_REPORT_FILE = $(ROOT_DIR)/code-coverage-report @@ -36,15 +65,45 @@ 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 +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. +CLOUD_HYPERVISOR_SHA256 := bf004ddc1a148f47caa87ac49a783b8dbd6bf9bc27abe522ed197df7b982d3b1 SWIFT_VERSION := $(shell cat $(ROOT_DIR)/.swift-version) SWIFT_SDK_URL := $(shell grep '^SWIFT_SDK_URL' vminitd/Makefile | head -1 | sed 's/.*:= *//') SWIFT_SDK_CHECKSUM := $(shell grep '^SWIFT_SDK_CHECKSUM' vminitd/Makefile | head -1 | sed 's/.*:= *//') LINUX_DEV_IMAGE := containerization-dev:$(SWIFT_VERSION) +# Use an alternative path (backed by a named volume) for the build cache +# when building products inside of a container +# LINUX_SCRATCH_ROOT is used for the build cache +# LINUX_SHARED_CACHE is used for the dependency cache +LINUX_BUILD_VOLUME := containerization-linux-build +LINUX_SCRATCH_ROOT := /build +LINUX_SHARED_CACHE := $(LINUX_SCRATCH_ROOT)/cache + +# Literal `,` for use inside $(call ...) arguments — bare commas are +# treated as the call's argument separator and split the value early. +comma := , + # Run a command inside a Linux dev container. # Requires 'container' (https://github.com/apple/container). # Automatically builds the dev image if it doesn't exist. +# +# Bind-mounts $(ROOT_DIR)/.local/integration-cache → the dev container's +# appRoot (`~/.local/share/com.apple.containerization`) so cctl-populated +# imageStore content (e.g. `vminit:latest` from `make init`, plus images +# pulled by the integration suite like alpine) persists across `container +# run` invocations. Without this, every `make linux-integration` re-pulls +# alpine and re-imports vminit, which dominates per-suite ramp-up. The +# macOS path gets this for free because $HOME persists. +# +# $(1): bash command to run inside the container. +# $(2): optional extra flags for `container run` (empty by default). Use this +# for linux-integration to pass `--kernel kernel/vmlinux-` so +# /dev/kvm is exposed in the dev container's Linux VM (the kata kernel +# fetched by `make fetch-default-kernel` does not enable KVM). define linux_run @if ! command -v container > /dev/null 2>&1; then \ echo "Error: 'container' CLI not found. Install from https://github.com/apple/container"; \ @@ -54,7 +113,18 @@ define linux_run echo "Building Linux dev container image..."; \ $(MAKE) linux-image; \ fi - @container run --memory 8gb --cpus 4 -v $(ROOT_DIR):/workspace -w /workspace $(LINUX_DEV_IMAGE) \ + @mkdir -p $(ROOT_DIR)/.local/integration-cache + @if ! container volume inspect $(LINUX_BUILD_VOLUME) > /dev/null 2>&1; then \ + echo "Creating Linux build volume $(LINUX_BUILD_VOLUME)..."; \ + container volume create $(LINUX_BUILD_VOLUME) > /dev/null; \ + fi + @container run --rm $(2) --memory 16gb --cpus 8 \ + --env SCRATCH_ROOT=$(LINUX_SCRATCH_ROOT) \ + --env XDG_CACHE_HOME=$(LINUX_SHARED_CACHE) \ + -v $(ROOT_DIR):/workspace \ + -v $(LINUX_BUILD_VOLUME):$(LINUX_SCRATCH_ROOT) \ + -v $(ROOT_DIR)/.local/integration-cache:/root/.local/share/com.apple.containerization \ + -w /workspace $(LINUX_DEV_IMAGE) \ bash -c "$(1)" endef @@ -85,14 +155,141 @@ linux-image: linux-build: LIBC ?= musl linux-build: ifeq ($(LIBC),all) - $(call linux_run,make containerization && make -C vminitd LIBC=glibc && make -C vminitd LIBC=musl) + $(call linux_run,make containerization && make -C vminitd LIBC=glibc && make -C vminitd LIBC=musl && make init) else - $(call linux_run,make containerization && make -C vminitd LIBC=$(LIBC)) + $(call linux_run,make containerization && make -C vminitd LIBC=$(LIBC) && make init) endif .PHONY: linux-test linux-test: - $(call linux_run,swift test $(SWIFT_CONFIGURATION)) + $(call linux_run,swift test $(SWIFT_CONFIGURATION) --scratch-path $(LINUX_SCRATCH_ROOT)/build-containerization) + +.PHONY: build-cloud-hypervisor +# Build cloud-hypervisor from the patched source at .local/cloud-hypervisor and +# install it to bin/cloud-hypervisor. Runs inside the Linux dev container so the +# resulting binary is aarch64-linux-gnu and can run nested-virt under +# `container run --virtualization`. Installs build deps + rustup the first +# time. Forces HOME=/root since the container inherits the host HOME otherwise, +# which breaks rustup's $HOME/.cargo path. +# +# Prerequisite: clone cloud-hypervisor into .local/cloud-hypervisor (any +# revision compatible with the v52.0 REST surface this repo targets). There +# is no fetch target — pin the revision deliberately. Example: +# git clone -b v52.0 https://github.com/cloud-hypervisor/cloud-hypervisor \ +# .local/cloud-hypervisor +build-cloud-hypervisor: +ifeq (,$(wildcard .local/cloud-hypervisor/Cargo.toml)) + @echo "missing .local/cloud-hypervisor source checkout." >&2 + @echo "clone the cloud-hypervisor repo into .local/cloud-hypervisor before running this target, e.g.:" >&2 + @echo " git clone -b v52.0 https://github.com/cloud-hypervisor/cloud-hypervisor .local/cloud-hypervisor" >&2 + @exit 1 +endif + $(call linux_run,export HOME=/root && if ! command -v curl >/dev/null 2>&1; then apt-get update && apt-get install -y --no-install-recommends curl ca-certificates build-essential pkg-config libssl-dev; fi && if [ ! -x /root/.cargo/bin/cargo ]; then curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal; fi && . /root/.cargo/env && cd .local/cloud-hypervisor && cargo build --release --bin cloud-hypervisor && cp target/release/cloud-hypervisor /workspace/bin/cloud-hypervisor && chmod +x /workspace/bin/cloud-hypervisor) + +.PHONY: build-virtiofsd +# Build virtiofsd from the source at .local/virtiofsd and install it to +# bin/virtiofsd. Runs inside the Linux dev container so the resulting +# binary is aarch64-linux-gnu and matches the cloud-hypervisor binary +# built by `make build-cloud-hypervisor`. +# +# Prerequisite: clone virtiofsd into .local/virtiofsd (any revision the +# scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch applies +# cleanly to). There is no fetch target — pin the revision deliberately: +# git clone https://gitlab.com/virtio-fs/virtiofsd .local/virtiofsd +# +# virtiofsd has two hard build deps that aren't in the base dev image: +# * libcap-ng-dev — capng crate is unconditional in [dependencies]. +# * libseccomp-dev — Cargo.toml has `default = ["seccomp"]` and +# `[[bin]] required-features = ["seccomp"]`, and libseccomp-sys is a +# -sys crate that links against the system library via pkg-config. +# Both are required even though we run with `--sandbox none` (capng is +# called for capability-drop at startup, before any sandbox setup). +# +# Before building, applies the patch at +# scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch (see +# that file for rationale). Idempotent: skips if already applied via +# git apply --reverse --check. +# +# Sentinel for the apt-get block is libcap-ng + libseccomp via pkg-config +# (not `command -v curl`) so this target works correctly even after +# `build-cloud-hypervisor` has already installed curl in the same dev +# container. +build-virtiofsd: +ifeq (,$(wildcard .local/virtiofsd/Cargo.toml)) + @echo "missing .local/virtiofsd source checkout." >&2 + @echo "clone the virtiofsd repo into .local/virtiofsd before running this target, e.g.:" >&2 + @echo " git clone https://gitlab.com/virtio-fs/virtiofsd .local/virtiofsd" >&2 + @exit 1 +endif + $(call linux_run,export HOME=/root && \ + if ! pkg-config --exists libcap-ng libseccomp 2>/dev/null; then \ + apt-get update && apt-get install -y --no-install-recommends \ + curl ca-certificates build-essential pkg-config libssl-dev \ + libcap-ng-dev libseccomp-dev; \ + fi && \ + if [ ! -x /root/.cargo/bin/cargo ]; then \ + curl --proto =https --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal; \ + fi && \ + . /root/.cargo/env && \ + cd /workspace/.local/virtiofsd && \ + if git apply --check /workspace/scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch 2>/dev/null; then \ + git apply /workspace/scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch && \ + echo 'applied virtiofsd cap-drop patch'; \ + elif git apply --reverse --check /workspace/scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch 2>/dev/null; then \ + echo 'virtiofsd cap-drop patch already applied'; \ + else \ + echo 'ERROR: virtiofsd cap-drop patch does not apply cleanly' >&2; \ + exit 1; \ + fi && \ + cargo build --release && \ + cp target/release/virtiofsd /workspace/bin/virtiofsd && \ + chmod +x /workspace/bin/virtiofsd) + +.PHONY: linux-integration +linux-integration: +ifeq (,$(wildcard bin/cloud-hypervisor)) + @echo "missing bin/cloud-hypervisor; run 'make fetch-cloud-hypervisor' first" + @exit 1 +endif +ifeq (,$(wildcard bin/virtiofsd)) + @echo "missing bin/virtiofsd; run 'make build-virtiofsd' first" + @exit 1 +endif +ifeq (,$(wildcard $(LINUX_INTEGRATION_KERNEL))) + @echo "missing $(LINUX_INTEGRATION_KERNEL); run 'make -C kernel' first to build a KVM-capable kernel" + @exit 1 +endif +ifeq (,$(wildcard bin/containerization-integration)) + @echo "missing bin/containerization-integration; run 'make linux-build' first" + @exit 1 +endif +ifeq (,$(wildcard bin/initfs.ext4)) + @echo "missing bin/initfs.ext4; run 'make init' first (this also seeds the persistent imageStore at .local/integration-cache)" + @exit 1 +endif + $(call linux_run,CONTAINERIZATION_RELAXED_SANDBOX=1 ./bin/containerization-integration --kernel ./$(LINUX_INTEGRATION_KERNEL) --ch-binary ./bin/cloud-hypervisor --virtiofsd-binary ./bin/virtiofsd --max-concurrency 1 $(linux_integration_filter),--kernel $(LINUX_INTEGRATION_KERNEL) --virtualization) + +# Builds the x86_64 deployment tarball. +# +# Cross-compiles cctl, vminitd, cloud-hypervisor, and virtiofsd to +# x86_64-linux-musl inside the aarch64 Linux dev container (using the +# musl cross toolchain + static C deps installed by the dev image), +# packs an initfs.ext4 with the x86_64 vminitd inside, and emits +# bin/containerization-x86_64-.tar.gz. +# +# Depends on linux-image so that Dockerfile / build-musl-x86_64-deps.sh +# changes are picked up automatically. `container build` is cheap when +# layers are cached, so the no-change path is a few seconds of overhead. +# +# Prereqs: +# * .local/cloud-hypervisor and .local/virtiofsd source checkouts +# (see build-cloud-hypervisor / build-virtiofsd for clone URLs). +# * kernel/vmlinuz-x86_64 (preferred) or kernel/vmlinux-x86_64 present. +# Build via `make -C kernel TARGET_ARCH=x86_64` (or `make -C kernel x86_64`). +# The script fails hard if neither is present. +.PHONY: dist-x86_64 +dist-x86_64: linux-image + $(call linux_run,./scripts/build-dist-x86_64.sh) endif .PHONY: all @@ -112,34 +309,67 @@ containerization: @echo Copying containerization binaries... @mkdir -p bin @install "$(BUILD_BIN_DIR)/cctl" ./bin/ -ifeq ($(UNAME_S),Darwin) @install "$(BUILD_BIN_DIR)/containerization-integration" ./bin/ - +ifeq ($(UNAME_S),Darwin) @echo Signing containerization binaries... @codesign --force --sign - --timestamp=none --entitlements=signing/vz.entitlements bin/cctl @codesign --force --sign - --timestamp=none --entitlements=signing/vz.entitlements bin/containerization-integration endif +# Shell fragments run inside the Linux dev container (see linux_run). Kept as +# variables so `vminitd` (compile only) and `init` (compile + build the initfs +# in a single container run) don't duplicate the command. +VMINITD_BUILD_CMD = make -C vminitd BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) WARNINGS_AS_ERRORS=$(WARNINGS_AS_ERRORS) +INITFS_BUILD_CMD = ./scripts/build-initfs.sh --vminitd vminitd/bin/vminitd --vmexec vminitd/bin/vmexec --ext4 bin/initfs.ext4 --tar bin/init.rootfs.tar.gz + .PHONY: init -init: containerization vminitd - @echo Creating init.ext4... - @rm -f bin/init.rootfs.tar.gz bin/init.block bin/initfs.ext4 +ifeq ($(UNAME_S),Darwin) +# Compile the guest and build the initfs (ext4 + rootfs tar) in a single dev +# container run — where mkfs/loop-mount live — then create the vminit:latest +# OCI image natively from the tar. The container's output is the finished +# initfs, not raw binaries. +init: containerization + @mkdir -p ./bin + $(call linux_run,$(VMINITD_BUILD_CMD) && $(INITFS_BUILD_CMD)) + @"$(MAKE)" init-image +else +init: containerization + @mkdir -p ./bin + @$(VMINITD_BUILD_CMD) + @$(INITFS_BUILD_CMD) + @"$(MAKE)" init-image +endif + +# Create the vminit:latest OCI image from the container-built rootfs tar, using +# the native cctl. Split out from `init` so CI can create the image after +# downloading the initfs artifact built by the Linux container job — no +# apple/container needed on the macOS runner. The integration suite and the +# release ghcr push consume this image. +.PHONY: init-image +init-image: + @echo Creating vminit:latest image... + @rm -f bin/init.block @./bin/cctl rootfs create \ - --vminitd vminitd/bin/vminitd \ - --vmexec vminitd/bin/vmexec \ - --ext4 ./bin/initfs.ext4 \ - --label org.opencontainers.image.source=https://github.com/apple/containerization \ --image vminit:latest \ + --label org.opencontainers.image.source=https://github.com/apple/containerization \ bin/init.rootfs.tar.gz -.PHONY: cross-prep -cross-prep: - @"$(MAKE)" -C vminitd cross-prep - .PHONY: vminitd +ifeq ($(UNAME_S),Darwin) +# On macOS, vminitd/vmexec are static musl Linux binaries. Rather than +# cross-compiling on the host (which used to require Swiftly + the Static +# Linux SDK via `make cross-prep`), build them inside the Linux dev container +# via `linux_run` — the same model `build-cloud-hypervisor` uses. The dev +# image bakes in the Static Linux SDK, and the /workspace mount makes the +# resulting binaries visible on the host at vminitd/bin/. +vminitd: + @mkdir -p ./bin + $(call linux_run,$(VMINITD_BUILD_CMD)) +else vminitd: @mkdir -p ./bin - @"$(MAKE)" -C vminitd BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) WARNINGS_AS_ERRORS=$(WARNINGS_AS_ERRORS) + @$(VMINITD_BUILD_CMD) +endif .PHONY: update-libarchive-source update-libarchive-source: @@ -169,12 +399,13 @@ coverage: test .PHONY: integration integration: -ifeq (,$(wildcard bin/vmlinux)) - @echo No bin/vmlinux kernel found. See fetch-default-kernel target. - @exit 1 -endif - @echo Running the integration tests... - @./bin/containerization-integration + @kernel="$$(for f in $(KERNEL_CANDIDATES); do [ -f $$f ] && echo $$f && break; done)"; \ + if [ -z "$$kernel" ]; then \ + echo "No kernel found. Looked for: $(KERNEL_CANDIDATES). See fetch-default-kernel target or build via kernel/Makefile."; \ + exit 1; \ + fi; \ + echo "Running the integration tests with kernel $$kernel..."; \ + ./bin/containerization-integration --kernel "$$kernel" .PHONY: fetch-default-kernel fetch-default-kernel: @@ -182,14 +413,28 @@ fetch-default-kernel: ifeq (,$(wildcard .local/kata.tar.gz)) @curl -SsL -o .local/kata.tar.gz ${KATA_BINARY_PACKAGE} endif -ifeq (,$(wildcard .local/vmlinux)) +ifeq (,$(wildcard .local/vmlinux-$(KERNEL_ARCH))) @tar -zxf .local/kata.tar.gz -C .local/ --strip-components=1 - @cp -L .local/opt/kata/share/kata-containers/vmlinux.container .local/vmlinux + @cp -L .local/opt/kata/share/kata-containers/vmlinux.container .local/vmlinux-$(KERNEL_ARCH) endif -ifeq (,$(wildcard bin/vmlinux)) - @cp .local/vmlinux bin/vmlinux +ifeq (,$(wildcard bin/vmlinux-$(KERNEL_ARCH))) + @cp .local/vmlinux-$(KERNEL_ARCH) bin/vmlinux-$(KERNEL_ARCH) endif +.PHONY: fetch-cloud-hypervisor +fetch-cloud-hypervisor: + @mkdir -p bin + @curl -SsL -o bin/cloud-hypervisor $(CLOUD_HYPERVISOR_URL) + @actual=$$(shasum -a 256 bin/cloud-hypervisor | awk '{print $$1}'); \ + if [ "$$actual" != "$(CLOUD_HYPERVISOR_SHA256)" ]; then \ + echo "ERROR: cloud-hypervisor checksum mismatch" >&2; \ + echo " expected: $(CLOUD_HYPERVISOR_SHA256)" >&2; \ + echo " actual: $$actual" >&2; \ + rm -f bin/cloud-hypervisor; \ + exit 1; \ + fi + @chmod +x bin/cloud-hypervisor + .PHONY: check check: swift-fmt-check check-licenses @@ -203,8 +448,8 @@ swift-fmt: @$(SWIFT) format --recursive --configuration .swift-format -i $(SWIFT_SRC) swift-fmt-check: - @echo Checking code formatting compliance... - @$(SWIFT) format lint --recursive --strict --configuration .swift-format-nolint $(SWIFT_SRC) + @echo Checking code formatting compliance... + @$(SWIFT) format lint --recursive --strict --configuration .swift-format-nolint $(SWIFT_SRC) .PHONY: update-licenses update-licenses: @@ -246,6 +491,14 @@ cleancontent: @echo Cleaning the content... @rm -rf ~/Library/Application\ Support/com.apple.containerization +.PHONY: examples +examples: + @echo Building examples... + @mkdir -p bin + @"$(MAKE)" -C examples/sandboxy build BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) + @install examples/sandboxy/bin/sandboxy ./bin/ + @codesign --force --sign - --timestamp=none --entitlements=signing/vz.entitlements bin/sandboxy + .PHONY: clean clean: @echo Cleaning build files... diff --git a/Package.resolved b/Package.resolved index 8319ea5df..c48a1a03c 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "5d4a569160adc023c31092ec813aeb5f7e7ac67ed853dfeb76f9964d10109bca", + "originHash" : "aa4f27194491a8119550ea4c904048ba7a0ae89d75b272116b89728ab393bf92", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "60235983163d040f343a489f7e2e77c1918a8bd9", - "version" : "1.26.1" + "revision" : "4603a8036d921ea999fadb742931546c341f4bd7", + "version" : "1.35.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift-2.git", "state" : { - "revision" : "f28854bc760a116e053fdfc4a48a9428c34625c0", - "version" : "2.3.0" + "revision" : "28cdd63ef88583ddc67d7bb179eab46fab465ce9", + "version" : "2.4.2" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", "state" : { - "revision" : "f37e0c2d293cea668b11e10e1fb1c24cb40781ff", - "version" : "2.4.4" + "revision" : "2ca31f06658ed288a2560e23ad649acbb3d6b3a3", + "version" : "2.9.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift-protobuf.git", "state" : { - "revision" : "19153231a03c2fda1f4ea60da1b92a2cb9c011d8", - "version" : "2.2.0" + "revision" : "176c5a434fd76f6f479848d1a8f7d44967534168", + "version" : "2.4.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "a54383ada6cecde007d374f58f864e29370ba5c3", - "version" : "1.3.2" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", - "version" : "1.15.0" + "revision" : "89fbc3714264cce8db8e4ec51b64e01c3e28c6c5", + "version" : "1.19.3" } }, { @@ -96,7 +96,16 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", "version" : "1.2.0" } }, @@ -105,8 +114,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", - "version" : "3.12.3" + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" } }, { @@ -114,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-docc-plugin", "state" : { - "revision" : "d1691545d53581400b1de9b0472d45eb25c19fed", - "version" : "1.4.4" + "revision" : "647c708be89f834fa6a6d4945442793a77ddf5b6", + "version" : "1.5.0" } }, { @@ -132,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "db6eea3692638a65e2124990155cd220c2915903", - "version" : "1.3.0" + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" } }, { @@ -141,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" } }, { @@ -150,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", - "version" : "1.10.1" + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" } }, { @@ -159,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237", - "version" : "2.99.0" + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" } }, { @@ -168,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "145db1962f4f33a4ea07a32e751d5217602eea29", - "version" : "1.28.0" + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" } }, { @@ -177,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9", - "version" : "1.43.0" + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" } }, { @@ -186,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "173cc69a058623525a58ae6710e2f5727c663793", - "version" : "2.36.0" + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" } }, { @@ -195,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56", - "version" : "1.24.0" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -204,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { @@ -213,8 +231,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "86970144a0b86068c81ff48ee29b3f97cae0b879", - "version" : "1.36.0" + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" } }, { @@ -222,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { @@ -231,8 +258,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "50688cacbd41d547e9eb9f7a213542340b7c442b", + "version" : "1.7.5" } }, { diff --git a/Package.swift b/Package.swift index df498005e..63f81a6b0 100644 --- a/Package.swift +++ b/Package.swift @@ -34,15 +34,16 @@ let package = Package( .library(name: "ContainerizationExtras", targets: ["ContainerizationExtras"]), .library(name: "ContainerizationArchive", targets: ["ContainerizationArchive"]), .library(name: "VminitdCore", targets: ["VminitdCore", "Cgroup", "LCShim"]), + .library(name: "CloudHypervisor", targets: ["CloudHypervisor"]), .executable(name: "cctl", targets: ["cctl"]), ], dependencies: [ .package(url: "https://github.com/apple/swift-log.git", from: "1.10.1"), .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.7.0"), - .package(url: "https://github.com/apple/swift-collections.git", from: "1.1.4"), + .package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"), .package(url: "https://github.com/apple/swift-crypto.git", from: "3.0.0"), .package(url: "https://github.com/grpc/grpc-swift-2.git", from: "2.3.0"), - .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "2.4.4"), + .package(url: "https://github.com/grpc/grpc-swift-nio-transport.git", from: "2.9.0"), .package(url: "https://github.com/grpc/grpc-swift-protobuf.git", from: "2.2.0"), .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.36.0"), .package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"), @@ -65,12 +66,15 @@ let package = Package( .product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"), .product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"), .product(name: "_NIOFileSystem", package: "swift-nio"), + "CloudHypervisor", "ContainerizationArchive", "ContainerizationOCI", "ContainerizationOS", "ContainerizationIO", "ContainerizationExtras", "ContainerizationEXT4", + "ContainerizationNetlink", + "CShim", ], exclude: [ "../Containerization/SandboxContext/SandboxContext.proto" @@ -91,7 +95,7 @@ let package = Package( ), .testTarget( name: "ContainerizationUnitTests", - dependencies: ["Containerization"], + dependencies: ["Containerization", "CloudHypervisor"], path: "Tests/ContainerizationTests", resources: [ .copy("ImageTests/Resources/scratch.tar"), @@ -102,6 +106,7 @@ let package = Package( name: "ContainerizationEXT4", dependencies: [ "ContainerizationArchive", + .product(name: "OrderedCollections", package: "swift-collections"), .product(name: "SystemPackage", package: "swift-system"), "ContainerizationOS", ], @@ -260,6 +265,30 @@ let package = Package( .target( name: "CShim" ), + .target( + name: "CloudHypervisor", + dependencies: [ + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), + ], + exclude: [ + "README.md" + ] + ), + .testTarget( + name: "CloudHypervisorTests", + dependencies: [ + "CloudHypervisor", + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "NIOConcurrencyHelpers", package: "swift-nio"), + ] + ), .target( name: "LCShim", path: "vminitd/Sources/LCShim" @@ -297,7 +326,6 @@ let package = Package( ] ) -#if os(macOS) package.targets.append( .executableTarget( name: "containerization-integration", @@ -311,4 +339,3 @@ package.targets.append( path: "Sources/Integration" ) ) -#endif diff --git a/README.md b/README.md index 7e81eb170..f658b08dd 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,22 @@ Containerization executes each Linux container inside of its own lightweight vir The API allows the runtime environment to be configured and containerized processes to be launched. `vminitd` provides I/O, signals, and events to the calling process when a process is run. +## Backends + +Containerization abstracts the VMM behind the `VirtualMachineManager` / +`VirtualMachineInstance` protocols and ships two implementations: + +- **macOS — Virtualization.framework** (`VZVirtualMachineManager`). The shipping path on Apple silicon. Uses Apple's `Virtualization` framework directly; no extra binaries required. +- **Linux — cloud-hypervisor + KVM** (`CHVirtualMachineManager`). One `cloud-hypervisor` subprocess per VM, controlled over its REST-on-UDS API by the standalone [`CloudHypervisor`](./Sources/CloudHypervisor) Swift package. Block storage uses virtio-blk, shared directories use virtio-fs (one `virtiofsd` per share), networking uses TAP, and the guest agent is reached over cloud-hypervisor's hybrid vsock — same `vminitd` contract as the macOS path, so guest-side semantics are unchanged. + +The Linux backend requires: + +- `cloud-hypervisor` and `virtiofsd` on the host. Both are looked up on `PATH` by default; `CHVirtualMachineManager.init` accepts explicit URLs to override. `virtiofsd` is resolved lazily — a VM that uses only block-device mounts can run without it installed at all. Recent stable releases of each are recommended (smoke testing pins specific versions). +- KVM access (`/dev/kvm` readable + writable by the calling user). +- Pre-staged TAP / bridge / NAT plumbing if the container needs networking. `TAPInterface` consumes an existing TAP device by name; bringing it up, attaching it to a bridge, and configuring NAT or routing is the caller's responsibility. + +The integration test suite (`make linux-integration`) runs inside an apple/container Linux VM with nested virt enabled (`container run --virtualization`). The kata kernel fetched by `make fetch-default-kernel` does not enable KVM, so the integration suite uses the in-repo kernel at `kernel/vmlinux-arm64` (or `kernel/vmlinuz-x86_64` on x86_64 hosts) instead — build it with `make -C kernel` before invoking `make linux-integration`. On Linux the suite runs only the cross-platform scenarios that don't depend on macOS-only types; the full suite remains macOS-only for now. + ## Requirements To build the Containerization package, you need: @@ -84,31 +100,19 @@ Set the active developer directory to the installed Xcode (replace ` ``` -Install [Swiftly](https://github.com/swiftlang/swiftly), [Swift](https://www.swift.org), and [Static Linux SDK](https://www.swift.org/documentation/articles/static-linux-getting-started.html): +The Linux guest init (`vminitd`/`vmexec`) is compiled as a static binary +*inside a Linux container* rather than cross-compiled on your Mac, so no Swift +toolchain, Swiftly, or Static Linux SDK setup is required on the host. Install +the [`container`](https://github.com/apple/container) CLI, which the build uses +to compile the guest: ```bash -make cross-prep +# Install per https://github.com/apple/container, then verify it is on PATH: +container --version ``` -If you use a custom terminal application, you may need to move this command from `.zprofile` to `.zshrc` (replace ``): - -```bash -# Added by swiftly -. "/Users//.swiftly/env.sh" -``` - -Restart the terminal application. Ensure this command returns `/Users//.swiftly/bin/swift` (replace ``): - -```bash -which swift -``` - -If you've installed or used a Static Linux SDK previously, you may need to remove older SDK versions from the system (replace ``): - -```bash -swift sdk list -swift sdk remove -``` +The first build automatically builds the Linux dev image used to compile the +guest, which can take a few minutes. ## Build the package diff --git a/Sources/CShim/cz_tap.c b/Sources/CShim/cz_tap.c new file mode 100644 index 000000000..9f4621f56 --- /dev/null +++ b/Sources/CShim/cz_tap.c @@ -0,0 +1,78 @@ +/* + * Copyright © 2026 Apple Inc. and the Containerization project authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +#if defined(__linux__) + +#include "cz_tap.h" + +#include +#include +#include /* struct ifreq, IFNAMSIZ */ +#include +#include +#include + +/* + * Avoid — the Static Linux SDK (musl) used to cross-compile + * vminitd ships from musl but not the linux kernel UAPI headers. + * The TUN ioctl number and flags are stable Linux ABI; redeclare locally. + * + * TUNSETIFF = _IOW('T', 202, int): + * dir=IOC_WRITE(1)<<30 | size(4)<<16 | type('T'=0x54)<<8 | nr(202=0xCA) + * = 0x400454CA + * Architecture-independent (Linux's ioctl encoding is the same on x86/arm). + */ +#ifndef TUNSETIFF +#define TUNSETIFF 0x400454CAu +#endif +#ifndef IFF_TAP +#define IFF_TAP 0x0002 +#endif +#ifndef IFF_NO_PI +#define IFF_NO_PI 0x1000 +#endif + +int cz_tap_create(const char *requested_name, char *out_name, size_t out_name_len) { + if (out_name == NULL || out_name_len < IFNAMSIZ) { + return -EINVAL; + } + + int fd = open("/dev/net/tun", O_RDWR | O_CLOEXEC); + if (fd < 0) { + return -errno; + } + + struct ifreq ifr; + memset(&ifr, 0, sizeof(ifr)); + ifr.ifr_flags = IFF_TAP | IFF_NO_PI; + if (requested_name != NULL && requested_name[0] != '\0') { + strncpy(ifr.ifr_name, requested_name, IFNAMSIZ - 1); + } + + if (ioctl(fd, TUNSETIFF, &ifr) < 0) { + int saved = errno; + close(fd); + return -saved; + } + + /* Copy out the resolved name. ifr.ifr_name is always NUL-terminated + * within IFNAMSIZ by the kernel. */ + memset(out_name, 0, out_name_len); + strncpy(out_name, ifr.ifr_name, IFNAMSIZ - 1); + return fd; +} + +#endif /* __linux__ */ diff --git a/Sources/CShim/include/cz_tap.h b/Sources/CShim/include/cz_tap.h new file mode 100644 index 000000000..e293b7fb3 --- /dev/null +++ b/Sources/CShim/include/cz_tap.h @@ -0,0 +1,39 @@ +/* + * 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 __CZ_TAP_H +#define __CZ_TAP_H + +#include + +/* + * Open /dev/net/tun, ioctl(TUNSETIFF) with IFF_TAP|IFF_NO_PI, and write the + * resolved interface name into `out_name` (must be at least 16 bytes). + * + * If `requested_name` is non-NULL and non-empty, it is the desired name; the + * kernel may rename it on collision (rare). If NULL or empty, the kernel + * picks a name like "tap%d". + * + * Returns the open fd on success, -errno on failure. + * + * Linux-only — the implementation in cz_tap.c is gated on __linux__. The + * declaration is left unconditional so Swift's clang importer can see it + * regardless of whose target's preprocessor defines reach the modulemap. + * On non-Linux targets the symbol is not provided; do not call. + */ +int cz_tap_create(const char *requested_name, char *out_name, size_t out_name_len); + +#endif /* __CZ_TAP_H */ diff --git a/Sources/CloudHypervisor/CloudHypervisor+Client.swift b/Sources/CloudHypervisor/CloudHypervisor+Client.swift new file mode 100644 index 000000000..2c4a67a24 --- /dev/null +++ b/Sources/CloudHypervisor/CloudHypervisor+Client.swift @@ -0,0 +1,169 @@ +//===----------------------------------------------------------------------===// +// 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 Logging +import NIOCore +import NIOHTTP1 +import NIOPosix + +extension CloudHypervisor { + /// A high-level client for Cloud Hypervisor's REST API over a Unix Domain Socket. + /// + /// Use ``init(socketPath:eventLoopGroup:logger:)`` to construct a client, then + /// call endpoint-specific methods (added as extensions in `Endpoints/`). + /// + /// The internal `get(_:)` / `put(_:)` / `put(_:body:)` helpers are used by + /// endpoint extensions in A8-A10 and are intentionally not public. + public final class Client: Sendable { + private let http: HTTPOverUDSClient + private let group: any EventLoopGroup + private let ownsGroup: Bool + private let encoder: JSONEncoder + private let decoder: JSONDecoder + + /// Create a client that communicates with Cloud Hypervisor over the given socket. + /// + /// - Parameters: + /// - socketPath: A `file://` URL whose `.path` points to the socket. + /// - eventLoopGroup: The NIO event loop group to use. When `nil` the client + /// creates and owns its own group. Callers wanting deterministic + /// resource release should pass a group they manage and call + /// ``shutdown()`` themselves; the deinit fallback shuts down + /// asynchronously and may outlive the `Client` instance briefly. + /// - logger: Logger for transport-level diagnostics. + /// - requestTimeout: Per-request deadline. A request that does not + /// complete within this window fails with + /// ``CloudHypervisor/Error/transport(_:)``. Defaults to 30 seconds. + /// - Throws: ``CloudHypervisor/Error/invalidSocketPath(_:)`` when `socketPath` + /// is not a `file://` URL. + public init( + socketPath: URL, + eventLoopGroup: (any EventLoopGroup)? = nil, + logger: Logger = Logger(label: "CloudHypervisor.Client"), + requestTimeout: TimeAmount = .seconds(30) + ) throws { + guard socketPath.isFileURL else { + throw CloudHypervisor.Error.invalidSocketPath(socketPath.absoluteString) + } + if let eventLoopGroup { + self.ownsGroup = false + self.group = eventLoopGroup + } else { + self.ownsGroup = true + self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + } + self.http = HTTPOverUDSClient( + socketPath: socketPath.path, + group: self.group, + logger: logger, + requestTimeout: requestTimeout + ) + self.encoder = JSONEncoder() + self.decoder = JSONDecoder() + } + + /// Drain the underlying `AsyncHTTPClient`, and shut down the NIO + /// event-loop group when this client owns it. Idempotent. Prefer + /// calling this explicitly over relying on the deinit fallback — + /// `shutdown()` waits for in-flight I/O to drain. + /// + /// Callers that pass in a shared `eventLoopGroup` MUST call this + /// before tearing down that group. AsyncHTTPClient parks deferred + /// connection-close work on the group's event loops after each + /// response returns; shutting the group down before that work + /// runs trips NIO's "Cannot schedule tasks on an EventLoop that + /// has already shut down" warning (and a forced crash in future + /// NIO releases). + public func shutdown() async throws { + try await http.shutdown() + if ownsGroup { + try await group.shutdownGracefully() + } + } + + deinit { + // Use the async-dispatched shutdown rather than + // `syncShutdownGracefully()`. The sync variant blocks the calling + // thread until every event loop drains, which deadlocks if deinit + // happens to run on one of the group's event loop threads (e.g. + // the last release came from inside a NIO callback). The + // callback-based variant schedules shutdown on its own queue and + // returns immediately — at the cost of giving up any signal that + // shutdown completed. Callers who need that signal should call + // `shutdown()` explicitly before letting the client deinit. + if ownsGroup { + group.shutdownGracefully(queue: .global()) { _ in } + } + } + + // MARK: - Internal request dispatch helpers + // + // Endpoint extensions (A8/A9/A10) call these to build their public API. + // They are internal (not public) because all public surface lives in those + // extensions. + + /// GET `path`, decode the response body as `Response`. + func get(_ path: String) async throws -> Response { + try await sendAndDecode(method: .GET, path: path, body: nil) + } + + /// PUT `path` with no body, discard the response. + func put(_ path: String) async throws { + try await sendVoid(method: .PUT, path: path, body: nil) + } + + /// PUT `path` with a JSON-encoded body, discard the response. + func put(_ path: String, body: Body) async throws { + let data = try encoder.encode(body) + try await sendVoid(method: .PUT, path: path, body: data) + } + + /// PUT `path` with a JSON-encoded body, decode the response as `Response`. + func put( + _ path: String, + body: Body + ) async throws -> Response { + let data = try encoder.encode(body) + return try await sendAndDecode(method: .PUT, path: path, body: data) + } + + // MARK: - Private machinery + + private func sendAndDecode( + method: HTTPMethod, + path: String, + body: Data? + ) async throws -> Response { + let resp = try await http.send(method: method, uri: path, body: body) + guard (200..<300).contains(Int(resp.status.code)) else { + throw CloudHypervisor.Error.http(status: resp.status, body: resp.body) + } + do { + return try decoder.decode(Response.self, from: resp.body) + } catch { + throw CloudHypervisor.Error.decoding(error, body: resp.body) + } + } + + private func sendVoid(method: HTTPMethod, path: String, body: Data?) async throws { + let resp = try await http.send(method: method, uri: path, body: body) + guard (200..<300).contains(Int(resp.status.code)) else { + throw CloudHypervisor.Error.http(status: resp.status, body: resp.body) + } + } + } +} diff --git a/Sources/CloudHypervisor/CloudHypervisor+Error.swift b/Sources/CloudHypervisor/CloudHypervisor+Error.swift new file mode 100644 index 000000000..942e1bfea --- /dev/null +++ b/Sources/CloudHypervisor/CloudHypervisor+Error.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// 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 NIOHTTP1 + +extension CloudHypervisor { + public enum Error: Swift.Error, Sendable { + case transport(any Swift.Error) + case http(status: HTTPResponseStatus, body: Data) + case decoding(any Swift.Error, body: Data) + case invalidSocketPath(String) + } +} diff --git a/Sources/CloudHypervisor/CloudHypervisor.swift b/Sources/CloudHypervisor/CloudHypervisor.swift new file mode 100644 index 000000000..d7231180d --- /dev/null +++ b/Sources/CloudHypervisor/CloudHypervisor.swift @@ -0,0 +1,17 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum CloudHypervisor {} diff --git a/Sources/CloudHypervisor/Endpoints/Client+Hotplug.swift b/Sources/CloudHypervisor/Endpoints/Client+Hotplug.swift new file mode 100644 index 000000000..4eeb52e9b --- /dev/null +++ b/Sources/CloudHypervisor/Endpoints/Client+Hotplug.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension CloudHypervisor.Client { + /// Hotplug a virtio-blk disk device into a running VM. + /// + /// Maps to `PUT /api/v1/vm.add-disk` in the Cloud Hypervisor REST API. + public func vmAddDisk(_ config: CloudHypervisor.DiskConfig) async throws -> CloudHypervisor.PciDeviceInfo { + try await put("/api/v1/vm.add-disk", body: config) + } + + /// Hotplug a virtio-fs filesystem device into a running VM. + /// + /// Maps to `PUT /api/v1/vm.add-fs` in the Cloud Hypervisor REST API. + public func vmAddFs(_ config: CloudHypervisor.FsConfig) async throws -> CloudHypervisor.PciDeviceInfo { + try await put("/api/v1/vm.add-fs", body: config) + } + + /// Hotplug a virtio-net network device into a running VM. + /// + /// Maps to `PUT /api/v1/vm.add-net` in the Cloud Hypervisor REST API. + public func vmAddNet(_ config: CloudHypervisor.NetConfig) async throws -> CloudHypervisor.PciDeviceInfo { + try await put("/api/v1/vm.add-net", body: config) + } + + /// Hotplug a virtio-vsock device into a running VM. + /// + /// Maps to `PUT /api/v1/vm.add-vsock` in the Cloud Hypervisor REST API. + public func vmAddVsock(_ config: CloudHypervisor.VsockConfig) async throws -> CloudHypervisor.PciDeviceInfo { + try await put("/api/v1/vm.add-vsock", body: config) + } + + /// Remove a hotplugged device from a running VM by its identifier. + /// + /// Maps to `PUT /api/v1/vm.remove-device` in the Cloud Hypervisor REST API. + public func vmRemoveDevice(id: String) async throws { + struct Request: Encodable, Sendable { let id: String } + try await put("/api/v1/vm.remove-device", body: Request(id: id)) + } +} diff --git a/Sources/CloudHypervisor/Endpoints/Client+VM.swift b/Sources/CloudHypervisor/Endpoints/Client+VM.swift new file mode 100644 index 000000000..b1f79986b --- /dev/null +++ b/Sources/CloudHypervisor/Endpoints/Client+VM.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension CloudHypervisor.Client { + /// Create a VM with the given configuration. + /// + /// Maps to `PUT /api/v1/vm.create` in the Cloud Hypervisor REST API. + public func vmCreate(_ config: CloudHypervisor.VmConfig) async throws { + try await put("/api/v1/vm.create", body: config) + } + + /// Boot the VM (transition from Created → Running). + /// + /// Maps to `PUT /api/v1/vm.boot` in the Cloud Hypervisor REST API. + public func vmBoot() async throws { + try await put("/api/v1/vm.boot") + } + + /// Shut down the VM. + /// + /// Maps to `PUT /api/v1/vm.shutdown` in the Cloud Hypervisor REST API. + public func vmShutdown() async throws { + try await put("/api/v1/vm.shutdown") + } + + /// Retrieve runtime information about the VM. + /// + /// Maps to `GET /api/v1/vm.info` in the Cloud Hypervisor REST API. + public func vmInfo() async throws -> CloudHypervisor.VmInfo { + try await get("/api/v1/vm.info") + } + + /// Pause the running VM. + /// + /// Maps to `PUT /api/v1/vm.pause` in the Cloud Hypervisor REST API. + public func vmPause() async throws { + try await put("/api/v1/vm.pause") + } + + /// Resume a paused VM. + /// + /// Maps to `PUT /api/v1/vm.resume` in the Cloud Hypervisor REST API. + public func vmResume() async throws { + try await put("/api/v1/vm.resume") + } +} diff --git a/Sources/CloudHypervisor/Endpoints/Client+VMM.swift b/Sources/CloudHypervisor/Endpoints/Client+VMM.swift new file mode 100644 index 000000000..fb9566393 --- /dev/null +++ b/Sources/CloudHypervisor/Endpoints/Client+VMM.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension CloudHypervisor.Client { + /// Ping the Cloud Hypervisor VMM process and return its version information. + /// + /// Maps to `GET /api/v1/vmm.ping` in the Cloud Hypervisor REST API. + public func vmmPing() async throws -> CloudHypervisor.VmmPingResponse { + try await get("/api/v1/vmm.ping") + } + + /// Request the Cloud Hypervisor VMM process to shut down gracefully. + /// + /// Maps to `PUT /api/v1/vmm.shutdown` in the Cloud Hypervisor REST API. + public func vmmShutdown() async throws { + try await put("/api/v1/vmm.shutdown") + } + + /// Retrieve information about the Cloud Hypervisor VMM process. + /// + /// Maps to `GET /api/v1/vmm.info` in the Cloud Hypervisor REST API. + public func vmmInfo() async throws -> CloudHypervisor.VmmInfo { + try await get("/api/v1/vmm.info") + } +} diff --git a/Sources/CloudHypervisor/HTTPOverUDS.swift b/Sources/CloudHypervisor/HTTPOverUDS.swift new file mode 100644 index 000000000..96ecf44f9 --- /dev/null +++ b/Sources/CloudHypervisor/HTTPOverUDS.swift @@ -0,0 +1,202 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import AsyncHTTPClient +import Foundation +import Logging +import NIOConcurrencyHelpers +import NIOCore +import NIOHTTP1 + +// MARK: - HTTPResponse + +/// An HTTP response received from Cloud Hypervisor's REST API. +struct HTTPResponse: Sendable { + let status: HTTPResponseStatus + let headers: HTTPHeaders + let body: Data +} + +// MARK: - HTTPOverUDSClient + +/// A minimal HTTP/1.1 client that speaks over a Unix Domain Socket. Backed +/// by `AsyncHTTPClient` so connection lifecycle, timeout handling, and the +/// head/body/end write race we used to manage manually all live in the +/// library rather than in this file. +/// +/// AHC selects UDS via the `http+unix://` URL scheme (the supplied +/// `URL(httpURLWithSocketPath:uri:)` initializer does the percent-encoding). +/// Each `HTTPOverUDSClient` owns a fresh `HTTPClient` configured with +/// `eventLoopGroupProvider: .shared(group)` so the underlying NIO group is +/// the caller's to shut down — `httpClient.shutdown` only releases the +/// client's own state. +final class HTTPOverUDSClient: Sendable { + private let socketPath: String + private let httpClient: HTTPClient + private let logger: Logger + private let requestTimeout: TimeAmount + // One-shot flag tracking whether shutdown has been initiated, so + // explicit `shutdown()` is idempotent and `deinit` skips its fallback + // when an explicit shutdown already drained the HTTPClient. + private let didShutdown: NIOLockedValueBox + + init( + socketPath: String, + group: any EventLoopGroup, + logger: Logger, + requestTimeout: TimeAmount = .seconds(30) + ) { + self.socketPath = socketPath + self.httpClient = HTTPClient( + eventLoopGroupProvider: .shared(group), + configuration: .init() + ) + self.logger = logger + self.requestTimeout = requestTimeout + self.didShutdown = NIOLockedValueBox(false) + } + + /// Drain the underlying HTTPClient and wait for in-flight I/O to + /// finish. Idempotent — safe to call multiple times. + /// + /// MUST be called before the shared event-loop group is torn down. + /// AsyncHTTPClient leaves deferred connection-cleanup work parked on + /// the group's event loops after a response returns; if the group is + /// shut down first, that deferred work fails to schedule and SwiftNIO + /// prints "Cannot schedule tasks on an EventLoop that has already + /// shut down" (and will upgrade to a forced crash in future NIO + /// releases). + func shutdown() async throws { + let already = didShutdown.withLockedValue { state -> Bool in + if state { return true } + state = true + return false + } + if already { return } + try await httpClient.shutdown() + } + + /// Send an HTTP request and return the response. + /// + /// Translates AHC errors → ``CloudHypervisor/Error/transport(_:)`` so + /// callers see a uniform error type regardless of failure mode. + func send( + method: HTTPMethod, + uri: String, + body: Data?, + headers: HTTPHeaders = [:] + ) async throws -> HTTPResponse { + // AHC handles the percent-encoding. nil only on a path that can't + // be encoded — surface it the same way the public Client init does. + guard let url = URL(httpURLWithSocketPath: socketPath, uri: uri) else { + throw CloudHypervisor.Error.invalidSocketPath(socketPath) + } + + var request = HTTPClientRequest(url: url.absoluteString) + request.method = method + + // Preserve all caller-supplied headers verbatim. + for (name, value) in headers { + request.headers.replaceOrAdd(name: name, value: value) + } + + // `Connection: close` is preserved from the previous transport. CH + // accepts both close and keep-alive, but close is the safer default + // until we have explicit smoke coverage of long-lived per-VM + // keep-alive behavior. Each request goes to a different per-VM UDS + // anyway so there's nothing to pool. + request.headers.replaceOrAdd(name: "Connection", value: "close") + + // Body framing. CH's HTTP parser rejects body-less PUTs unless the + // request carries `Content-Length: 0` instead of falling back to + // chunked transfer encoding. + // + // How AHC actually frames the request is subtle: + // `RequestValidation.setTransportFraming` strips any manually-set + // `Content-Length` and re-derives framing from the body's known + // length. Assigning `.bytes(ByteBuffer())` (rather than leaving + // body nil) sets `bodyLength == .known(0)`, which AHC then frames + // as `Content-Length: 0` for PUT/POST per RFC 7230 §3.3.2. Leaving + // body nil would surface as `bodyLength == .unknown`, and AHC may + // emit chunked framing or no framing at all, which CH rejects. + // The explicit `Content-Length: 0` header set below is documentation + // of intent — AHC removes it before deriving framing — but the + // wire shape is determined by the empty body assignment. + // + // Regression test: ClientTests.bodylessPUTSendsContentLengthZero. + if let body, !body.isEmpty { + if request.headers["Content-Type"].isEmpty { + request.headers.add(name: "Content-Type", value: "application/json") + } + request.body = .bytes(ByteBuffer(bytes: body)) + } else { + request.headers.replaceOrAdd(name: "Content-Length", value: "0") + request.body = .bytes(ByteBuffer()) + } + + let deadline = NIODeadline.now() + requestTimeout + logger.debug("HTTPOverUDSClient: \(method) \(uri) → \(socketPath)") + + do { + let response = try await httpClient.execute( + request, + deadline: deadline, + logger: logger + ) + + // 16 MiB is far larger than any CH response we expect — vm.info, + // the largest, measures in low-KB even for many-disk VMs. The + // cap exists so a wedged server can't OOM us. + // + // Use `readableBytesView` + the Sequence-based Data init rather + // than `Data(buffer: ByteBuffer)`: the latter requires + // `NIOFoundationCompat`, which the Linux musl build doesn't + // pull in via Foundation by default. + let bodyBuffer = try await response.body.collect(upTo: 1 << 24) + let bodyData = Data(bodyBuffer.readableBytesView) + + logger.debug("HTTPOverUDSClient: \(method) \(uri) ← \(response.status.code)") + return HTTPResponse( + status: response.status, + headers: response.headers, + body: bodyData + ) + } catch let error as CloudHypervisor.Error { + throw error + } catch { + throw CloudHypervisor.Error.transport(error) + } + } + + deinit { + // Fire the callback-based shutdown only when `shutdown()` wasn't + // already called. The sync variant would deadlock if deinit + // happened to run on one of the HTTPClient's own event loops + // (commit fe1c95cf); the callback variant returns immediately at + // the cost of any completion signal. If explicit shutdown + // already ran, the HTTPClient is drained and a second call would + // just return `alreadyShutdown` — but it can still try to + // schedule the callback on the (now-dead) event loop, which is + // exactly the failure mode this whole flag guards against. + let already = didShutdown.withLockedValue { state -> Bool in + if state { return true } + state = true + return false + } + guard !already else { return } + httpClient.shutdown { _ in } + } +} diff --git a/Sources/CloudHypervisor/README.md b/Sources/CloudHypervisor/README.md new file mode 100644 index 000000000..9f8d3971f --- /dev/null +++ b/Sources/CloudHypervisor/README.md @@ -0,0 +1,95 @@ +# CloudHypervisor + +A standalone Swift library for driving the [cloud-hypervisor](https://github.com/cloud-hypervisor/cloud-hypervisor) REST API over a Unix domain socket. The package compiles on both macOS and Linux, though `cloud-hypervisor` itself only runs on Linux. + +## Dependencies + +- [swift-nio](https://github.com/apple/swift-nio): `NIOCore`, `NIOPosix`, `NIOHTTP1`, `NIOConcurrencyHelpers` +- [swift-log](https://github.com/apple/swift-log): `Logging` + +There are no transitive dependencies on any other `containerization` library types. + +## Usage + +```swift +import CloudHypervisor + +let client = try CloudHypervisor.Client( + socketPath: URL(filePath: "/tmp/ch-foo/api.sock") +) + +try await client.vmmPing() +try await client.vmCreate(VmConfig(/* ... */)) +try await client.vmBoot() +``` + +### Full example with shared event loop group + +```swift +import CloudHypervisor +import NIOPosix + +let group = MultiThreadedEventLoopGroup(numberOfThreads: 2) +defer { try? group.syncShutdownGracefully() } + +let client = try CloudHypervisor.Client( + socketPath: URL(filePath: "/run/ch/vm0.sock"), + eventLoopGroup: group +) + +let info = try await client.vmInfo() +print(info.state) +``` + +## Supported Endpoints (v1) + +### VMM + +- `vmmPing() -> VmmPingResponse` — verify the VMM process is alive +- `vmmShutdown()` — shut down the VMM process +- `vmmInfo() -> VmmInfo` — query VMM-level metadata + +### VM Lifecycle + +- `vmCreate(_ config: VmConfig)` — define a new VM +- `vmBoot()` — start the VM +- `vmShutdown()` — gracefully shut down the VM +- `vmInfo() -> VmInfo` — query VM state and configuration +- `vmPause()` — pause a running VM +- `vmResume()` — resume a paused VM + +### Hotplug + +- `vmAddDisk(_ config: DiskConfig) -> PciDeviceInfo` — hot-add a block device +- `vmAddFs(_ config: FsConfig) -> PciDeviceInfo` — hot-add a virtio-fs share +- `vmAddNet(_ config: NetConfig) -> PciDeviceInfo` — hot-add a network device +- `vmAddVsock(_ config: VsockConfig) -> PciDeviceInfo` — hot-add a vsock device +- `vmRemoveDevice(id: String)` — hot-remove a device by ID + +## Minimum Supported cloud-hypervisor Version + +The package targets the `/api/v1/` REST namespace. It is tested against **cloud-hypervisor v40** and later. Earlier releases may be missing endpoints or use incompatible JSON schemas. + +## Error Model + +All failures are reported through `CloudHypervisor.Error`: + +- `.transport(any Swift.Error)` — a network or NIO-level failure before the HTTP response was received +- `.http(status:body:)` — the server responded with a non-2xx HTTP status; `body` contains the raw response bytes +- `.decoding(any Swift.Error, body:)` — the response had a 2xx status but JSON decoding failed; `body` is the raw bytes for diagnostics +- `.invalidSocketPath(String)` — the URL passed to `Client.init` is not a `file://` URL + +Non-2xx responses always produce `.http`, never a decode error, so callers can distinguish protocol-level errors from unexpected payloads. + +## Concurrency + +`Client` is `Sendable` and all endpoint methods are `async throws`. Each call opens a fresh TCP-over-UDS connection to cloud-hypervisor and closes it when the response is complete. + +By default the client creates and owns a `MultiThreadedEventLoopGroup` and shuts it down in `deinit`. If you already have an event loop group (e.g. from NIO or another library), pass it via the `eventLoopGroup:` parameter — in that case the client does **not** shut the group down on `deinit`, leaving lifecycle management to the caller. + +## Non-Goals (v1) + +- Not a high-level VM orchestration layer — for that, use the `Containerization` library. +- Not exhaustive coverage of cloud-hypervisor's full OpenAPI surface — only the 14 endpoints listed above are implemented; additional endpoints can be added incrementally. +- No connection pooling — a fresh connection is opened per request, which is appropriate for low-volume control-plane use. +- No streaming response bodies — response payloads are buffered in memory before decoding. diff --git a/Sources/CloudHypervisor/Types/DeviceConfigs.swift b/Sources/CloudHypervisor/Types/DeviceConfigs.swift new file mode 100644 index 000000000..e54877d5a --- /dev/null +++ b/Sources/CloudHypervisor/Types/DeviceConfigs.swift @@ -0,0 +1,242 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +extension CloudHypervisor { + // MARK: - ImageType + + /// On-disk format of a `DiskConfig`'s backing file. When omitted on the + /// wire, cloud-hypervisor defaults to `Unknown` and rejects writes to + /// the disk (logging "Attempting to write to sector 0 on a disk without + /// specifying image_type"); always set this explicitly. + /// + /// Raw values match the Rust `block::ImageType` enum variants used in + /// CH's JSON serialization (PascalCase) — these differ from the + /// lowercase tokens accepted on the `--disk` CLI flag. + public enum ImageType: String, Sendable, Codable, Equatable { + case raw = "Raw" + case qcow2 = "Qcow2" + case fixedVhd = "FixedVhd" + case vhdx = "Vhdx" + case unknown = "Unknown" + } + + // MARK: - DiskConfig + + /// Virtio-blk disk configuration. + /// + /// Maps to `DiskConfig` in the Cloud Hypervisor OpenAPI spec. + public struct DiskConfig: Sendable, Codable, Equatable { + /// Path to the disk image file. + public var path: String + /// Open the disk in read-only mode. + public var readonly: Bool? + /// Use O_DIRECT for disk I/O. + public var direct: Bool? + /// Enable IOMMU for this device. + public var iommu: Bool? + /// Optional device identifier. + public var id: String? + /// PCI segment to attach the device to. + public var pciSegment: UInt16? + /// On-disk format of the backing file. + public var imageType: ImageType? + + public init( + path: String, + readonly: Bool? = nil, + direct: Bool? = nil, + iommu: Bool? = nil, + id: String? = nil, + pciSegment: UInt16? = nil, + imageType: ImageType? = nil + ) { + self.path = path + self.readonly = readonly + self.direct = direct + self.iommu = iommu + self.id = id + self.pciSegment = pciSegment + self.imageType = imageType + } + + enum CodingKeys: String, CodingKey { + case path + case readonly + case direct + case iommu + case id + case pciSegment = "pci_segment" + case imageType = "image_type" + } + } + + // MARK: - NetConfig + + /// Virtio-net network device configuration. + /// + /// Maps to `NetConfig` in the Cloud Hypervisor OpenAPI spec. + public struct NetConfig: Sendable, Codable, Equatable { + /// TAP device name on the host. + public var tap: String? + /// IPv4 address for the device. + public var ip: String? + /// IPv4 subnet mask. + public var mask: String? + /// MAC address for the device. + public var mac: String? + /// Maximum transmission unit. + public var mtu: Int? + /// Number of virtio queues. + public var numQueues: Int? + /// Size of each virtio queue. + public var queueSize: Int? + /// Optional device identifier. + public var id: String? + + public init( + tap: String? = nil, + ip: String? = nil, + mask: String? = nil, + mac: String? = nil, + mtu: Int? = nil, + numQueues: Int? = nil, + queueSize: Int? = nil, + id: String? = nil + ) { + self.tap = tap + self.ip = ip + self.mask = mask + self.mac = mac + self.mtu = mtu + self.numQueues = numQueues + self.queueSize = queueSize + self.id = id + } + + enum CodingKeys: String, CodingKey { + case tap + case ip + case mask + case mac + case mtu + case numQueues = "num_queues" + case queueSize = "queue_size" + case id + } + } + + // MARK: - FsConfig + + /// Virtio-fs filesystem device configuration. + /// + /// Maps to `FsConfig` in the Cloud Hypervisor OpenAPI spec. + public struct FsConfig: Sendable, Codable, Equatable { + /// Filesystem tag used by the guest to mount. + public var tag: String + /// Path to the virtiofsd Unix socket. + public var socket: String + /// Number of virtio queues. + public var numQueues: Int? + /// Size of each virtio queue. + public var queueSize: Int? + /// Optional device identifier. + public var id: String? + /// PCI segment to attach the device to. + public var pciSegment: UInt16? + + public init( + tag: String, + socket: String, + numQueues: Int? = nil, + queueSize: Int? = nil, + id: String? = nil, + pciSegment: UInt16? = nil + ) { + self.tag = tag + self.socket = socket + self.numQueues = numQueues + self.queueSize = queueSize + self.id = id + self.pciSegment = pciSegment + } + + enum CodingKeys: String, CodingKey { + case tag + case socket + case numQueues = "num_queues" + case queueSize = "queue_size" + case id + case pciSegment = "pci_segment" + } + } + + // MARK: - VsockConfig + + /// Virtio-vsock configuration. + /// + /// Maps to `VsockConfig` in the Cloud Hypervisor OpenAPI spec. + public struct VsockConfig: Sendable, Codable, Equatable { + /// Context ID (CID) for the vsock device. + public var cid: UInt32 + /// Path to the vsock Unix socket on the host. + public var socket: String + /// Enable IOMMU for this device. + public var iommu: Bool? + /// Optional device identifier. + public var id: String? + + public init( + cid: UInt32, + socket: String, + iommu: Bool? = nil, + id: String? = nil + ) { + self.cid = cid + self.socket = socket + self.iommu = iommu + self.id = id + } + + enum CodingKeys: String, CodingKey { + case cid + case socket + case iommu + case id + } + } + + // MARK: - PciDeviceInfo + + /// PCI device identifier returned by Cloud Hypervisor after device add. + /// + /// Maps to `PciDeviceInfo` in the Cloud Hypervisor OpenAPI spec. + public struct PciDeviceInfo: Sendable, Codable, Equatable { + /// Device identifier string. + public var id: String + /// PCI Bus:Device.Function address (e.g. `"0000:00:03.0"`). + public var bdf: String + + public init(id: String, bdf: String) { + self.id = id + self.bdf = bdf + } + + enum CodingKeys: String, CodingKey { + case id + case bdf + } + } +} diff --git a/Sources/CloudHypervisor/Types/VmConfig.swift b/Sources/CloudHypervisor/Types/VmConfig.swift new file mode 100644 index 000000000..429b7db95 --- /dev/null +++ b/Sources/CloudHypervisor/Types/VmConfig.swift @@ -0,0 +1,187 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +extension CloudHypervisor { + // MARK: - VmConfig + + /// Top-level VM boot / create payload. + /// + /// Maps to `VmConfig` in the Cloud Hypervisor OpenAPI spec. + public struct VmConfig: Sendable, Codable, Equatable { + public var cpus: CpusConfig + public var memory: MemoryConfig + public var payload: PayloadConfig + public var disks: [DiskConfig]? + public var net: [NetConfig]? + public var fs: [FsConfig]? + public var vsock: VsockConfig? + public var console: ConsoleConfig + public var serial: ConsoleConfig + + public init( + cpus: CpusConfig, + memory: MemoryConfig, + payload: PayloadConfig, + disks: [DiskConfig]? = nil, + net: [NetConfig]? = nil, + fs: [FsConfig]? = nil, + vsock: VsockConfig? = nil, + console: ConsoleConfig, + serial: ConsoleConfig + ) { + self.cpus = cpus + self.memory = memory + self.payload = payload + self.disks = disks + self.net = net + self.fs = fs + self.vsock = vsock + self.console = console + self.serial = serial + } + + enum CodingKeys: String, CodingKey { + case cpus + case memory + case payload + case disks + case net + case fs + case vsock + case console + case serial + } + } + + // MARK: - CpusConfig + + /// CPU configuration for a VM. + /// + /// Maps to `CpusConfig` in the Cloud Hypervisor OpenAPI spec. + public struct CpusConfig: Sendable, Codable, Equatable { + /// Number of vCPUs to boot with. + public var bootVcpus: Int + /// Maximum number of vCPUs (for hotplug). + public var maxVcpus: Int + + public init(bootVcpus: Int, maxVcpus: Int) { + self.bootVcpus = bootVcpus + self.maxVcpus = maxVcpus + } + + enum CodingKeys: String, CodingKey { + case bootVcpus = "boot_vcpus" + case maxVcpus = "max_vcpus" + } + } + + // MARK: - MemoryConfig + + /// Memory configuration for a VM. + /// + /// Maps to `MemoryConfig` in the Cloud Hypervisor OpenAPI spec. + public struct MemoryConfig: Sendable, Codable, Equatable { + /// RAM size in bytes. + public var size: UInt64 + /// Hotplug memory size in bytes. + public var hotplugSize: UInt64? + /// Enable memory merging (KSM). + public var mergeable: Bool? + /// Use a shared memory mapping (`MAP_SHARED`). Required when any + /// vhost-user device (e.g. virtio-fs / virtiofsd) is attached — + /// CH otherwise rejects `vm.boot` with "Using vhost-user requires + /// using shared memory or huge pages". + public var shared: Bool? + + public init(size: UInt64, hotplugSize: UInt64? = nil, mergeable: Bool? = nil, shared: Bool? = nil) { + self.size = size + self.hotplugSize = hotplugSize + self.mergeable = mergeable + self.shared = shared + } + + enum CodingKeys: String, CodingKey { + case size + case hotplugSize = "hotplug_size" + case mergeable + case shared + } + } + + // MARK: - PayloadConfig + + /// Kernel / initramfs / cmdline payload for a VM. + /// + /// Maps to `PayloadConfig` in the Cloud Hypervisor OpenAPI spec. + public struct PayloadConfig: Sendable, Codable, Equatable { + /// Path to the uncompressed kernel image (vmlinux). + public var kernel: String + /// Optional initramfs path. + public var initramfs: String? + /// Optional kernel command line. + public var cmdline: String? + + public init(kernel: String, initramfs: String? = nil, cmdline: String? = nil) { + self.kernel = kernel + self.initramfs = initramfs + self.cmdline = cmdline + } + + enum CodingKeys: String, CodingKey { + case kernel + case initramfs + case cmdline + } + } + + // MARK: - ConsoleConfig + + /// Console / serial device configuration. + /// + /// Maps to `ConsoleConfig` in the Cloud Hypervisor OpenAPI spec. + public struct ConsoleConfig: Sendable, Codable, Equatable { + /// Console I/O mode. + /// + /// CH's OpenAPI spec uses these capitalized strings literally. + public enum Mode: String, Codable, Sendable { + case Off + case Pty + case Tty + case File + case Socket + case Null + } + + public var mode: Mode + /// Path to the output file when `mode == .File`. + public var file: String? + /// Path to the Unix socket when `mode == .Socket`. + public var socket: String? + + public init(mode: Mode, file: String? = nil, socket: String? = nil) { + self.mode = mode + self.file = file + self.socket = socket + } + + enum CodingKeys: String, CodingKey { + case mode + case file + case socket + } + } + +} diff --git a/Sources/CloudHypervisor/Types/VmInfo.swift b/Sources/CloudHypervisor/Types/VmInfo.swift new file mode 100644 index 000000000..f37f63e2c --- /dev/null +++ b/Sources/CloudHypervisor/Types/VmInfo.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +extension CloudHypervisor { + // MARK: - VmState + + /// Lifecycle state of a Cloud Hypervisor VM. + /// + /// Maps to `VmState` in the Cloud Hypervisor OpenAPI spec. + /// The raw values match CH's literal strings exactly (capitalized). + public enum VmState: String, Sendable, Codable, Equatable { + case Created + case Running + case Shutdown + case Paused + case BreakPoint + } + + // MARK: - VmInfo + + /// Response body for `GET /vm.info`. + /// + /// Maps to `VmInfo` in the Cloud Hypervisor OpenAPI spec. + /// + /// Note: the `device_tree` map (`[String: VmInfoDeviceNode]`) from the + /// upstream OpenAPI spec is omitted in this v1 implementation — no current + /// endpoint consumers require it. Add when needed. + public struct VmInfo: Sendable, Codable, Equatable { + /// The boot configuration used for this VM. + public var config: VmConfig + /// Current lifecycle state. + public var state: VmState + /// Actual memory size in bytes as reported by the VMM, if available. + public var memoryActualSize: UInt64? + + public init(config: VmConfig, state: VmState, memoryActualSize: UInt64? = nil) { + self.config = config + self.state = state + self.memoryActualSize = memoryActualSize + } + + enum CodingKeys: String, CodingKey { + case config + case state + case memoryActualSize = "memory_actual_size" + } + } + + // MARK: - VmmPingResponse + + /// Response body for `GET /vmm.ping`. + /// + /// Maps to `VmmPingResponse` in the Cloud Hypervisor OpenAPI spec. + public struct VmmPingResponse: Sendable, Codable, Equatable { + /// Cloud Hypervisor version string (e.g. `"v40.0"`). + public var version: String + /// PID of the VMM process, if provided. + public var pid: Int? + /// List of compiled-in feature flags, if provided. + public var features: [String]? + /// Build-time version string, if provided. + public var buildVersion: String? + + public init(version: String, pid: Int? = nil, features: [String]? = nil, buildVersion: String? = nil) { + self.version = version + self.pid = pid + self.features = features + self.buildVersion = buildVersion + } + + enum CodingKeys: String, CodingKey { + case version + case pid + case features + case buildVersion = "build_version" + } + } + + // MARK: - VmmInfo + + /// Response body for `GET /vmm.info`. + /// + /// Maps to a subset of the `VmmInfo` schema in the Cloud Hypervisor OpenAPI + /// spec. Only the fields needed by v1 consumers are included (YAGNI). + public struct VmmInfo: Sendable, Codable, Equatable { + /// Cloud Hypervisor version string (e.g. `"v40.0"`). + public var version: String + /// PID of the VMM process, if provided. + public var pid: Int? + /// Build-time version string, if provided. + public var buildVersion: String? + /// The currently-running VM's boot configuration, if a VM exists. + public var config: VmConfig? + + public init(version: String, pid: Int? = nil, buildVersion: String? = nil, config: VmConfig? = nil) { + self.version = version + self.pid = pid + self.buildVersion = buildVersion + self.config = config + } + + enum CodingKeys: String, CodingKey { + case version + case pid + case buildVersion = "build_version" + case config + } + } +} diff --git a/Sources/Containerization/BridgeManager.swift b/Sources/Containerization/BridgeManager.swift new file mode 100644 index 000000000..c117d1977 --- /dev/null +++ b/Sources/Containerization/BridgeManager.swift @@ -0,0 +1,399 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ContainerizationError +import ContainerizationExtras +import ContainerizationNetlink +import Foundation +import Logging + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +/// Linux-only host plumbing for a container bridge network. +/// +/// `create()` is idempotent: it brings the bridge to a known state (created +/// if absent, configured if already present), records what it changed in +/// `/run/containerization/bridge-.state`, and `delete()` reverses +/// only what was recorded. +/// +/// **NAT is opt-in.** With the default (`enableNAT: false`) `create()` only +/// brings up the bridge link and assigns the gateway IP — it does NOT touch +/// `ip_forward`, does NOT program iptables, and does NOT pick an egress +/// interface. Containers attached to the bridge can talk to each other and +/// to the host, but not to the outside world. Pass `enableNAT: true` to +/// also enable IPv4 forwarding and program a scoped MASQUERADE/FORWARD +/// pair (`-i -o `); the bridge becomes a NAT exit and the +/// host now routes guest traffic. +/// +/// Concurrent `create()`/`delete()` calls (e.g. from two `cctl run` +/// processes) serialize via `flock(LOCK_EX)` on +/// `/run/containerization/bridge-.lock`. +/// +/// Requires root (or `CAP_NET_ADMIN` plus, when NAT is enabled, the ability +/// to write `/proc/sys/...` and invoke `iptables`). +public struct BridgeManager: Sendable { + public let name: String + public let subnet: CIDRv4 + public let gateway: IPv4Address + public let mtu: UInt32 + public let egressInterface: String? + public let enableNAT: Bool + private let log: Logger + + /// - Parameters: + /// - name: bridge interface name (e.g. `cz0`). + /// - subnet: subnet to assign on the bridge. + /// - gateway: host-side IP on the bridge. Defaults to `subnet.gateway` + /// (= `subnet.lower + 1`). + /// - mtu: bridge MTU. Default 1500. + /// - egressInterface: explicit egress iface for MASQUERADE. nil = + /// auto-detect via `/proc/net/route` at `create()` time. Only used + /// when `enableNAT` is true. + /// - enableNAT: when true, program iptables MASQUERADE+FORWARD and + /// enable `net.ipv4.ip_forward`. Default false — the bridge is + /// created without external connectivity, leaving host firewall + /// policy untouched. + /// - logger: optional logger. Defaults to a `bridge`-labeled logger. + public init( + name: String, + subnet: CIDRv4, + gateway: IPv4Address? = nil, + mtu: UInt32 = 1500, + egressInterface: String? = nil, + enableNAT: Bool = false, + logger: Logger? = nil + ) { + self.name = Self.validateInterfaceName(name) + self.subnet = subnet + self.gateway = gateway ?? subnet.gateway + self.mtu = mtu + self.egressInterface = egressInterface.map(Self.validateInterfaceName) + self.enableNAT = enableNAT + self.log = logger ?? Logger(label: "com.apple.containerization.bridge") + } + + /// Reject obviously-bogus interface names before they hit netlink or + /// `iptables`. This is a defense-in-depth check; the kernel and + /// `iptables` themselves will also reject pathological inputs, but doing + /// it here surfaces the error in a callable Swift API rather than as a + /// netlink rc or iptables exit. Asserts (rather than throws) — these + /// constraints are static, so a violation is a programming error. + private static func validateInterfaceName(_ name: String) -> String { + // IFNAMSIZ on Linux is 16 (15 usable + NUL). iptables itself caps + // at 15. Names with `/`, whitespace, or NUL are kernel-rejected. + precondition(!name.isEmpty, "interface name must be non-empty") + precondition(name.utf8.count <= 15, "interface name '\(name)' exceeds IFNAMSIZ-1 (15)") + precondition( + !name.contains(where: { $0.isWhitespace || $0 == "/" || $0 == "\0" || $0 == ":" }), + "interface name '\(name)' contains invalid characters" + ) + return name + } + + /// Idempotent create. + public func create() throws { + try Self.ensureStateDirectory() + let lock = try FileLock(path: Self.lockPath(for: name)) + try lock.withExclusive { + try createLocked() + } + } + + /// Idempotent delete. No-op when the bridge does not exist. + public func delete() throws { + try Self.ensureStateDirectory() + let lock = try FileLock(path: Self.lockPath(for: name)) + try lock.withExclusive { + try deleteLocked() + } + } + + private func createLocked() throws { + let session = NetlinkSession(socket: try DefaultNetlinkSocket(), log: log) + let stateURL = URL(fileURLWithPath: Self.statePath(for: name)) + + // Preserve `prevIpForward` across re-runs: a second NAT-enabled + // create() call would otherwise read the value the FIRST run left + // behind ("1") and clobber the original prior state, so delete() + // couldn't restore. + let priorState: BridgeState? = (try? Data(contentsOf: stateURL)) + .flatMap { try? BridgeState.decode($0) } + + // 1. Bridge link. + do { + try session.linkAddBridge(name: name) + log.info("created bridge \(name)") + } catch { + // EEXIST is fine; treat any error as "maybe it exists" and probe. + // `linkGet` throws ENODEV when the iface is absent (rather than + // returning an empty array), so coalesce both shapes to "absent". + let existing = (try? session.linkGet(interface: name)) ?? [] + if existing.isEmpty { + throw ContainerizationError( + .internalError, + message: "linkAddBridge \(name) failed and bridge does not exist: \(error)" + ) + } + log.debug("bridge \(name) already exists") + } + + // 2. Address (gateway/prefix) on the bridge. + let cidr = try CIDRv4(gateway, prefix: subnet.prefix) + do { + try session.addressAdd(interface: name, ipv4Address: cidr) + } catch { + // EEXIST tolerated; netlink layer doesn't expose errno cleanly, + // so log and continue. linkSet/up below will fail visibly if the + // bridge state is actually broken. + log.debug("addressAdd \(cidr) on \(name) returned \(error) (likely already set)") + } + + // 3. Up + MTU. + try session.linkSet(interface: name, up: true, mtu: mtu) + + // NAT is opt-in but sticky: once enabled by a previous create(), + // subsequent create() calls without --enable-nat leave the existing + // rules and ip_forward state in place. Otherwise `cctl run` + // (defaults to NAT off) called after `cctl bridge create + // --enable-nat` would silently disable the NAT the user explicitly + // turned on. delete() always reverses whatever the state file + // records. + let effectiveNAT = enableNAT || (priorState?.natEnabled ?? false) + guard effectiveNAT else { + let state = BridgeState(natEnabled: false) + try state.encode().write(to: stateURL) + log.info("bridge \(name) ready (subnet \(subnet), NAT disabled)") + return + } + + // 4. ip_forward: read what's currently on the host, decide what to + // record. If we already have a NAT-enabled state file from a prior + // create(), keep its `prevIpForward` (it's the *original* prior + // value); otherwise record what we just read. + let currentIpForward = (try? Self.readSysctl("net/ipv4/ip_forward")) ?? "0" + let prevIpForward = (priorState?.natEnabled == true ? priorState?.prevIpForward : nil) ?? currentIpForward + if currentIpForward != "1" { + try Self.writeSysctl("net/ipv4/ip_forward", value: "1") + } + + // 5. Egress iface — explicit override or auto-detect. + let egress: String + if let explicit = egressInterface { + egress = explicit + } else if let detected = HostDefaultRoute.currentEgress() { + egress = detected + } else { + throw ContainerizationError( + .invalidArgument, + message: "no default route on host; pass egressInterface explicitly" + ) + } + + // 6. Record state BEFORE iptables. If a later iptables -A fails, + // delete() still has authority to clean up partial rules; if we + // deferred the write until after, a mid-failure would orphan rules + // with no record. + let state = BridgeState( + natEnabled: true, + prevIpForward: prevIpForward, + egressInterface: egress + ) + try state.encode().write(to: stateURL) + + // 7. iptables rules — idempotent. The FORWARD rule is scoped to + // `-i -o ` so the host doesn't become an + // unrestricted router for guest traffic across every host iface + // (e.g. a VPN or a sibling bridge). + try IptablesRules.ensure( + table: "nat", + args: [ + "POSTROUTING", "-s", subnet.description, "!", "-o", name, "-j", "MASQUERADE", + ]) + try IptablesRules.ensure(args: [ + "FORWARD", "-i", name, "-o", egress, "-j", "ACCEPT", + ]) + try IptablesRules.ensure(args: [ + "FORWARD", "-i", egress, "-o", name, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT", + ]) + + log.info("bridge \(name) ready (subnet \(subnet), egress \(egress), NAT enabled)") + } + + private func deleteLocked() throws { + let stateURL = URL(fileURLWithPath: Self.statePath(for: name)) + let state: BridgeState? = (try? Data(contentsOf: stateURL)) + .flatMap { try? BridgeState.decode($0) } + + // 1. iptables — only if a prior create() with NAT enabled left state + // we own. The rules are keyed off subnet, bridge name, and the + // recorded egress iface, so removal is precise even when the + // host has rules from other tools. + if let state, state.natEnabled, let egress = state.egressInterface { + log.debug("removing iptables rules for bridge \(name) (egress \(egress))") + IptablesRules.remove( + table: "nat", + args: [ + "POSTROUTING", "-s", subnet.description, "!", "-o", name, "-j", "MASQUERADE", + ]) + IptablesRules.remove(args: [ + "FORWARD", "-i", name, "-o", egress, "-j", "ACCEPT", + ]) + IptablesRules.remove(args: [ + "FORWARD", "-i", egress, "-o", name, "-m", "conntrack", "--ctstate", "RELATED,ESTABLISHED", "-j", "ACCEPT", + ]) + } + + // 2. Bridge link. + let session = NetlinkSession(socket: try DefaultNetlinkSocket(), log: log) + // Refuse to delete anything that isn't actually a bridge — the + // kernel exposes `/sys/class/net//bridge` only for links of + // kind=bridge, so its presence is an authoritative kind check + // without parsing IFLA_LINKINFO. This guards against `cctl bridge + // delete --name eth0` (or docker0, etc.) taking down host links. + let sysfsBridge = "/sys/class/net/\(name)/bridge" + let isBridge = FileManager.default.fileExists(atPath: sysfsBridge) + let exists = !((try? session.linkGet(interface: name)) ?? []).isEmpty + if exists && !isBridge { + throw ContainerizationError( + .invalidArgument, + message: "refusing to delete \(name): exists but is not a bridge interface" + ) + } + do { + try session.linkDel(name: name) + log.info("deleted bridge \(name)") + } catch { + // ENODEV-like: nothing to do. + log.debug("linkDel \(name) returned \(error) (likely already absent)") + } + + // 3. Restore ip_forward only if this bridge's create()-with-NAT set + // it from 0 AND no other containerization bridge still has NAT + // enabled. ip_forward is a single global sysctl shared by every + // bridge, so it must be reference-counted against the on-disk state + // files rather than blindly reset — otherwise tearing down one NAT + // bridge would disable forwarding for a sibling that still needs it. + // + // (Torn down in the order that removes the original flipper first — + // or two NAT bridges torn down concurrently — may leave ip_forward=1 + // after the last bridge is gone. That's the safe direction: + // forwarding with no bridge or iptables rules attached is inert, and + // a reboot clears it. Erroneously forcing it to 0 under a live NAT + // bridge is the failure this guards against.) + let otherNAT = Self.otherNATEnabledBridgesExist(excluding: name) + if state?.natEnabled == true, state?.prevIpForward == "0", !otherNAT { + try? Self.writeSysctl("net/ipv4/ip_forward", value: "0") + } + + // 4. Remove state file. + try? FileManager.default.removeItem(at: stateURL) + } + + // MARK: - Paths / sysctl helpers + + private static let stateDir = "/run/containerization" + + private static func statePath(for name: String) -> String { + "\(stateDir)/bridge-\(name).state" + } + + private static func lockPath(for name: String) -> String { + "\(stateDir)/bridge-\(name).lock" + } + + private static func ensureStateDirectory() throws { + try FileManager.default.createDirectory( + atPath: stateDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o755] + ) + } + + private static func readSysctl(_ path: String) throws -> String { + let url = URL(fileURLWithPath: "/proc/sys/\(path)") + return try String(contentsOf: url, encoding: .utf8) + .trimmingCharacters(in: .whitespacesAndNewlines) + } + + private static func writeSysctl(_ path: String, value: String) throws { + let url = URL(fileURLWithPath: "/proc/sys/\(path)") + try Data((value + "\n").utf8).write(to: url) + } + + /// Whether any *other* containerization bridge still has NAT enabled, + /// determined by scanning the `bridge-*.state` files under `stateDir`. + /// Used by `delete()` to reference-count the shared global `ip_forward` + /// sysctl so tearing down one NAT bridge doesn't disable forwarding for + /// its siblings. `excluding` is this bridge's name — its own (still + /// present) state file is skipped since `delete()` removes it afterward. + private static func otherNATEnabledBridgesExist(excluding name: String) -> Bool { + let selfFile = "bridge-\(name).state" + let entries = (try? FileManager.default.contentsOfDirectory(atPath: stateDir)) ?? [] + for entry in entries { + guard entry.hasPrefix("bridge-"), entry.hasSuffix(".state"), entry != selfFile else { + continue + } + let url = URL(fileURLWithPath: "\(stateDir)/\(entry)") + guard + let data = try? Data(contentsOf: url), + let state = try? BridgeState.decode(data) + else { + continue + } + if state.natEnabled { + return true + } + } + return false + } +} + +/// `flock(2)` wrapper. Held for the duration of a closure. +struct FileLock { + let fd: Int32 + + init(path: String) throws { + let f = open(path, O_RDWR | O_CREAT | O_CLOEXEC, 0o600) + guard f >= 0 else { + throw ContainerizationError( + .internalError, + message: "open \(path) failed: errno=\(errno)" + ) + } + self.fd = f + } + + func withExclusive(_ body: () throws -> T) throws -> T { + guard flock(fd, LOCK_EX) == 0 else { + close(fd) + throw ContainerizationError( + .internalError, + message: "flock LOCK_EX failed: errno=\(errno)" + ) + } + defer { + _ = flock(fd, LOCK_UN) + close(fd) + } + return try body() + } +} +#endif diff --git a/Sources/Containerization/BridgeStateFile.swift b/Sources/Containerization/BridgeStateFile.swift new file mode 100644 index 000000000..34dbd09e8 --- /dev/null +++ b/Sources/Containerization/BridgeStateFile.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// On-disk record of state `BridgeManager.create()` modified, used by +/// `delete()` to restore the host. Stored at +/// `/run/containerization/bridge-.state` (tmpfs — gone after host +/// reboot, which is fine because reboot already clears `ip_forward` and +/// the bridge link itself). +struct BridgeState: Codable, Equatable { + /// Whether `create()` programmed NAT (iptables MASQUERADE/FORWARD + + /// `ip_forward`). When `false`, the only thing `create()` did was bring + /// the bridge up — `delete()` only needs to remove the link, not roll + /// back NAT. State files predating this field are decoded as + /// `natEnabled = true` for back-compat. + let natEnabled: Bool + + /// Value of `/proc/sys/net/ipv4/ip_forward` read at the *first* + /// `create()` call. Preserved across re-runs so `delete()` can restore + /// the host's true original value. Only set when `natEnabled`. + let prevIpForward: String? + + /// Egress interface that `create()` used in the iptables rules — passed + /// explicitly by the caller, or auto-detected from `/proc/net/route`. + /// Only set when `natEnabled`. Recorded for debug / observability and + /// to scope the FORWARD rule's `-o` clause; rule removal is keyed off + /// subnet, bridge name, and egress. + let egressInterface: String? + + init(natEnabled: Bool, prevIpForward: String? = nil, egressInterface: String? = nil) { + self.natEnabled = natEnabled + self.prevIpForward = prevIpForward + self.egressInterface = egressInterface + } + + enum CodingKeys: String, CodingKey { + case natEnabled + case prevIpForward + case egressInterface + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + // Default natEnabled to true when missing so files written by older + // versions (which always programmed NAT) still describe themselves + // accurately — delete() will roll back ip_forward / iptables. + self.natEnabled = try container.decodeIfPresent(Bool.self, forKey: .natEnabled) ?? true + self.prevIpForward = try container.decodeIfPresent(String.self, forKey: .prevIpForward) + self.egressInterface = try container.decodeIfPresent(String.self, forKey: .egressInterface) + } + + func encode() throws -> Data { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + return try encoder.encode(self) + } + + static func decode(_ data: Data) throws -> BridgeState { + try JSONDecoder().decode(BridgeState.self, from: data) + } +} diff --git a/Sources/Containerization/CHHotplugProvider.swift b/Sources/Containerization/CHHotplugProvider.swift new file mode 100644 index 000000000..870fb1ffb --- /dev/null +++ b/Sources/Containerization/CHHotplugProvider.swift @@ -0,0 +1,426 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import CloudHypervisor +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging +import NIOHTTP1 +import Synchronization + +/// Hotplug provider for the cloud-hypervisor backend. +/// +/// 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. +final class CHHotplugProvider: HotplugProvider { + struct HotplugRecord: Sendable { + let chDeviceId: String + let kind: Kind + + enum Kind: Sendable { + case block(letter: Character) + case virtiofs(tag: String) + } + } + + struct VirtiofsdTagState: Sendable { + var process: VirtiofsdProcess + var refcount: Int + var chDeviceId: String + } + + private let client: CloudHypervisor.Client + private let workDir: URL + private let virtiofsdBinaryOverride: URL? + private let allocator: any AddressAllocator + private let _mounts: Mutex<[String: [AttachedFilesystem]]> + private let _records: Mutex<[String: [HotplugRecord]]> + private let _tags: Mutex<[String: VirtiofsdTagState]> + /// Serializes per-tag virtiofsd spawn so a concurrent hotplug for the + /// same tag can't race the existence-check / process-registration window + /// (TOCTOU → orphaned virtiofsd). Held across awaits, so it must be an + /// `AsyncLock` rather than the sync `Mutex` that protects `_tags`. + private let spawnLock: AsyncLock + private let logger: Logger? + + init( + client: CloudHypervisor.Client, + workDir: URL, + virtiofsdBinary: URL?, + allocator: any AddressAllocator, + initialMounts: [String: [AttachedFilesystem]], + logger: Logger? + ) { + self.client = client + self.workDir = workDir + self.virtiofsdBinaryOverride = virtiofsdBinary + self.allocator = allocator + self._mounts = Mutex(initialMounts) + self._records = Mutex([:]) + self._tags = Mutex([:]) + self.spawnLock = AsyncLock() + self.logger = logger + } + + // MARK: - Read accessors + + var mounts: [String: [AttachedFilesystem]] { + _mounts.withLock { $0 } + } + + func withMountRegistry( + _ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T + ) rethrows -> T { + try _mounts.withLock(body) + } + + // MARK: - HotplugProvider conformance + + func hotplug(_ rootfs: Mount, id: String) async throws -> AttachedFilesystem { + switch rootfs.runtimeOptions { + case .virtioblk: + let letter = try allocator.allocate() + let chId = "blk-\(id)-\(letter)" + let disk = CloudHypervisor.DiskConfig( + path: rootfs.source, + readonly: rootfs.options.contains("ro"), + id: chId, + imageType: .raw + ) + + let pci: CloudHypervisor.PciDeviceInfo + do { + pci = try await chCall { try await self.client.vmAddDisk(disk) } + } catch { + try? allocator.release(letter) + throw error + } + + let attached = AttachedFilesystem( + type: rootfs.type, + source: "/dev/vd\(letter)", + destination: rootfs.destination, + options: rootfs.options + ) + + _records.withLock { + $0[id, default: []].append(HotplugRecord(chDeviceId: pci.id, kind: .block(letter: letter))) + } + return attached + + case .virtiofs: + // Compute the tag up front (throwing) so nothing can fail between + // committing the virtiofsd/device and recording the HotplugRecord — + // otherwise a thrown error would orphan a running virtiofsd. + let tag = try hashFilePath(path: rootfs.source) + let chDeviceId = try await ensureVirtiofsDevice( + tag: tag, + source: rootfs.source, + readonly: rootfs.options.contains("ro") + ) + _records.withLock { + $0[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag))) + } + return AttachedFilesystem( + type: rootfs.type, + source: tag, + destination: rootfs.destination, + options: rootfs.options + ) + + case .shared, .any: + throw ContainerizationError(.unsupported, message: "hotplug rootfs must be virtio-blk or virtiofs") + } + } + + func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws { + var attached: [AttachedFilesystem] = [rootfs] + for mount in additionalMounts { + attached.append(try AttachedFilesystem(mount: mount, allocator: allocator)) + } + _mounts.withLock { + $0[id, default: []].append(contentsOf: attached) + } + } + + func releaseHotplug(id: String) async throws { + let popped: [HotplugRecord] = _records.withLock { records in + let all = records[id] ?? [] + let blocks = all.filter { record in + if case .block = record.kind { return true } + return false + } + let remaining = all.filter { record in + if case .block = record.kind { return false } + return true + } + if remaining.isEmpty { + records.removeValue(forKey: id) + } else { + records[id] = remaining + } + return blocks + } + + for rec in popped { + do { + try await chCall { try await self.client.vmRemoveDevice(id: rec.chDeviceId) } + } catch { + logger?.warning("vmRemoveDevice failed for \(rec.chDeviceId): \(error)") + } + if case .block(let letter) = rec.kind { + try? allocator.release(letter) + } + } + + // 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 + } + } + } + + func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws { + let virtiofs = mounts.filter { + if case .virtiofs = $0.runtimeOptions { return true } + return false + } + guard !virtiofs.isEmpty else { return } + + // Group by tag (source-hash). Multiple Mounts to the same source dir + // share a tag and a single virtiofsd. + var byTag: [String: [Mount]] = [:] + for mount in virtiofs { + let tag = try hashFilePath(path: mount.source) + byTag[tag, default: []].append(mount) + } + + for (tag, group) in byTag { + guard let source = group.first?.source else { continue } + let readonly = group.allSatisfy { $0.options.contains("ro") } + 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. + _records.withLock { + $0[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag))) + } + } + } + + /// Ensure a virtio-fs device backed by `virtiofsd` exists for `tag`, + /// spawning one (and issuing `vm.add-fs`) on first use or bumping the + /// refcount of an existing one. Returns the cloud-hypervisor device id + /// (`vm.remove-device` keys on it). Serialized per-provider by `spawnLock` + /// so two concurrent callers for the same tag can't double-spawn. + private func ensureVirtiofsDevice(tag: String, source: String, readonly: Bool) async throws -> String { + try await spawnLock.withLock { _ in + // Refcount-bump path: a virtiofsd already serves this tag. + let cached: String? = self._tags.withLock { tags in + if var state = tags[tag] { + state.refcount += 1 + tags[tag] = state + return state.chDeviceId + } + return nil + } + if let cached { + return cached + } + + // First-spawn path: spawn → vm.add-fs → commit _tags, rolling back + // the process/socket if vm.add-fs fails. + let socket = chVirtiofsSocketURL(workDir: self.workDir, tag: tag) + let virtiofsdBinary = try CHVirtualMachineManager.resolveBinary( + self.virtiofsdBinaryOverride, + name: "virtiofsd" + ) + let process = VirtiofsdProcess( + config: .init( + binary: virtiofsdBinary, + socketPath: socket, + sharedDir: URL(fileURLWithPath: source), + readonly: readonly + ), + logger: self.logger + ) + + try await process.start() + + let fsConfig = CloudHypervisor.FsConfig( + tag: tag, + socket: socket.path, + id: "fs-\(tag)" + ) + let pci: CloudHypervisor.PciDeviceInfo + do { + pci = try await chCall { try await self.client.vmAddFs(fsConfig) } + } catch { + await process.terminate(graceSeconds: 5) + try? FileManager.default.removeItem(at: socket) + throw error + } + + self._tags.withLock { + $0[tag] = VirtiofsdTagState(process: process, refcount: 1, chDeviceId: pci.id) + } + return pci.id + } + } + + func releaseVirtioFS(id: String) async throws { + let popped: [HotplugRecord] = _records.withLock { records in + let all = records[id] ?? [] + let fs = all.filter { record in + if case .virtiofs = record.kind { return true } + return false + } + let remaining = all.filter { record in + if case .virtiofs = record.kind { return false } + return true + } + if remaining.isEmpty { + records.removeValue(forKey: id) + } else { + records[id] = remaining + } + return fs + } + + var processesToStop: [(VirtiofsdProcess, String, String)] = [] // (process, tag, chDeviceId) + for rec in popped { + guard case .virtiofs(let tag) = rec.kind else { continue } + _tags.withLock { tags in + guard var state = tags[tag] else { return } + state.refcount -= 1 + if state.refcount <= 0 { + tags.removeValue(forKey: tag) + processesToStop.append((state.process, tag, state.chDeviceId)) + } else { + tags[tag] = state + } + } + } + + for (process, tag, chDeviceId) in processesToStop { + do { + try await chCall { try await self.client.vmRemoveDevice(id: chDeviceId) } + } catch { + logger?.warning("vmRemoveDevice failed for \(chDeviceId): \(error)") + } + await process.terminate(graceSeconds: 5) + let socket = chVirtiofsSocketURL(workDir: workDir, tag: tag) + 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 + } + } + } + + // MARK: - Boot-time + shutdown hooks (used by CHVirtualMachineInstance) + + /// Record a virtiofsd that was started as part of `start()`'s initial + /// `VmConfig.fs` (rather than a runtime `vm.add-fs`). The `chDeviceId` + /// 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. + func recordBootTimeVirtiofs( + tag: String, + process: VirtiofsdProcess, + chDeviceId: String, + ownerIds: [String] + ) { + _tags.withLock { + $0[tag] = VirtiofsdTagState(process: process, refcount: ownerIds.count, chDeviceId: chDeviceId) + } + _records.withLock { records in + for id in ownerIds { + records[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag))) + } + } + } + + /// Called from `CHVirtualMachineInstance.stop()` to terminate any + /// virtiofsd subprocesses still alive. The CH side teardown is handled by + /// `chProcess.terminate()`. + func shutdown() async { + let processes = _tags.withLock { tags -> [VirtiofsdProcess] in + let all = tags.values.map(\.process) + tags.removeAll() + return all + } + _records.withLock { $0.removeAll() } + + for process in processes { + await process.terminate(graceSeconds: 5) + } + } +} + +// MARK: - Error translation + +/// Wraps a closure that may throw `CloudHypervisor.Error`, translating it into +/// `ContainerizationError` per spec §6 so callers of the public API only see +/// `ContainerizationError`. +func chCall(_ block: @Sendable () async throws -> T) async throws -> T { + do { + return try await block() + } catch let error as CloudHypervisor.Error { + switch error { + case .http(let status, let body): + let bodyStr = String(data: body, encoding: .utf8) ?? "" + if status == .notFound { + throw ContainerizationError(.notFound, message: "cloud-hypervisor 404: \(bodyStr)") + } + if status == .badRequest { + throw ContainerizationError(.invalidArgument, message: "cloud-hypervisor 400: \(bodyStr)") + } + throw ContainerizationError( + .internalError, + message: "cloud-hypervisor HTTP \(status.code): \(bodyStr)" + ) + case .transport(let underlying): + throw ContainerizationError(.internalError, message: "cloud-hypervisor transport error", cause: underlying) + case .decoding(let underlying, _): + throw ContainerizationError(.internalError, message: "cloud-hypervisor response decode error", cause: underlying) + case .invalidSocketPath(let path): + throw ContainerizationError(.invalidArgument, message: "invalid cloud-hypervisor socket path: \(path)") + } + } +} +#endif diff --git a/Sources/Containerization/CHInstanceExtension.swift b/Sources/Containerization/CHInstanceExtension.swift new file mode 100644 index 000000000..801214766 --- /dev/null +++ b/Sources/Containerization/CHInstanceExtension.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import CloudHypervisor + +/// Extension hook for `CHVirtualMachineInstance` lifecycle. Append conforming +/// types to `Configuration.extensions` to participate in VM setup and +/// teardown without subclassing. +/// +/// All methods have no-op defaults so a conforming type only needs to +/// implement the hooks it actually cares about. +public protocol CHInstanceExtension: Sendable { + /// Mutate the cloud-hypervisor `VmConfig` before the VM is created. + /// Called by `start()` after the base config is built but before + /// `vm.create` is dispatched to the VMM. + func configureCH(_ config: inout CloudHypervisor.VmConfig) throws + + /// Called once the VM has been created and booted but before + /// `start()` returns to the caller. + func didCreate(_ instance: CHVirtualMachineInstance) throws + + /// Called from `stop()` before the VM is shut down. Errors are + /// best-effort — `stop()` swallows them. + func willStop(_ instance: CHVirtualMachineInstance) async throws +} + +extension CHInstanceExtension { + public func configureCH(_ config: inout CloudHypervisor.VmConfig) throws {} + public func didCreate(_ instance: CHVirtualMachineInstance) throws {} + public func willStop(_ instance: CHVirtualMachineInstance) async throws {} +} +#endif diff --git a/Sources/Containerization/CHInterface.swift b/Sources/Containerization/CHInterface.swift new file mode 100644 index 000000000..459209f23 --- /dev/null +++ b/Sources/Containerization/CHInterface.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import CloudHypervisor +import ContainerizationExtras + +/// An `Interface` specialization that can produce a `CloudHypervisor.NetConfig` +/// describing how the cloud-hypervisor VMM should attach the device. +public protocol CHInterface { + func chNetConfig() throws -> CloudHypervisor.NetConfig +} + +/// A TAP-backed network interface for the cloud-hypervisor backend. +/// +/// IP configuration on the guest side is delegated to `vminitd` (matching the +/// macOS path). `chNetConfig()` therefore leaves CH's `ip`/`mask` fields nil — +/// those would assign an address to the host end of the TAP, which we do not +/// use. Bringing up the TAP and any bridge/NAT plumbing is the caller's +/// responsibility. +public struct TAPInterface: CHInterface, Interface, Sendable { + public let tapName: String + public let ipv4Address: CIDRv4 + public let ipv4Gateway: IPv4Address? + public let macAddress: MACAddress? + public let mtu: UInt32 + + public init( + tapName: String, + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address? = nil, + macAddress: MACAddress? = nil, + mtu: UInt32 = 1500 + ) { + self.tapName = tapName + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway + self.macAddress = macAddress + self.mtu = mtu + } + + public func chNetConfig() throws -> CloudHypervisor.NetConfig { + CloudHypervisor.NetConfig( + tap: tapName, + ip: nil, + mask: nil, + mac: macAddress?.description, + mtu: Int(mtu), + numQueues: nil, + queueSize: nil, + id: nil + ) + } +} +#endif diff --git a/Sources/Containerization/CHProcess.swift b/Sources/Containerization/CHProcess.swift new file mode 100644 index 000000000..fd1c6b180 --- /dev/null +++ b/Sources/Containerization/CHProcess.swift @@ -0,0 +1,211 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Logging +import Synchronization + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +/// A managed `cloud-hypervisor` subprocess. +/// +/// Owns spawning the binary with `--api-socket `, attaching stdout/stderr +/// per the supplied `BootLog`, and tearing it down with a SIGTERM/SIGKILL ladder. +/// One `CHProcess` per VM. Not safe to call `start()` more than once. +final class CHProcess: Sendable { + struct Config: Sendable { + let binary: URL + let apiSocketPath: URL + let bootLog: BootLog? + } + + enum ExitReason: Sendable, Equatable { + case exited(Int32) + case signalled(Int32) + case unknown + } + + private struct State { + var command: Command? + var bootLogHandle: FileHandle? + var exitTask: Task? + } + + private let config: Config + private let logger: Logger? + private let state: Mutex + + init(config: Config, logger: Logger?) { + self.config = config + self.logger = logger + self.state = Mutex(State(command: nil, bootLogHandle: nil, exitTask: nil)) + } + + /// Spawn the cloud-hypervisor binary and wait for its API socket to accept + /// connections. Throws `ContainerizationError(.timeout, ...)` if the socket + /// is not connectable within the bounded poll deadline. + func start() async throws { + let logHandle = try Self.openBootLogHandle(config.bootLog) + var arguments = ["--api-socket", config.apiSocketPath.path] + if SandboxOverrides.chSeccompDisabled { + // `--seccomp false`: cloud-hypervisor's default seccomp profile + // SIGSYS-kills the VMM on syscalls it didn't anticipate. Inside + // apple/container's --virtualization dev container the unix-vsock + // muxer's accept(2)/connect(2) interactions on per-port UDS files + // trip the filter and CH dies mid-process-start, surfacing on the + // host as "Stream unexpectedly closed" on the vminitd gRPC channel. + // Opt-in via CONTAINERIZATION_NO_CH_SECCOMP=1; default = secure. + logger?.warning( + "cloud-hypervisor launching with --seccomp false (CONTAINERIZATION_NO_CH_SECCOMP=1) — VMM seccomp filter disabled" + ) + arguments.append(contentsOf: ["--seccomp", "false"]) + } + var command = Command( + config.binary.path, + arguments: arguments, + environment: ChildEnvironment.minimal() + ) + command.stdout = logHandle + command.stderr = logHandle + // Run cloud-hypervisor in its own session. Without setsid, the VMM + // shares the parent process group and inherits SIGINT/SIGQUIT from + // the controlling TTY (e.g. Ctrl-C in `cctl run`), dying alongside + // the parent before our own teardown ladder (terminate → wait) gets + // a chance to run an orderly shutdown. + command.attrs.setsid = true + + do { + try command.start() + } catch { + try? logHandle?.close() + throw error + } + + let exitTask = Task.detached { [command, logger] in + do { + let status = try command.wait() + if status >= 128 { + return .signalled(status - 128) + } + return .exited(status) + } catch { + logger?.error("cloud-hypervisor wait failed: \(error)") + return .unknown + } + } + + state.withLock { + $0.command = command + $0.bootLogHandle = logHandle + $0.exitTask = exitTask + } + + try await waitForAPISocket() + } + + /// Wait for the subprocess to exit. Resolves with the cached `ExitReason` + /// once `wait4` has returned. Safe to call any number of times. + func wait() async -> ExitReason { + guard let task = state.withLock({ $0.exitTask }) else { + return .unknown + } + return await task.value + } + + /// Send SIGTERM, then SIGKILL after `graceSeconds` if the process is still + /// running. Returns once the process has been reaped. + func terminate(graceSeconds: UInt32) async { + guard let command = state.withLock({ $0.command }) else { return } + + _ = command.kill(SIGTERM) + + do { + try await Timeout.run(for: .seconds(Int(graceSeconds))) { + _ = await self.wait() + } + } catch { + logger?.warning("cloud-hypervisor did not exit within \(graceSeconds)s, sending SIGKILL") + _ = command.kill(SIGKILL) + _ = await wait() + } + + state.withLock { + try? $0.bootLogHandle?.close() + $0.bootLogHandle = nil + } + } + + // MARK: - Private helpers + + private static let socketDeadline: Duration = .seconds(2) + private static let socketPollInterval: Duration = .milliseconds(50) + + private func waitForAPISocket() async throws { + let clock = ContinuousClock() + let deadline = clock.now.advanced(by: Self.socketDeadline) + + while clock.now < deadline { + if Self.isAPISocketReady(at: config.apiSocketPath) { + return + } + try? await Task.sleep(for: Self.socketPollInterval) + } + + await terminate(graceSeconds: 5) + throw ContainerizationError( + .timeout, + message: "cloud-hypervisor API socket not connectable at \(config.apiSocketPath.path) within \(Self.socketDeadline)" + ) + } + + private static func isAPISocketReady(at url: URL) -> Bool { + guard let unix = try? UnixType(path: url.path) else { return false } + guard let socket = try? Socket(type: unix) else { return false } + defer { try? socket.close() } + do { + try socket.connect() + return true + } catch { + return false + } + } + + private static func openBootLogHandle(_ bootLog: BootLog?) throws -> FileHandle? { + guard let bootLog else { return nil } + switch bootLog.base { + case .file(let path, let append): + var flags = O_WRONLY | O_CREAT + flags |= append ? O_APPEND : O_TRUNC + let fd = open(path.path, flags, 0o644) + guard fd >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + return FileHandle(fileDescriptor: fd, closeOnDealloc: true) + case .fileHandle(let handle): + return handle + } + } + +} +#endif diff --git a/Sources/Containerization/CHVirtualMachineInstance.swift b/Sources/Containerization/CHVirtualMachineInstance.swift new file mode 100644 index 000000000..122f592a2 --- /dev/null +++ b/Sources/Containerization/CHVirtualMachineInstance.swift @@ -0,0 +1,753 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import CloudHypervisor +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Logging +import NIOCore +import NIOPosix +import Synchronization + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +/// Cloud-hypervisor backed virtual machine instance. +/// +/// One CH subprocess per VM. Connects to the same `Vminitd` guest agent the +/// macOS path uses, so guest-side semantics are unchanged. This file is the +/// D1 scaffold — `start`/`stop`/`dialAgent`/`dial`/`listen` throw +/// `.unsupported` until D3–D5 fill them in. Hotplug methods delegate to +/// `CHHotplugProvider` (stubbed in D0, real in D2). +public final class CHVirtualMachineInstance: Sendable { + public typealias Agent = Vminitd + + /// VM-instance configuration. Mirrors the macOS `VZVirtualMachineInstance.Configuration`, + /// minus rosetta / nested-virt (which are macOS-only concepts). + public struct Configuration: Sendable { + public var cpus: Int + public var memoryInBytes: UInt64 + public var mountsByID: [String: [Mount]] + public var interfaces: [any Interface] + public var kernel: Kernel? + public var initialFilesystem: Mount? + public var bootLog: BootLog? + public var extensions: [any Sendable] = [] + + public init() { + self.cpus = 4 + self.memoryInBytes = 1024 * 1024 * 1024 + self.mountsByID = [:] + self.interfaces = [] + } + } + + /// One boot-time virtio-blk disk. Built deterministically in `init` so + /// `start()`'s `VmConfig.disks` ordering matches the allocator letters. + struct BootDisk: Sendable { + let mount: Mount + let containerId: String? // nil for rootfs + let letter: Character + } + + // MARK: - State + + private let _state: Mutex + public var state: VirtualMachineInstanceState { + _state.withLock { $0 } + } + + public var mounts: [String: [AttachedFilesystem]] { + hotplug.mounts + } + + /// Cloud-hypervisor exposes one virtio-fs device per source-hash tag, so + /// guests must mount each tag separately at `/run/virtiofs/` rather + /// than using a single unified-share device. + public var virtiofsLayout: VirtiofsLayout { .perTag } + + /// Block-letter allocator shared between the boot wiring (already + /// reserved in `init` via `bootDisks`) and runtime hotplug (D2). + let blockAllocator: any AddressAllocator + + /// Boot-time disks in the order their letters were allocated. D3 maps + /// these into `VmConfig.disks`. + let bootDisks: [BootDisk] + + /// Owned resources + let workDir: URL + let config: Configuration + let chProcess: CHProcess + let client: CloudHypervisor.Client + let hotplug: CHHotplugProvider + let virtiofsdBinaryOverride: URL? + let group: any EventLoopGroup + private let ownsGroup: Bool + private let lock: AsyncLock + private let timeSyncer: TimeSyncer + let logger: Logger? + + /// Pre-bound vsock listener pool for stdio. apple/container's + /// `--virtualization` mode hands the cloud-hypervisor child process a + /// snapshotted filesystem view at fork time, so files written under the + /// per-VM workDir AFTER cloud-hypervisor starts are invisible to CH. + /// We work around this by binding a fixed range of `vsock.sock_` + /// listener files BEFORE launching CH; `vm.listen(_:)` then consumes + /// pre-bound entries from this pool instead of binding on demand. + /// Range covers `LinuxContainer.hostVsockPorts` initial value + /// (`0x10000000`) through the next `stdioPoolSize` sequential ports — + /// enough for `[stdin,stdout,stderr] x N` processes per VM. Bump + /// `stdioPoolSize` if you need more concurrent stdio streams than that. + static let stdioPoolBase: UInt32 = 0x1000_0000 + static let stdioPoolSize: Int = 16 + private struct PreboundListener: Sendable { + let port: UInt32 + let listenFd: Int32 + let path: URL + } + private let _stdioPool: Mutex<[UInt32: PreboundListener]> + + public convenience init( + group: (any EventLoopGroup)? = nil, + runtimeRoot: URL, + chBinary: URL, + virtiofsdBinary: URL?, + logger: Logger? = nil, + with: (inout Configuration) throws -> Void + ) throws { + var config = Configuration() + try with(&config) + try self.init( + group: group, + config: config, + runtimeRoot: runtimeRoot, + chBinary: chBinary, + virtiofsdBinary: virtiofsdBinary, + logger: logger + ) + } + + init( + group: (any EventLoopGroup)?, + config: Configuration, + runtimeRoot: URL, + chBinary: URL, + virtiofsdBinary: URL?, + logger: Logger? + ) throws { + // 1. Working directory: per-instance under runtimeRoot. Mode 0o700 + // so the per-VM UDS sockets inside (api.sock, vsock.sock, vfs-*) + // aren't reachable by other local users — the gRPC channel into + // vminitd has no peer authentication, so socket-file perms are + // the trust boundary. + let workDir = runtimeRoot.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory( + at: workDir, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + 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 + // provider so runtime add-disk picks up where boot wiring left off. + let allocator = Character.blockDeviceTagAllocator() + let inventory = try config.bootInventory(allocator: allocator) + self.blockAllocator = allocator + self.bootDisks = inventory.bootDisks + + // 3. EventLoopGroup + if let group { + self.ownsGroup = false + self.group = group + } else { + self.ownsGroup = true + self.group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + } + + // 4. CHProcess + REST client. The api socket lives next to the workDir. + let apiSocket = workDir.appendingPathComponent("api.sock") + self.chProcess = CHProcess( + config: .init( + binary: chBinary, + apiSocketPath: apiSocket, + bootLog: config.bootLog + ), + logger: logger + ) + self.client = try CloudHypervisor.Client( + socketPath: apiSocket, + eventLoopGroup: self.group, + logger: logger ?? Logger(label: "CloudHypervisor.Client") + ) + + // 5. Hotplug provider — owns the mount registry, 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, + logger: logger + ) + + // 6. Misc + self.config = config + self.virtiofsdBinaryOverride = virtiofsdBinary + self.logger = logger + self.lock = .init() + self.timeSyncer = .init(logger: logger) + self._state = Mutex(.stopped) + self._stdioPool = Mutex([:]) + } + + /// Mutate the mount 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) + } +} + +// MARK: - VirtualMachineInstance conformance (stubbed; D3–D5 fill these in) + +extension CHVirtualMachineInstance: VirtualMachineInstance { + public func start() async throws { + try await lock.withLock { _ in + guard self.state == .stopped else { + throw ContainerizationError( + .invalidState, + message: "virtual machine is not stopped (\(self.state))" + ) + } + self._state.withLock { $0 = .starting } + + do { + var vmConfig = try await self.buildVmConfig() + for ext in self.config.extensions.compactMap({ $0 as? any CHInstanceExtension }) { + try ext.configureCH(&vmConfig) + } + let finalConfig = vmConfig + + // Pre-bind the stdio vsock listener pool before launching CH. + // CH inherits a fs snapshot at fork time and is blind to + // anything we add to workDir after — see `_stdioPool` doc. + try self.prebindStdioPool() + + try await self.chProcess.start() + + try await chCall { try await self.client.vmCreate(finalConfig) } + try await chCall { try await self.client.vmBoot() } + + let fh = try await self.dialVminitdWithRetries() + let agent = try await Vminitd(connection: fh, group: self.group) + await self.timeSyncer.start(context: agent) + + for ext in self.config.extensions.compactMap({ $0 as? any CHInstanceExtension }) { + try ext.didCreate(self) + } + + self._state.withLock { $0 = .running } + } catch { + self.logger?.warning("CH VM start failed; tearing down partial resources: \(error)") + await self.teardownAfterFailedStart() + self._state.withLock { $0 = .stopped } + throw error + } + } + } + + /// Reverse the side effects of any partially-completed `start()`: + /// terminate cloud-hypervisor, kill registered virtiofsd processes, + /// close pre-bound stdio listener fds, remove the workDir, and shut + /// down the owned event-loop group. All steps are best-effort and + /// safe to invoke whether the corresponding `start()` step ran or not. + private func teardownAfterFailedStart() async { + try? await self.timeSyncer.close() + + // chProcess.terminate() is a no-op if `start()` never reached the + // spawn — otherwise SIGTERM / SIGKILL ladder + reap. + await self.chProcess.terminate(graceSeconds: 5) + + // Kills every virtiofsd registered by buildVmConfig (boot-time) or + // by an in-flight hotplug. Empty if neither ran. + await self.hotplug.shutdown() + + // Close pre-bound stdio listener fds the start path opened in + // prebindStdioPool. Files unlink with workDir below. + let leftover = self._stdioPool.withLock { pool -> [PreboundListener] in + let entries = Array(pool.values) + pool.removeAll() + return entries + } + for entry in leftover { + _ = close(entry.listenFd) + } + + try? FileManager.default.removeItem(at: self.workDir) + + // Drain the AHC HTTP client before shutting down the shared + // event-loop group, same rationale as `stop()`: AHC's deferred + // connection cleanup must not outlive the group it's parked on. + try? await self.client.shutdown() + + if self.ownsGroup { + try? await self.group.shutdownGracefully() + } + } + + public func stop() async throws { + try await lock.withLock { _ in + guard self.state == .running else { + throw ContainerizationError(.invalidState, message: "vm is not running") + } + self._state.withLock { $0 = .stopping } + + try? await self.timeSyncer.close() + + for ext in self.config.extensions.compactMap({ $0 as? any CHInstanceExtension }) { + try? await ext.willStop(self) + } + + // Best-effort graceful shutdown via REST. The CH process may + // already be on its way out, so swallow errors from these. + _ = try? await chCall { try await self.client.vmShutdown() } + _ = try? await chCall { try await self.client.vmmShutdown() } + + await self.chProcess.terminate(graceSeconds: 10) + await self.hotplug.shutdown() + + // Drain the AHC HTTP client before tearing down the shared + // event-loop group. AHC parks deferred connection-cleanup + // work on the group's event loops after each response; if we + // shut the group down with that work still pending, NIO + // prints "Cannot schedule tasks on an EventLoop that has + // already shut down" (and will hard-crash in future NIO + // releases). Must run after the last `chCall` above and + // before `group.shutdownGracefully()` below. + try? await self.client.shutdown() + + // Close any listening fds for stdio ports the test never + // consumed. The files themselves are removed when workDir is + // unlinked below. + let leftover = self._stdioPool.withLock { pool -> [PreboundListener] in + let entries = Array(pool.values) + pool.removeAll() + return entries + } + for entry in leftover { + _ = close(entry.listenFd) + } + + if self.ownsGroup { + try? await self.group.shutdownGracefully() + } + + try? FileManager.default.removeItem(at: self.workDir) + + self._state.withLock { $0 = .stopped } + } + } + + public func dialAgent() async throws -> Vminitd { + try await lock.withLock { _ in + try self.requireRunning() + let fh = try await chVsockDial( + baseSocket: self.workDir.appendingPathComponent("vsock.sock"), + port: Vminitd.port + ) + return try await Vminitd(connection: fh, group: self.group) + } + } + + public func dial(_ port: UInt32) async throws -> FileHandle { + try await lock.withLock { _ in + try self.requireRunning() + return try await chVsockDial( + baseSocket: self.workDir.appendingPathComponent("vsock.sock"), + port: port + ) + } + } + + /// Reject vsock dials when the VM isn't actually running. Without this, + /// a dial issued after `stop()` (or before `start()` finished) raced + /// against `workDir` removal and surfaced as an opaque "connect: No + /// such file or directory" instead of a clear lifecycle error. + private func requireRunning() throws { + let current = self.state + guard current == .running else { + throw ContainerizationError( + .invalidState, + message: "vm is not running (state=\(current))" + ) + } + } + + public func listen(_ port: UInt32) throws -> VsockListener { + // Consume from the pre-bound pool (see `_stdioPool` doc). + let prebound = _stdioPool.withLock { $0.removeValue(forKey: port) } + guard let prebound else { + throw ContainerizationError( + .invalidArgument, + message: "vsock port \(port) was not pre-bound; only ports " + + "\(Self.stdioPoolBase)..<\(Self.stdioPoolBase + UInt32(Self.stdioPoolSize)) " + + "are available for stdio. Increase CHVirtualMachineInstance.stdioPoolSize " + + "if you need more concurrent stdio streams per VM." + ) + } + let listenFd = prebound.listenFd + let path = prebound.path + logger?.debug("vsock listen consuming pool entry port=\(port) path=\(path.path)") + let listener = VsockListener(port: port) { [path, listenFd, logger] _ in + logger?.debug("vsock listen finishing port=\(port) closing listenFd=\(listenFd)") + _ = close(listenFd) + try? FileManager.default.removeItem(at: path) + } + let acceptLogger = logger + // The accept loop calls a blocking accept() syscall, which is + // inappropriate for Swift's cooperative thread pool: a pool thread + // pinned to accept() can't service other tasks until the syscall + // returns. With even a few leaked accept loops (e.g. when a test's + // setupIO times out and the listener is finished only when the + // 30s timer fires), Task.detached'd accept loops queue behind the + // pinned threads and never run, manifesting as the "vsock acceptLoop + // starting" log being silent and the dial-back never being seen by + // the host. Use libdispatch's global queue instead — it spawns + // OS threads on demand and is the right tool for blocking syscalls. + DispatchQueue.global(qos: .userInitiated).async { [listener, listenFd] in + acceptLogger?.debug("vsock acceptLoop starting port=\(listener.port) listenFd=\(listenFd)") + Self.acceptLoop(listenFd: listenFd, into: listener, logger: acceptLogger) + acceptLogger?.debug("vsock acceptLoop exited port=\(listener.port)") + } + return listener + } + + /// Bind every port in `stdioPoolBase../vsock.sock_`. Must run before + /// `chProcess.start()` so the files end up in CH's snapshot view of + /// the workDir. Files for ports never consumed are removed during + /// `stop()` along with the rest of `workDir`; the listening fds are + /// closed there too. + private func prebindStdioPool() throws { + let base = workDir.appendingPathComponent("vsock.sock") + var pool: [UInt32: PreboundListener] = [:] + pool.reserveCapacity(Self.stdioPoolSize) + do { + for offset in 0.. AttachedFilesystem { + try await hotplug.hotplug(block, id: id) + } + + public func releaseHotplug(id: String) async throws { + try await hotplug.releaseHotplug(id: id) + } + + public func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws { + try await hotplug.hotplugVirtioFS(mounts, id: id) + } + + public func releaseVirtioFS(id: String) async throws { + try await hotplug.releaseVirtioFS(id: id) + } + + public func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws { + try hotplug.registerMounts(id: id, rootfs: rootfs, additionalMounts: additionalMounts) + } +} + +// MARK: - VmConfig + vminitd dial helpers + +extension CHVirtualMachineInstance { + /// Build the cloud-hypervisor `VmConfig` from `config`. Spawns one + /// `virtiofsd` per unique boot-time virtiofs source-hash tag and registers + /// each with the hotplug provider so `releaseVirtioFS(id:)` and `stop()` + /// can reclaim them. + private func buildVmConfig() async throws -> CloudHypervisor.VmConfig { + guard let kernel = config.kernel else { + throw ContainerizationError(.invalidArgument, message: "kernel is required for cloud-hypervisor backend") + } + guard let rootfs = config.initialFilesystem else { + throw ContainerizationError(.invalidArgument, message: "initialFilesystem is required for cloud-hypervisor backend") + } + + // Disks: rootfs forced read-only at the device level; container 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 { + 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) + } + byTag[tag] = entry + } + } + + var fsConfigs: [CloudHypervisor.FsConfig] = [] + // Resolve virtiofsd lazily — only if we actually have any virtiofs + // mounts at boot. A block-only VM doesn't require virtiofsd. + let resolvedVirtiofsdBinary: URL? = + byTag.isEmpty + ? nil + : try CHVirtualMachineManager.resolveBinary(virtiofsdBinaryOverride, name: "virtiofsd") + for (tag, entry) in byTag { + guard let source = entry.mounts.first?.source else { continue } + guard let binary = resolvedVirtiofsdBinary else { continue } + let socket = chVirtiofsSocketURL(workDir: workDir, tag: tag) + let readonly = entry.mounts.allSatisfy { $0.options.contains("ro") } + let chDeviceId = "fs-\(tag)" + + let process = VirtiofsdProcess( + config: .init( + binary: binary, + socketPath: socket, + sharedDir: URL(fileURLWithPath: source), + readonly: readonly + ), + logger: logger + ) + try await process.start() + + hotplug.recordBootTimeVirtiofs( + tag: tag, + process: process, + chDeviceId: chDeviceId, + ownerIds: entry.owners + ) + + fsConfigs.append( + CloudHypervisor.FsConfig( + tag: tag, + socket: socket.path, + id: chDeviceId + ) + ) + } + + let net: [CloudHypervisor.NetConfig] = try config.interfaces.compactMap { + try ($0 as? any CHInterface)?.chNetConfig() + } + + let vsock = CloudHypervisor.VsockConfig( + cid: 3, + socket: workDir.appendingPathComponent("vsock.sock").path + ) + + let payload = CloudHypervisor.PayloadConfig( + kernel: kernel.path.path, + cmdline: kernel.linuxCommandline(initialFilesystem: rootfs) + ) + + return CloudHypervisor.VmConfig( + cpus: .init(bootVcpus: config.cpus, maxVcpus: config.cpus), + // `shared: true` is required as soon as any vhost-user device (e.g. + // virtiofsd) is attached — CH rejects `vm.boot` with "Using + // vhost-user requires using shared memory or huge pages" otherwise. + // We set it unconditionally because virtiofs can be added via + // hotplug after boot (CHHotplugProvider.hotplugVirtioFS), and the + // memory config can't be changed once the VM has booted. The + // MAP_SHARED-backed RAM has negligible runtime impact. + memory: .init( + size: Self.alignMemorySize(config.memoryInBytes), + shared: true + ), + payload: payload, + disks: disks.isEmpty ? nil : disks, + net: net.isEmpty ? nil : net, + fs: fsConfigs.isEmpty ? nil : fsConfigs, + vsock: vsock, + // Kernel cmdline is `console=hvc0`, so userspace (vminitd) writes + // to hvc0 — capture that to the bootlog. We deliberately disable + // the pl011 (`serial`) UART entirely with `.Off`. Any non-Off mode + // makes cloud-hypervisor APPEND `earlycon=pl011,mmio,0x...` to + // the kernel cmdline (see CH device_manager.rs add_serial_device), + // which forces every early-boot printk character through an MMIO + // trap into CH's pl011 emulator and adds ~1.5s to VM boot. We + // don't need pl011 — virtio-console is enough — so just turn it + // off. To diagnose pre-virtio-console boot, switch to `.File` and + // re-add `earlycon=pl011,mmio,0x09000000` to the cmdline. + console: Self.consoleConfig(forBootLog: config.bootLog), + serial: .init(mode: .Off) + ) + } + + /// Round `bytes` up to the nearest 2 MiB boundary. Cloud Hypervisor + /// rejects `vm.boot` with "Memory size is misaligned with default page + /// size or its hugepage size" if the memory size isn't a multiple of the + /// guest's page size; 2 MiB is a multiple of both 4 KiB and 64 KiB pages + /// and the standard hugepage size on aarch64. + private static func alignMemorySize(_ bytes: UInt64) -> UInt64 { + let alignment: UInt64 = 2 * 1024 * 1024 + let remainder = bytes % alignment + return remainder == 0 ? bytes : bytes + (alignment - remainder) + } + + private static func consoleConfig(forBootLog bootLog: BootLog?) -> CloudHypervisor.ConsoleConfig { + guard let bootLog else { return .init(mode: .Null) } + switch bootLog.base { + case .file(let path, _): + return .init(mode: .File, file: path.path) + case .fileHandle: + // Cloud Hypervisor's File mode requires a path. For raw FDs we + // could route through a pipe/relay later; for v1 fall back to + // null to avoid silently dropping logs to a wrong place. + return .init(mode: .Null) + } + } + + /// Bounded retry loop for dialing the vminitd vsock port. Absorbs the + /// short delay between `vm.boot` and the guest agent advertising the + /// CONNECT/OK protocol on the host UDS. Vminitd typically becomes ready + /// within a few hundred ms of `vm.boot` returning, so we poll fast at + /// 10 ms intervals (capped at 50 ms) to avoid burning wall-clock in + /// exponential backoff while the guest is already up. Deadline stays + /// at 60s as a safety net for the cold-cache long tail. + private func dialVminitdWithRetries( + deadline: Duration = .seconds(60), + initialDelay: Duration = .milliseconds(10) + ) async throws -> FileHandle { + let baseSocket = workDir.appendingPathComponent("vsock.sock") + let clock = ContinuousClock() + let stop = clock.now.advanced(by: deadline) + var delay = initialDelay + var lastError: any Error = ContainerizationError(.timeout, message: "could not dial vminitd") + while clock.now < stop { + do { + return try await chVsockDial(baseSocket: baseSocket, port: Vminitd.port) + } catch { + lastError = error + try? await Task.sleep(for: delay) + if delay < .milliseconds(50) { + delay = delay * 2 + } + } + } + throw ContainerizationError(.timeout, message: "could not dial vminitd within \(deadline): \(lastError)") + } + + /// Blocking accept loop driving a `VsockListener`. Runs on a detached + /// task because `accept(2)` blocks. Exits when the listening fd is + /// closed (by `VsockListener.finish()`) or the stream consumer + /// terminates. + private static func acceptLoop(listenFd: Int32, into listener: VsockListener, logger: Logger?) { + while true { + logger?.debug("vsock acceptLoop blocking on accept port=\(listener.port) listenFd=\(listenFd)") + let connFd = accept(listenFd, nil, nil) + if connFd < 0 { + let savedErrno = errno + if savedErrno == EINTR { + continue + } + logger?.debug("vsock acceptLoop accept returned \(connFd) errno=\(savedErrno) port=\(listener.port)") + return + } + logger?.debug("vsock acceptLoop accepted connFd=\(connFd) port=\(listener.port)") + let handle = FileHandle(fileDescriptor: connFd, closeOnDealloc: true) + let result = listener.yield(handle) + if case .terminated = result { + logger?.debug("vsock acceptLoop yield terminated port=\(listener.port)") + try? handle.close() + return + } + logger?.debug("vsock acceptLoop yield enqueued port=\(listener.port)") + } + } +} + +// 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. + /// + /// 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]) { + 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. + if let rootfs = self.initialFilesystem, rootfs.isBlock { + let letter = try allocator.allocate() + bootDisks.append(.init(mount: rootfs, containerId: nil, letter: letter)) + } + + 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) + } + attachments[cid] = perContainer + } + + return (attachments, bootDisks) + } +} +#endif diff --git a/Sources/Containerization/CHVirtualMachineManager.swift b/Sources/Containerization/CHVirtualMachineManager.swift new file mode 100644 index 000000000..ba20b5d1d --- /dev/null +++ b/Sources/Containerization/CHVirtualMachineManager.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ContainerizationError +import Foundation +import Logging +import NIOCore + +/// VirtualMachineManager backed by `cloud-hypervisor` + KVM on Linux. +/// +/// One subprocess per VM. The manager itself is just a factory: kernel, +/// initial filesystem, host binary paths, and a runtime root (under which +/// each instance gets its own working directory). +public struct CHVirtualMachineManager: VirtualMachineManager { + private let kernel: Kernel + private let initialFilesystem: Mount + private let chBinary: URL + private let virtiofsdBinaryOverride: URL? + private let runtimeRoot: URL + private let group: (any EventLoopGroup)? + private let logger: Logger? + + /// - Parameters: + /// - kernel: The Linux kernel image used for every VM this manager creates. + /// - initialFilesystem: The rootfs `Mount` (typically the `init.ext4` + /// blob produced by `make init`). + /// - chBinary: Path to the `cloud-hypervisor` binary; if nil, looked + /// up on `PATH`. Validated at init time. + /// - virtiofsdBinary: Path to `virtiofsd`; if nil, looked up on `PATH` + /// lazily — only when a virtiofs share is actually used. A VM that + /// boots with only block-device mounts can run without virtiofsd + /// installed at all. + /// - runtimeRoot: Directory under which per-VM working directories are + /// created. Defaults to `/run/containerization/ch`. The directory is + /// created with mode `0o700` so per-VM UDS sockets (api.sock, + /// vsock.sock, vfs-*.sock) inside aren't reachable by other local + /// users. `/run` is tmpfs on every modern Linux distro, so contents + /// don't survive reboot — which is the right lifecycle for VM + /// runtime state. + /// - group: Optional shared NIO `EventLoopGroup`; if nil, each VM + /// spawns its own. + public init( + kernel: Kernel, + initialFilesystem: Mount, + chBinary: URL? = nil, + virtiofsdBinary: URL? = nil, + runtimeRoot: URL? = nil, + group: (any EventLoopGroup)? = nil, + logger: Logger? = nil + ) throws { + self.kernel = kernel + self.initialFilesystem = initialFilesystem + self.chBinary = try Self.resolveBinary(chBinary, name: "cloud-hypervisor") + if let virtiofsdBinary { + // Validate explicit overrides at init time so misconfiguration + // surfaces early. PATH-lookup deferral only applies when no + // override is supplied. + guard FileManager.default.isExecutableFile(atPath: virtiofsdBinary.path) else { + throw ContainerizationError( + .notFound, + message: "virtiofsd not executable at \(virtiofsdBinary.path)" + ) + } + } + self.virtiofsdBinaryOverride = virtiofsdBinary + let runtimeRoot = runtimeRoot ?? URL(fileURLWithPath: "/run/containerization/ch") + try FileManager.default.createDirectory( + at: runtimeRoot, + withIntermediateDirectories: true, + attributes: [.posixPermissions: 0o700] + ) + // createDirectory only sets attributes on directories it creates, so + // explicitly tighten an existing dir if a previous run left it at a + // looser mode. + try? FileManager.default.setAttributes([.posixPermissions: 0o700], ofItemAtPath: runtimeRoot.path) + self.runtimeRoot = runtimeRoot + self.group = group + self.logger = logger + } + + public func create(config: some VMCreationConfig) async throws -> any VirtualMachineInstance { + let vmConfig = config.configuration + + var instanceConfig = CHVirtualMachineInstance.Configuration() + instanceConfig.cpus = vmConfig.cpus + instanceConfig.memoryInBytes = vmConfig.memoryInBytes + instanceConfig.interfaces = vmConfig.interfaces + instanceConfig.mountsByID = vmConfig.mountsByID + instanceConfig.bootLog = vmConfig.bootLog + instanceConfig.extensions = vmConfig.extensions + instanceConfig.kernel = kernel + instanceConfig.initialFilesystem = initialFilesystem + + return try CHVirtualMachineInstance( + group: group, + config: instanceConfig, + runtimeRoot: runtimeRoot, + chBinary: chBinary, + virtiofsdBinary: virtiofsdBinaryOverride, + logger: logger + ) + } + + // MARK: - Binary resolution + + /// Resolve a binary path, accepting an explicit override or falling back to + /// `PATH` lookup. Used both at manager init for `cloud-hypervisor` and + /// lazily by the CH instance / hotplug provider for `virtiofsd` so a + /// block-only VM doesn't require virtiofsd to be installed. + static func resolveBinary(_ override: URL?, name: String) throws -> URL { + if let override { + guard FileManager.default.isExecutableFile(atPath: override.path) else { + throw ContainerizationError( + .notFound, + message: "\(name) not executable at \(override.path)" + ) + } + return override + } + + let path = ProcessInfo.processInfo.environment["PATH"] ?? "/usr/local/bin:/usr/bin:/bin" + for dir in path.split(separator: ":") where !dir.isEmpty { + let candidate = URL(fileURLWithPath: String(dir)).appendingPathComponent(name) + if FileManager.default.isExecutableFile(atPath: candidate.path) { + return candidate + } + } + + throw ContainerizationError( + .notFound, + message: "could not find \(name) on PATH; pass an explicit URL to CHVirtualMachineManager.init" + ) + } +} +#endif diff --git a/Sources/Containerization/ContainerManager.swift b/Sources/Containerization/ContainerManager.swift index 48682b387..27e9fbe47 100644 --- a/Sources/Containerization/ContainerManager.swift +++ b/Sources/Containerization/ContainerManager.swift @@ -302,15 +302,17 @@ public struct ContainerManager: Sendable { if let imageConfig { config.process = .init(from: imageConfig) } - if networking, let interface = try self.network?.createInterface(id) { - config.interfaces = [interface] - guard let gateway = interface.ipv4Gateway else { - throw ContainerizationError( - .invalidState, - message: "missing ipv4 gateway for container \(id)" - ) + if networking { + if let interface = try self.network?.createInterface(id) { + config.interfaces = [interface] + guard let gateway = interface.ipv4Gateway else { + throw ContainerizationError( + .invalidState, + message: "missing ipv4 gateway for container \(id)" + ) + } + config.dns = .init(nameservers: [gateway.description]) } - config.dns = .init(nameservers: [gateway.description]) } config.bootLog = BootLog.file(path: self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log")) try configuration(&config) @@ -340,7 +342,7 @@ public struct ContainerManager: Sendable { private func unpack(image: Image, destination: URL, size: UInt64, progress: ProgressHandler? = nil) async throws -> Mount { do { - let unpacker = EXT4Unpacker(blockSizeInBytes: size) + let unpacker = EXT4Unpacker(capacityInBytes: size) return try await unpacker.unpack(image, for: .current, at: destination, progress: progress) } catch let err as ContainerizationError { if err.code == .exists { @@ -371,10 +373,10 @@ public struct ContainerManager: Sendable { } } -extension CIDRv4 { +extension CIDRv6 { /// The gateway address of the network. - public var gateway: IPv4Address { - IPv4Address(self.lower.value + 1) + public var gateway: IPv6Address { + IPv6Address(self.lower.value + 1) } } diff --git a/Sources/Containerization/HTTP2ConnectBufferingHandler.swift b/Sources/Containerization/HTTP2ConnectBufferingHandler.swift deleted file mode 100644 index 4cdb3ce47..000000000 --- a/Sources/Containerization/HTTP2ConnectBufferingHandler.swift +++ /dev/null @@ -1,90 +0,0 @@ -//===----------------------------------------------------------------------===// -// Copyright © 2026 Apple Inc. and the Containerization project authors. -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// https://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -//===----------------------------------------------------------------------===// - -import ContainerizationError -import GRPCCore -import GRPCNIOTransportCore -import NIOCore -import NIOPosix - -/// Buffers incoming bytes until the full gRPC HTTP/2 pipeline is configured, then replays them. -/// -/// This prevents the race condition where the vminitd server's initial HTTP/2 SETTINGS frame -/// arrives and is discarded before `configureGRPCClientPipeline` has finished installing -/// `ClientConnectionHandler`. -/// -/// The handler is added via `ClientBootstrap.channelInitializer`, which runs before -/// `registerAlreadyConfigured0` adds the fd to epoll/kqueue — guaranteeing it is in place -/// before any bytes can arrive on the socket. -/// -/// When `NIOHTTP2Handler` is added to the pipeline (inside `configureGRPCClientPipeline`), its -/// `handlerAdded` fires an outbound flush (the HTTP/2 client preface). We intercept that flush -/// and schedule a deferred removal via the event loop. Because `configureGRPCClientPipeline` runs -/// as a single synchronous event loop task, the deferred removal is guaranteed to run after that -/// entire task completes — i.e., after `ClientConnectionHandler` is also in the pipeline. -/// Buffered bytes are replayed atomically as part of the pipeline removal. - -// FIXME: This handler is needed until the swift GRPC libraries offers us a way to create a -// client transport from an existing fd. Remove this type when such an API exists. -public final class HTTP2ConnectBufferingHandler: ChannelDuplexHandler, RemovableChannelHandler { - public typealias InboundIn = ByteBuffer - public typealias InboundOut = ByteBuffer - public typealias OutboundIn = ByteBuffer - public typealias OutboundOut = ByteBuffer - - private var removalScheduled = false - private var bufferedReads: [NIOAny] = [] - - public init() {} - - public func channelRead(context: ChannelHandlerContext, data: NIOAny) { - bufferedReads.append(data) - } - - public func channelReadComplete(context: ChannelHandlerContext) { - // Suppress while buffering; a single readComplete is emitted after replay. - } - - public func flush(context: ChannelHandlerContext) { - if !removalScheduled { - removalScheduled = true - // Defer removal to the next event loop task. configureGRPCClientPipeline runs as a - // single synchronous event loop task, so this deferred task is guaranteed to run - // after that whole task completes (including ClientConnectionHandler being added). - context.eventLoop.assumeIsolatedUnsafeUnchecked().execute { - context.pipeline.syncOperations.removeHandler(self, promise: nil) - } - } - context.flush() - } - - public func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { - var didRead = false - while !bufferedReads.isEmpty { - context.fireChannelRead(bufferedReads.removeFirst()) - didRead = true - } - if didRead { - context.fireChannelReadComplete() - } - context.leavePipeline(removalToken: removalToken) - } - - public func channelInactive(context: ChannelHandlerContext) { - bufferedReads.removeAll() - context.fireChannelInactive() - } -} diff --git a/Sources/Containerization/HostDefaultRoute.swift b/Sources/Containerization/HostDefaultRoute.swift new file mode 100644 index 000000000..32785d7b7 --- /dev/null +++ b/Sources/Containerization/HostDefaultRoute.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Reads the host's default IPv4 egress interface from `/proc/net/route`. +/// +/// `/proc/net/route` columns (tab-separated): +/// +/// Iface Destination Gateway Flags RefCnt Use Metric Mask MTU Window IRTT +/// +/// Numeric fields are hex with bytes in network order (so `0102A8C0` is +/// `192.168.2.1`). Pure-string parsing keeps this cross-platform-testable +/// even though `/proc/net/route` itself only exists on Linux. +enum HostDefaultRoute { + /// `RTF_GATEWAY` from ``. Set on rows representing a gateway route. + private static let RTF_GATEWAY: UInt32 = 0x0002 + + /// Parse the contents of `/proc/net/route` and return the iface for the + /// default route (destination 0.0.0.0 with `RTF_GATEWAY`). When multiple + /// default routes exist, the one with the lowest metric wins. + static func parseEgress(procNetRoute contents: String) -> String? { + var best: (iface: String, metric: UInt64)? + for (i, line) in contents.split(separator: "\n", omittingEmptySubsequences: true).enumerated() { + if i == 0 { continue } // header + let cols = line.split(separator: "\t", omittingEmptySubsequences: false) + .map { $0.trimmingCharacters(in: .whitespaces) } + guard cols.count >= 11 else { continue } + let iface = String(cols[0]) + let destination = cols[1] + let flagsHex = cols[3] + let metricStr = cols[6] + + guard destination == "00000000" else { continue } + guard let flags = UInt32(flagsHex, radix: 16), + flags & RTF_GATEWAY != 0 + else { continue } + let metric = UInt64(metricStr) ?? UInt64.max + if let current = best, metric >= current.metric { + continue + } + best = (iface, metric) + } + return best?.iface + } + + /// Read `/proc/net/route` and return the default-route iface, or nil if + /// the file is missing or no default route exists. + static func currentEgress() -> String? { + guard let contents = try? String(contentsOfFile: "/proc/net/route", encoding: .utf8) else { + return nil + } + return parseEgress(procNetRoute: contents) + } +} diff --git a/Sources/Containerization/Image/ImageStore/ImageStore+ReferenceManager.swift b/Sources/Containerization/Image/ImageStore/ImageStore+ReferenceManager.swift index f898bc6e4..bf746867b 100644 --- a/Sources/Containerization/Image/ImageStore/ImageStore+ReferenceManager.swift +++ b/Sources/Containerization/Image/ImageStore/ImageStore+ReferenceManager.swift @@ -49,7 +49,7 @@ extension ImageStore { private func save(_ state: State) throws { let statePath = self.path.appendingPathComponent("state.json") - try JSONEncoder().encode(state).write(to: statePath) + try JSONEncoder().encode(state).write(to: statePath, options: .atomic) } public func delete(reference: String) throws { diff --git a/Sources/Containerization/Image/InitImage.swift b/Sources/Containerization/Image/InitImage.swift index a63f0dce2..5a4ef58ba 100644 --- a/Sources/Containerization/Image/InitImage.swift +++ b/Sources/Containerization/Image/InitImage.swift @@ -34,7 +34,7 @@ public struct InitImage: Sendable { extension InitImage { /// Unpack the initial filesystem for the desired platform at a given path. public func initBlock(at: URL, for platform: SystemPlatform) async throws -> Mount { - let unpacker = EXT4Unpacker(blockSizeInBytes: 512.mib()) + let unpacker = EXT4Unpacker(capacityInBytes: 512.mib()) var fs = try await unpacker.unpack(self.image, for: platform.ociPlatform(), at: at) fs.options = ["ro"] return fs diff --git a/Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift b/Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift index 0a4ebcdf9..f69449625 100644 --- a/Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift +++ b/Sources/Containerization/Image/Unpacker/EXT4Unpacker.swift @@ -23,10 +23,17 @@ import Foundation import SystemPackage public struct EXT4Unpacker: Unpacker { - let blockSizeInBytes: UInt64 + let capacityInBytes: UInt64 - public init(blockSizeInBytes: UInt64) { - self.blockSizeInBytes = blockSizeInBytes + let journal: EXT4.JournalConfig? + + /// Creates an unpacker that extracts images into EXT4 filesystems. + /// - Parameters: + /// - capacityInBytes: The minimum usable capacity of the filesystem image, in bytes. + /// - journal: The journal configuration to use, or nil for no journaling. + public init(capacityInBytes: UInt64, journal: EXT4.JournalConfig? = nil) { + self.capacityInBytes = capacityInBytes + self.journal = journal } /// Performs the unpacking of a tar archive into a filesystem. @@ -42,7 +49,8 @@ public struct EXT4Unpacker: Unpacker { let cleanedPath = try prepareUnpackPath(path: path) let filesystem = try EXT4.Formatter( FilePath(cleanedPath), - minDiskSize: blockSizeInBytes + minDiskSize: capacityInBytes, + journal: journal ) defer { try? filesystem.close() } @@ -71,7 +79,8 @@ public struct EXT4Unpacker: Unpacker { FilePath( cleanedPath ), - minDiskSize: blockSizeInBytes + minDiskSize: capacityInBytes, + journal: journal ) defer { try? filesystem.close() } diff --git a/Sources/Containerization/Interface.swift b/Sources/Containerization/Interface.swift index 80eac49b1..a95c58f9d 100644 --- a/Sources/Containerization/Interface.swift +++ b/Sources/Containerization/Interface.swift @@ -22,9 +22,16 @@ public protocol Interface: Sendable { /// Example: `192.168.64.3/24` var ipv4Address: CIDRv4 { get } - /// The IP address for the default route, or nil for no default route. + /// The IPv4 gateway address for the default route, or nil for no IPv4 default route. var ipv4Gateway: IPv4Address? { get } + /// The interface IPv6 address and subnet prefix length, as a CIDRv6 address, or nil for no IPv6 address. + /// Example: `fd00::1/64` + var ipv6Address: CIDRv6? { get } + + /// The IPv6 gateway address for the default route, or nil for no IPv6 default route. + var ipv6Gateway: IPv6Address? { get } + /// The interface MAC address, or nil to auto-configure the address. var macAddress: MACAddress? { get } @@ -34,4 +41,6 @@ public protocol Interface: Sendable { extension Interface { public var mtu: UInt32 { 1500 } + public var ipv6Address: CIDRv6? { nil } + public var ipv6Gateway: IPv6Address? { nil } } diff --git a/Sources/Containerization/IptablesRules.swift b/Sources/Containerization/IptablesRules.swift new file mode 100644 index 000000000..a62e21c1d --- /dev/null +++ b/Sources/Containerization/IptablesRules.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ContainerizationError +import ContainerizationOS +import Foundation + +/// Thin idempotent wrappers around the `iptables` CLI for use by +/// `BridgeManager`. We don't program nftables directly; modern distros +/// ship the `iptables` binary as a shim over nftables and it's universally +/// available. +enum IptablesRules { + /// Add a rule unless it already exists. `args` is the rule body + /// excluding the leading action (`-A`/`-C`/`-D`). + /// + /// Implementation: run `iptables -C ` first; if exit 0, the rule + /// exists already — return. Otherwise run `iptables -A ` and + /// throw on non-zero exit. + static func ensure(table: String? = nil, args: [String]) throws { + let tableArgs = table.map { ["-t", $0] } ?? [] + let check = try run(args: tableArgs + ["-C"] + args) + if check.exit == 0 { return } + let add = try run(args: tableArgs + ["-A"] + args) + if add.exit != 0 { + throw ContainerizationError( + .internalError, + message: """ + iptables -A \(args.joined(separator: " ")) failed (exit \(add.exit))\ + \(add.stderr.isEmpty ? "" : ": \(add.stderr)") + """ + ) + } + } + + /// Best-effort delete. Ignores non-zero exit (rule may not exist). + static func remove(table: String? = nil, args: [String]) { + let tableArgs = table.map { ["-t", $0] } ?? [] + _ = try? run(args: tableArgs + ["-D"] + args) + } + + /// Captured outcome of a single `iptables` invocation. + private struct InvocationResult { + let exit: Int32 + let stderr: String + } + + /// Run `iptables` with the given args, returning the exit status and any + /// stderr the binary emitted. Throws if no `iptables` binary is found. + private static func run(args: [String]) throws -> InvocationResult { + // ContainerizationOS.Command uses execve() under the hood, which + // requires an absolute path. Probe the two paths iptables actually + // ships at on Linux distros — /usr/sbin first (Debian, Ubuntu, + // Fedora, Alpine, RHEL), then /sbin (older / busybox-style). + let candidates = ["/usr/sbin/iptables", "/sbin/iptables"] + // Open /dev/null fresh rather than using FileHandle.nullDevice: + // swift-corelibs-foundation's nullDevice uses a sentinel fd that + // doesn't survive dup2() in Command's child, producing EBADF on exec. + // Capture stderr through a pipe so failures surface with the actual + // iptables error (locked xtables, missing kernel module, conflicting + // rule) instead of an opaque exit code. + let devNullOut = FileHandle(forWritingAtPath: "/dev/null") + let stderrPipe = Pipe() + defer { + try? devNullOut?.close() + try? stderrPipe.fileHandleForReading.close() + } + for path in candidates where FileManager.default.isExecutableFile(atPath: path) { + // `-w` makes iptables block on the xtables lock rather than + // failing (exit 4) when another actor — a sibling BridgeManager on + // a different bridge, Docker, firewalld — is mid-iptables. Accepted + // by both legacy iptables and the nft shim. + var cmd = Command(path, arguments: ["-w"] + args) + cmd.stdout = devNullOut + cmd.stderr = stderrPipe.fileHandleForWriting + try cmd.start() + // Close the parent's write end so the read end sees EOF when + // iptables exits, even if iptables itself never writes anything. + try? stderrPipe.fileHandleForWriting.close() + let exit = try cmd.wait() + let data = (try? stderrPipe.fileHandleForReading.readToEnd()) ?? Data() + let stderr = + String(data: data, encoding: .utf8)? + .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return InvocationResult(exit: exit, stderr: stderr) + } + throw ContainerizationError( + .notFound, + message: "iptables not found at /usr/sbin/iptables or /sbin/iptables; install iptables (or its nftables shim)" + ) + } +} +#endif diff --git a/Sources/Containerization/Kernel+Commandline.swift b/Sources/Containerization/Kernel+Commandline.swift new file mode 100644 index 000000000..a5ff83c48 --- /dev/null +++ b/Sources/Containerization/Kernel+Commandline.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +extension Kernel { + /// Build the `init=/sbin/vminitd` Linux kernel command line for the given + /// rootfs type. Used by both the VZ and cloud-hypervisor backends since + /// the guest's vminitd init contract is identical across VMMs. + func linuxCommandline(initialFilesystem: Mount) -> String { + var args = self.commandLine.kernelArgs + + args.append("init=/sbin/vminitd") + // rootfs is always mounted read-only. + args.append("ro") + + switch initialFilesystem.type { + case "virtiofs": + args.append(contentsOf: [ + "rootfstype=virtiofs", + "root=rootfs", + ]) + case "ext4": + args.append(contentsOf: [ + "rootfstype=ext4", + "root=/dev/vda", + ]) + default: + fatalError("unsupported initfs filesystem \(initialFilesystem.type)") + } + + if self.commandLine.initArgs.count > 0 { + args.append("--") + args.append(contentsOf: self.commandLine.initArgs) + } + + return args.joined(separator: " ") + } +} diff --git a/Sources/Containerization/Kernel.swift b/Sources/Containerization/Kernel.swift index a7c91d395..c4c2468d0 100644 --- a/Sources/Containerization/Kernel.swift +++ b/Sources/Containerization/Kernel.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import Foundation +import Logging /// An object representing a Linux kernel used to boot a virtual machine. /// In addition to a path to the kernel itself, this type stores relevant @@ -37,6 +38,11 @@ public struct Kernel: Sendable, Codable { self.kernelArgs.append("panic=\(level)") } + // Sets the log level for the Agent + mutating public func setAgentLogLevel(level: Logger.Level) { + self.initArgs.append(contentsOf: ["--log-level", level.description]) + } + /// Additional kernel arguments. public var kernelArgs: [String] /// Additional arguments passed to the Initial Process / Agent. diff --git a/Sources/Containerization/LinuxBlockIO.swift b/Sources/Containerization/LinuxBlockIO.swift new file mode 100644 index 000000000..e6858040e --- /dev/null +++ b/Sources/Containerization/LinuxBlockIO.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI + +/// Block I/O resource limits applied to the container cgroup. +public struct LinuxBlockIO: Sendable { + /// The relative weight of the cgroup for block I/O. Valid range is 10 to 1000. + public var weight: UInt16? + /// The relative weight applied to tasks of the cgroup but not their descendant cgroups. + public var leafWeight: UInt16? + /// Per-device weight overrides. + public var weightDevice: [LinuxWeightDevice] + /// Per-device read rate limits in bytes per second. + public var throttleReadBpsDevice: [LinuxThrottleDevice] + /// Per-device write rate limits in bytes per second. + public var throttleWriteBpsDevice: [LinuxThrottleDevice] + /// Per-device read rate limits in IO operations per second. + public var throttleReadIOPSDevice: [LinuxThrottleDevice] + /// Per-device write rate limits in IO operations per second. + public var throttleWriteIOPSDevice: [LinuxThrottleDevice] + + public init( + weight: UInt16? = nil, + leafWeight: UInt16? = nil, + weightDevice: [LinuxWeightDevice] = [], + throttleReadBpsDevice: [LinuxThrottleDevice] = [], + throttleWriteBpsDevice: [LinuxThrottleDevice] = [], + throttleReadIOPSDevice: [LinuxThrottleDevice] = [], + throttleWriteIOPSDevice: [LinuxThrottleDevice] = [] + ) { + self.weight = weight + self.leafWeight = leafWeight + self.weightDevice = weightDevice + self.throttleReadBpsDevice = throttleReadBpsDevice + self.throttleWriteBpsDevice = throttleWriteBpsDevice + self.throttleReadIOPSDevice = throttleReadIOPSDevice + self.throttleWriteIOPSDevice = throttleWriteIOPSDevice + } + + /// Convert to OCI format for transport. + public func toOCI() -> ContainerizationOCI.LinuxBlockIO { + ContainerizationOCI.LinuxBlockIO( + weight: self.weight, + leafWeight: self.leafWeight, + weightDevice: self.weightDevice.map { $0.toOCI() }, + throttleReadBpsDevice: self.throttleReadBpsDevice.map { $0.toOCI() }, + throttleWriteBpsDevice: self.throttleWriteBpsDevice.map { $0.toOCI() }, + throttleReadIOPSDevice: self.throttleReadIOPSDevice.map { $0.toOCI() }, + throttleWriteIOPSDevice: self.throttleWriteIOPSDevice.map { $0.toOCI() } + ) + } +} + +/// A per-device block I/O weight override. +public struct LinuxWeightDevice: Sendable { + /// The major device number. + public var major: Int64 + /// The minor device number. + public var minor: Int64 + /// The relative weight applied to the device. Valid range is 10 to 1000. + public var weight: UInt16? + /// The relative weight applied to tasks of the cgroup but not their descendant cgroups. + public var leafWeight: UInt16? + + public init( + major: Int64, + minor: Int64, + weight: UInt16? = nil, + leafWeight: UInt16? = nil + ) { + self.major = major + self.minor = minor + self.weight = weight + self.leafWeight = leafWeight + } + + /// Convert to OCI format for transport. + public func toOCI() -> ContainerizationOCI.LinuxWeightDevice { + ContainerizationOCI.LinuxWeightDevice( + major: self.major, + minor: self.minor, + weight: self.weight, + leafWeight: self.leafWeight + ) + } +} + +/// A per-device block I/O throughput limit. +public struct LinuxThrottleDevice: Sendable { + /// The major device number. + public var major: Int64 + /// The minor device number. + public var minor: Int64 + /// The rate limit applied to the device. + public var rate: UInt64 + + public init(major: Int64, minor: Int64, rate: UInt64) { + self.major = major + self.minor = minor + self.rate = rate + } + + /// Convert to OCI format for transport. + public func toOCI() -> ContainerizationOCI.LinuxThrottleDevice { + ContainerizationOCI.LinuxThrottleDevice( + major: self.major, + minor: self.minor, + rate: self.rate + ) + } +} diff --git a/Sources/Containerization/LinuxBridgedNetwork.swift b/Sources/Containerization/LinuxBridgedNetwork.swift new file mode 100644 index 000000000..de62ab684 --- /dev/null +++ b/Sources/Containerization/LinuxBridgedNetwork.swift @@ -0,0 +1,181 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ContainerizationError +import ContainerizationExtras +import ContainerizationNetlink +import Crypto +import Foundation + +/// A `Network` implementation backed by Linux TAP devices, optionally +/// enslaved to a pre-existing bridge. The bridge itself is **not** managed +/// by this type — callers own its creation, teardown, and any NAT/firewall +/// rules. This abstraction only handles per-VM TAP lifecycle and IPv4 +/// address allocation within a configured subnet. +/// +/// Mirrors the `VmnetNetwork` shape on macOS so the two backends are +/// interchangeable from a `LinuxContainer`/`Network` consumer's POV. +/// +/// Requires `CAP_NET_ADMIN` for TAP creation and bridge enslavement. +public struct LinuxBridgedNetwork: Network { + /// The IPv4 subnet from which container interfaces are allocated. + public let subnet: CIDRv4 + /// The default-route gateway for containers attached to this network. + public let ipv4Gateway: IPv4Address + /// Optional bridge name to enslave each created TAP to. + public let bridge: String? + /// MTU applied to every TAP this network creates. + public let mtu: UInt32 + + private var allocator: Allocator + private var taps: [String: TAPDevice] + + /// Per-id rotating IPv4 allocator. Mirrors `VmnetNetwork.Allocator` + /// verbatim: lower bound = `subnet.lower + 2` (gateway = `lower + 1`, + /// network = `lower`), size = `upper - lower - 3` (broadcast = `upper`, + /// also reserved). + struct Allocator: Sendable { + private let addressAllocator: any AddressAllocator + private let cidr: CIDRv4 + private var allocations: [String: UInt32] + + init(cidr: CIDRv4) throws { + self.cidr = cidr + self.allocations = [:] + let span = cidr.upper.value - cidr.lower.value + guard span >= 4 else { + throw ContainerizationError( + .invalidArgument, + message: "subnet \(cidr) has no usable host addresses (need at least 4)" + ) + } + let size = Int(span - 3) + self.addressAllocator = try UInt32.rotatingAllocator( + lower: cidr.lower.value + 2, + size: UInt32(size) + ) + } + + mutating func allocate(_ id: String) throws -> CIDRv4 { + if allocations[id] != nil { + throw ContainerizationError( + .exists, + message: "allocation with id \(id) already exists" + ) + } + let index = try addressAllocator.allocate() + allocations[id] = index + return try CIDRv4(IPv4Address(index), prefix: cidr.prefix) + } + + mutating func release(_ id: String) throws { + if let index = allocations[id] { + try addressAllocator.release(index) + allocations.removeValue(forKey: id) + } + } + } + + /// Create a Linux bridged network. + /// + /// - Parameters: + /// - subnet: The IPv4 subnet to allocate container addresses from. + /// - gateway: Default-route gateway IPv4. If nil, defaults to + /// `subnet.gateway` (= `lower + 1`). + /// - bridge: Existing bridge name to enslave each TAP to, or nil for + /// standalone TAPs. Validated at init time via netlink. + /// - mtu: MTU applied to every created TAP (default 1500). + public init( + subnet: CIDRv4, + gateway: IPv4Address? = nil, + bridge: String? = nil, + mtu: UInt32 = 1500 + ) throws { + self.subnet = subnet + self.ipv4Gateway = gateway ?? subnet.gateway + self.bridge = bridge + self.mtu = mtu + self.allocator = try Allocator(cidr: subnet) + self.taps = [:] + + if let bridge { + // Validate via the public linkGet — empty result or netlink error + // means the bridge does not exist or is unreachable. + let session = try NetlinkSession(socket: DefaultNetlinkSocket()) + do { + let links = try session.linkGet(interface: bridge) + guard !links.isEmpty else { + throw ContainerizationError( + .notFound, + message: "bridge \(bridge) not found" + ) + } + } catch let err as ContainerizationError { + throw err + } catch { + throw ContainerizationError( + .notFound, + message: "bridge \(bridge) not found: \(error)" + ) + } + } + } + + public mutating func createInterface(_ id: String) throws -> Interface? { + let cidr = try allocator.allocate(id) + let tapName = Self.derivedTAPName(forID: id) + + let device: TAPDevice + do { + device = try TAPDevice( + name: tapName, + bridge: bridge, + mtu: mtu, + macAddress: nil + ) + } catch { + // Roll back the allocator so the IP isn't leaked. + try? allocator.release(id) + throw error + } + taps[id] = device + + return TAPInterface( + tapName: device.name, + ipv4Address: cidr, + ipv4Gateway: ipv4Gateway, + macAddress: nil, + mtu: mtu + ) + } + + public mutating func releaseInterface(_ id: String) throws { + if let device = taps.removeValue(forKey: id) { + device.close() + } + try allocator.release(id) + } + + /// Derive a deterministic, IFNAMSIZ-compliant TAP name from a container id. + /// Format: `czt-<10 hex chars>` (14 chars total; IFNAMSIZ-1 = 15). + static func derivedTAPName(forID id: String) -> String { + let hash = SHA256.hash(data: Data(id.utf8)) + let hex = hash.map { String(format: "%02x", $0) }.joined() + return "czt-" + String(hex.prefix(10)) + } +} +#endif diff --git a/Sources/Containerization/LinuxContainer.swift b/Sources/Containerization/LinuxContainer.swift index 7b1395b0e..8ab69ddf5 100644 --- a/Sources/Containerization/LinuxContainer.swift +++ b/Sources/Containerization/LinuxContainer.swift @@ -57,6 +57,8 @@ public final class LinuxContainer: Container, Sendable { public var cpus: Int = 4 /// The memory in bytes to give to the container. public var memoryInBytes: UInt64 = 1024.mib() + /// Optional block I/O resource limits for the container cgroup. + public var blockIO: LinuxBlockIO? /// The hostname for the container. public var hostname: String? /// The system control options for the container. @@ -67,6 +69,16 @@ public final class LinuxContainer: Container, Sendable { public var sockets: [UnixSocketConfiguration] = [] /// The mounts for the container. public var mounts: [Mount] = LinuxContainer.defaultMounts() + /// Paths inside the container that vmexec hides from the workload. + /// Defaults to the OCI standard set (``LinuxContainer/defaultMaskedPaths()``), + /// matching the restricted capability baseline. Set to `[]` to opt out, + /// or append to extend it. + public var maskedPaths: [String] = LinuxContainer.defaultMaskedPaths() + /// Paths inside the container that vmexec marks read-only. + /// Defaults to the OCI standard set (``LinuxContainer/defaultReadonlyPaths()``), + /// matching the restricted capability baseline. Set to `[]` to opt out, + /// or append to extend it. + public var readonlyPaths: [String] = LinuxContainer.defaultReadonlyPaths() /// The DNS configuration for the container. public var dns: DNS? /// The hosts to add to /etc/hosts for the container. @@ -95,11 +107,14 @@ public final class LinuxContainer: Container, Sendable { process: LinuxProcessConfiguration, cpus: Int = 4, memoryInBytes: UInt64 = 1024.mib(), + blockIO: LinuxBlockIO? = nil, hostname: String? = nil, sysctl: [String: String] = [:], interfaces: [any Interface] = [], sockets: [UnixSocketConfiguration] = [], mounts: [Mount] = LinuxContainer.defaultMounts(), + maskedPaths: [String] = LinuxContainer.defaultMaskedPaths(), + readonlyPaths: [String] = LinuxContainer.defaultReadonlyPaths(), dns: DNS? = nil, hosts: Hosts? = nil, virtualization: Bool = false, @@ -112,11 +127,14 @@ public final class LinuxContainer: Container, Sendable { self.process = process self.cpus = cpus self.memoryInBytes = memoryInBytes + self.blockIO = blockIO self.hostname = hostname self.sysctl = sysctl self.interfaces = interfaces self.sockets = sockets self.mounts = mounts + self.maskedPaths = maskedPaths + self.readonlyPaths = readonlyPaths self.dns = dns self.hosts = hosts self.virtualization = virtualization @@ -375,7 +393,7 @@ public final class LinuxContainer: Container, Sendable { ) } - private func generateRuntimeSpec() -> Spec { + func generateRuntimeSpec() -> Spec { var spec = Self.createDefaultRuntimeSpec(id) // Process toggles. @@ -394,6 +412,8 @@ public final class LinuxContainer: Container, Sendable { // Linux toggles. spec.linux?.sysctl = config.sysctl + spec.linux?.maskedPaths = config.maskedPaths + spec.linux?.readonlyPaths = config.readonlyPaths // If the rootfs was requested as read-only, set it in the OCI spec. // We let the OCI runtime remount as ro, instead of doing it originally. @@ -410,7 +430,8 @@ public final class LinuxContainer: Container, Sendable { cpu: LinuxCPU( quota: Int64(config.cpus * 100_000), period: 100_000 - ) + ), + blockIO: config.blockIO?.toOCI() ) spec.linux?.namespaces = [ @@ -438,6 +459,44 @@ public final class LinuxContainer: Container, Sendable { ] } + /// The default set of paths to mask inside a container, matching the OCI + /// runtime spec defaults that runc and other production runtimes apply. + /// Each path is hidden from the workload (replaced by `/dev/null` for files + /// or an empty tmpfs for directories) by `vmexec` after `pivot_root`. + /// + /// Applied by default (see ``Configuration/maskedPaths``); set + /// `config.maskedPaths = []` to opt out, or append to extend the set. + public static func defaultMaskedPaths() -> [String] { + [ + "/proc/asound", + "/proc/acpi", + "/proc/kcore", + "/proc/keys", + "/proc/latency_stats", + "/proc/timer_list", + "/proc/timer_stats", + "/proc/sched_debug", + "/proc/scsi", + "/sys/firmware", + "/sys/devices/virtual/powercap", + ] + } + + /// The default set of paths to mark read-only inside a container, matching + /// the OCI runtime spec defaults that runc and other production runtimes apply. + /// + /// Applied by default (see ``Configuration/readonlyPaths``); set + /// `config.readonlyPaths = []` to opt out, or append to extend the set. + public static func defaultReadonlyPaths() -> [String] { + [ + "/proc/bus", + "/proc/fs", + "/proc/irq", + "/proc/sys", + "/proc/sysrq-trigger", + ] + } + /// A more traditional default set of mounts that OCI runtimes typically employ. public static func defaultOCIMounts() -> [Mount] { let defaultOptions = ["nosuid", "noexec", "nodev"] @@ -587,20 +646,59 @@ extension LinuxContainer { let vm = try await self.vmm.create(config: creationConfig) let relayManager = UnixSocketRelayManager(vm: vm, log: self.logger) - try await vm.start() do { + try await vm.start() + let mountsForAgent = containerMounts try await vm.withAgent { agent in try await agent.standardSetup() - // Mount the unified virtiofs share at /run/virtiofs - // All virtiofs directories appear as subdirectories here - try await agent.mount( - ContainerizationOCI.Mount( - type: "virtiofs", - source: "virtiofs", - destination: "/run/virtiofs", - options: [] - )) + // Mount the unified virtiofs share at /run/virtiofs only + // when at least one of the container's mounts is virtiofs + // — the bind-mount transform below derives its sources + // from /run/virtiofs/{tag}, so the unified share is only + // load-bearing when there are virtiofs mounts. The macOS + // VZ backend always exposes the virtiofs device (even + // with zero shares), but the cloud-hypervisor backend + // only spawns virtiofsd when shares exist; mounting an + // unbacked tag fails with EINVAL. + let hasVirtiofsMount = mountsForAgent.contains { mount in + if case .virtiofs = mount.runtimeOptions { return true } + return false + } + if hasVirtiofsMount { + // VZ exposes ONE virtio-fs device with tag "virtiofs" + // and multiple sources as subdirs (VZMultipleDirectoryShare). + // The CH backend exposes one device per source-hash + // tag instead, so the guest must mount each tag + // separately at /run/virtiofs/. The bind-mount + // transform below uses /run/virtiofs/ in both + // cases, so this branch is only about how /run/virtiofs + // gets populated. + if vm.virtiofsLayout == .perTag { + try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) + let virtiofsAttachments = (vm.mounts[self.id] ?? []).filter { $0.type == "virtiofs" } + let uniqueTags = Set(virtiofsAttachments.map(\.source)) + for tag in uniqueTags { + let dest = "/run/virtiofs/\(tag)" + try await agent.mkdir(path: dest, all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: tag, + destination: dest, + options: [] + )) + } + } else { + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: "virtiofs", + destination: "/run/virtiofs", + options: [] + )) + } + } guard let attachments = vm.mounts[self.id] else { throw ContainerizationError(.notFound, message: "rootfs mount not found") @@ -635,22 +733,12 @@ extension LinuxContainer { var defaultRouteSet = false for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" - self.logger?.debug("setting up interface \(name) with address \(i.ipv4Address)") - try await agent.addressAdd(name: name, ipv4Address: i.ipv4Address) - try await agent.up(name: name, mtu: i.mtu) - if defaultRouteSet { - continue - } - if let ipv4Gateway = i.ipv4Gateway { - if !i.ipv4Address.contains(ipv4Gateway) { - self.logger?.debug("gateway \(ipv4Gateway) is outside subnet \(i.ipv4Address), adding a route first") - try await agent.routeAddLink(name: name, dstIPv4Addr: ipv4Gateway, srcIPv4Addr: i.ipv4Address.address) - } - try await agent.routeAddDefault(name: name, ipv4Gateway: ipv4Gateway) - } else { - self.logger?.debug("no gateway for \(name)") - try await agent.routeAddDefault(name: name, ipv4Gateway: nil) - } + try await agent.setupInterface( + i, + name: name, + setDefaultRoute: !defaultRouteSet, + logger: self.logger + ) defaultRouteSet = true } @@ -1043,6 +1131,20 @@ extension LinuxContainer { } } + // Perform filesystem operations in the container. + public func filesystemOperation(operation: FilesystemOperation, path: String) async throws { + try await self.state.withLock { + let state = try $0.startedState("filesystemOperation") + try await state.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent") + } + let guestPath = URL(filePath: Self.guestRootfsPath(self.id)).appending(path: path).path + try await vminitd.filesystemOperation(operation: operation, path: guestPath) + } + } + } + private func relayUnixSocket( socket: UnixSocketConfiguration, relayManager: UnixSocketRelayManager, @@ -1259,6 +1361,7 @@ extension LinuxContainer { try await withThrowingTaskGroup(of: Void.self) { group in group.addTask { + defer { metadataCont.finish() } try await state.vm.withAgent { agent in guard let vminitd = agent as? Vminitd else { throw ContainerizationError(.unsupported, message: "copyOut requires Vminitd agent") diff --git a/Sources/Containerization/LinuxPod.swift b/Sources/Containerization/LinuxPod.swift index 1ace38b61..6275a4d49 100644 --- a/Sources/Containerization/LinuxPod.swift +++ b/Sources/Containerization/LinuxPod.swift @@ -83,6 +83,16 @@ public final class LinuxPod: Sendable { public var sysctl: [String: String] = [:] /// The mounts for the container. public var mounts: [Mount] = LinuxContainer.defaultMounts() + /// Paths inside the container that vmexec hides from the workload. + /// Defaults to the OCI standard set (``LinuxContainer/defaultMaskedPaths()``), + /// matching the restricted capability baseline. Set to `[]` to opt out, + /// or append to extend it. + public var maskedPaths: [String] = LinuxContainer.defaultMaskedPaths() + /// Paths inside the container that vmexec marks read-only. + /// Defaults to the OCI standard set (``LinuxContainer/defaultReadonlyPaths()``), + /// matching the restricted capability baseline. Set to `[]` to opt out, + /// or append to extend it. + public var readonlyPaths: [String] = LinuxContainer.defaultReadonlyPaths() /// The Unix domain socket relays to setup for the container. public var sockets: [UnixSocketConfiguration] = [] /// The DNS configuration for the container. @@ -102,6 +112,10 @@ public final class LinuxPod: Sendable { public enum Source: Sendable { /// A network block device (NBD) volume. case nbd(url: URL, timeout: TimeInterval? = nil, readOnly: Bool = false) + /// A disk-image file on the host, attached as a virtio-block device. + case diskImage(path: URL, readOnly: Bool = false) + /// An in-memory (tmpfs) volume mounted inside the guest. + case tmpfs(sizeBytes: UInt64? = nil) } /// The logical name of this volume. Containers reference this name @@ -132,6 +146,20 @@ public final class LinuxPod: Sendable { options: readOnly ? ["ro"] : [], runtimeOptions: runtimeOptions ) + case .diskImage(let path, let readOnly): + return Mount.block( + format: self.format, + source: path.absolutePath(), + destination: LinuxPod.guestVolumePath(name), + options: readOnly ? ["ro"] : [] + ) + case .tmpfs(let sizeBytes): + return Mount.any( + type: "tmpfs", + source: "tmpfs", + destination: LinuxPod.guestVolumePath(name), + options: sizeBytes.map { ["size=\($0)"] } ?? [] + ) } } } @@ -167,6 +195,8 @@ public final class LinuxPod: Sendable { var phase: Phase var containers: [String: PodContainer] var pauseProcess: LinuxProcess? + // Whether the unified virtiofs share is mounted at `/run/virtiofs` in the guest + var unifiedVirtiofsMounted: Bool = false } private enum Phase: Sendable { @@ -281,6 +311,8 @@ public final class LinuxPod: Sendable { // Linux toggles spec.linux?.sysctl = config.sysctl + spec.linux?.maskedPaths = config.maskedPaths + spec.linux?.readonlyPaths = config.readonlyPaths // If the rootfs was requested as read-only, set it in the OCI spec. // We let the OCI runtime remount as ro, instead of doing it originally. @@ -395,12 +427,71 @@ extension LinuxPod { mount.destination = Self.guestRootfsPath(id) try await agent.mount(mount) + // Filter out shared mounts — those are handled separately as + // pod volume bind mounts. Without it here, a container added to an + // already-created would add a duplicated mount into the shared VM. + let nonSharedMounts = fileMountContext.transformedMounts.filter { + if case .shared = $0.runtimeOptions { return false } + return true + } try vm.registerMounts( id: id, rootfs: attachment, - additionalMounts: fileMountContext.transformedMounts + additionalMounts: nonSharedMounts ) + // Mount this container's additional virtiofs shares in the + // guest. create() does this for boot-time containers (the + // /run/virtiofs loop); the hotplug path must do the same or + // 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. + 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 } + ) + var seen: Set = [] + for tag in newVirtiofsTags + where !alreadyMounted.contains(tag) && seen.insert(tag).inserted { + let dest = "/run/virtiofs/\(tag)" + try await agent.mkdir(path: dest, all: true, perms: 0o755) + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: tag, + destination: dest, + options: [] + )) + } + } else if !state.unifiedVirtiofsMounted && vm.virtiofsLayout == .unified { + // Unified layout: one /run/virtiofs mount for the + // VM's lifetime, so mount it only if nothing has + // mounted it at boot or on an earlier hotplug. + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: "virtiofs", + destination: "/run/virtiofs", + options: [] + )) + state.unifiedVirtiofsMounted = true + } + } + if fileMountContext.hasFileMounts { let containerMounts = vm.mounts[id] ?? [] try await updatedFileMountContext.mountHoldingDirectories( @@ -511,6 +602,19 @@ extension LinuxPod { mountsByID[self.id] = podVolumeMounts } + // Capture into an immutable `let` so the value is safely usable + // from the concurrent `withAgent` closure below. The container + // path makes the same decision in LinuxContainer.create — CH + // 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 + } + } + var vmConfig = VMConfiguration( cpus: self.config.cpus, memoryInBytes: self.config.memoryInBytes, @@ -534,16 +638,41 @@ extension LinuxPod { try await vm.withAgent { agent in try await agent.standardSetup() - // Mount the unified virtiofs share at /run/virtiofs - // All virtiofs directories appear as subdirectories here - try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) - try await agent.mount( - ContainerizationOCI.Mount( - type: "virtiofs", - source: "virtiofs", - destination: "/run/virtiofs", - options: [] - )) + // Mount the unified virtiofs share at /run/virtiofs only + // when at least one container has a virtiofs mount. VZ + // tolerates the unbacked mount; CH does not. + if hasVirtiofsMount { + try await agent.mkdir(path: "/run/virtiofs", all: true, perms: 0o755) + if vm.virtiofsLayout == .perTag { + // CH backend: one virtio-fs device per source-hash + // tag, so mount each tag separately at + // /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: [] + )) + } + } + } else { + try await agent.mount( + ContainerizationOCI.Mount( + type: "virtiofs", + source: "virtiofs", + destination: "/run/virtiofs", + options: [] + )) + } + } // Create pause container if PID namespace sharing is enabled if shareProcessNamespace { @@ -636,7 +765,7 @@ extension LinuxPod { type: volume.format, source: attachment.source, destination: guestPath, - options: [] + options: attachment.options )) } @@ -659,22 +788,12 @@ extension LinuxPod { var defaultRouteSet = false for (index, i) in self.interfaces.enumerated() { let name = "eth\(index)" - self.logger?.debug("setting up interface \(name) with address \(i.ipv4Address)") - try await agent.addressAdd(name: name, ipv4Address: i.ipv4Address) - try await agent.up(name: name, mtu: i.mtu) - if defaultRouteSet { - continue - } - if let ipv4Gateway = i.ipv4Gateway { - if !i.ipv4Address.contains(ipv4Gateway) { - self.logger?.debug("gateway \(ipv4Gateway) is outside subnet \(i.ipv4Address), adding a route first") - try await agent.routeAddLink(name: name, dstIPv4Addr: ipv4Gateway, srcIPv4Addr: nil) - } - try await agent.routeAddDefault(name: name, ipv4Gateway: ipv4Gateway) - } else { - self.logger?.debug("no gateway for \(name)") - try await agent.routeAddDefault(name: name, ipv4Gateway: nil) - } + try await agent.setupInterface( + i, + name: name, + setDefaultRoute: !defaultRouteSet, + logger: self.logger + ) defaultRouteSet = true } @@ -697,6 +816,7 @@ extension LinuxPod { } state.pauseProcess = pauseProcessHolder.withLock { $0 } + state.unifiedVirtiofsMounted = hasVirtiofsMount && vm.virtiofsLayout == .unified // Apply file mount context updates. let updates = fileMountContextUpdates.withLock { $0 } @@ -1142,6 +1262,35 @@ extension LinuxPod { return try await fn(vm) } + // Perform filesystem operations in a container. + public func filesystemOperation(_ containerID: String, operation: FilesystemOperation, path: String) async throws { + try await self.state.withLock { state in + let createdState = try state.phase.createdState("filesystemOperation") + + guard let container = state.containers[containerID] else { + throw ContainerizationError( + .notFound, + message: "container \(containerID) not found in pod" + ) + } + + guard container.state == .started else { + throw ContainerizationError( + .invalidState, + message: "container \(containerID) must be started to perform filesystem operations" + ) + } + + try await createdState.vm.withAgent { agent in + guard let vminitd = agent as? Vminitd else { + throw ContainerizationError(.unsupported, message: "filesystemOperation requires Vminitd agent") + } + let guestPath = URL(filePath: Self.guestRootfsPath(containerID)).appending(path: path).path + try await vminitd.filesystemOperation(operation: operation, path: guestPath) + } + } + } + /// Close a container's standard input to signal no more input is arriving. public func closeContainerStdin(_ containerID: String) async throws { try await self.state.withLock { state in diff --git a/Sources/Containerization/LinuxProcess.swift b/Sources/Containerization/LinuxProcess.swift index 0d8300ba4..a77cbcfc1 100644 --- a/Sources/Containerization/LinuxProcess.swift +++ b/Sources/Containerization/LinuxProcess.swift @@ -125,7 +125,9 @@ public final class LinuxProcess: Sendable { extension LinuxProcess { func setupIO(listeners: [VsockListener?]) async throws -> [FileHandle?] { - let handles = try await Timeout.run(seconds: 3) { + let ioTimeout: UInt32 = 30 + + let handles = try await Timeout.run(seconds: ioTimeout) { try await withThrowingTaskGroup(of: (Int, FileHandle?).self) { group in var results = [FileHandle?](repeating: nil, count: 3) diff --git a/Sources/Containerization/LinuxProcessConfiguration.swift b/Sources/Containerization/LinuxProcessConfiguration.swift index 1eef8b0a4..7a15ee0e7 100644 --- a/Sources/Containerization/LinuxProcessConfiguration.swift +++ b/Sources/Containerization/LinuxProcessConfiguration.swift @@ -376,8 +376,12 @@ public struct LinuxProcessConfiguration: Sendable { /// process and its children cannot gain additional privileges via setuid/setgid binaries /// or file capabilities. public var noNewPrivileges: Bool = false - /// The Linux capabilities for the container process. - public var capabilities: LinuxCapabilities = .allCapabilities + /// The Linux capabilities for the container process. Defaults to + /// ``LinuxCapabilities/defaultOCICapabilities`` — the restricted baseline used by + /// runc/containerd, which excludes privileged capabilities such as `CAP_SYS_ADMIN`. + /// Callers that require additional capabilities (for example, privileged containers) + /// must opt in explicitly, e.g. by setting ``LinuxCapabilities/allCapabilities``. + public var capabilities: LinuxCapabilities = .defaultOCICapabilities /// Whether to allocate a pseudo terminal for the process. If you'd like interactive /// behavior and are planning to use a terminal for stdin/out/err on the client side, /// this should likely be set to true. @@ -398,7 +402,7 @@ public struct LinuxProcessConfiguration: Sendable { user: ContainerizationOCI.User = .init(), rlimits: [LinuxRLimit] = [], noNewPrivileges: Bool = false, - capabilities: LinuxCapabilities = .allCapabilities, + capabilities: LinuxCapabilities = .defaultOCICapabilities, terminal: Bool = false, stdin: ReaderStream? = nil, stdout: Writer? = nil, diff --git a/Sources/Containerization/Mount+CH.swift b/Sources/Containerization/Mount+CH.swift new file mode 100644 index 000000000..2e25e2b90 --- /dev/null +++ b/Sources/Containerization/Mount+CH.swift @@ -0,0 +1,80 @@ +//===----------------------------------------------------------------------===// +// 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 CloudHypervisor +import Foundation + +extension Mount { + /// Returns a `CloudHypervisor.DiskConfig` describing this mount as a virtio-blk + /// device, or `nil` if the mount is not a block device. + /// + /// The caller supplies the device id; cloud-hypervisor uses it both as a + /// stable handle for hotplug-remove and as the udev/sysfs identifier inside + /// the guest. + /// + /// `imageType` defaults to `.raw` because Containerization mounts are + /// always raw block files (ext4 produced by the EXT4 unpacker, NBD URLs, + /// etc.). When cloud-hypervisor doesn't see an `image_type` it falls + /// back to `Unknown` and silently rejects all writes — see CH's + /// `virtio-devices/src/block.rs` "Attempting to write to sector 0 on a + /// disk without specifying image_type" warning. + public func chDiskConfig(id: String) -> CloudHypervisor.DiskConfig? { + guard case .virtioblk = self.runtimeOptions else { + return nil + } + return CloudHypervisor.DiskConfig( + path: self.source, + readonly: self.options.contains("ro"), + direct: nil, + iommu: nil, + id: id, + pciSegment: nil, + imageType: .raw + ) + } + + /// Returns a `CloudHypervisor.FsConfig` describing this mount as a virtio-fs + /// share served by an out-of-process `virtiofsd`, or `nil` if the mount is + /// not a virtiofs share. + /// + /// `tag` is the guest-side mount tag and `socketPath` is the UDS path the + /// virtiofsd subprocess publishes. Both are owned by the caller. + public func chFsConfig(tag: String, socketPath: String, id: String) -> CloudHypervisor.FsConfig? { + guard case .virtiofs = self.runtimeOptions else { + return nil + } + return CloudHypervisor.FsConfig( + tag: tag, + socket: socketPath, + numQueues: nil, + queueSize: nil, + id: id, + pciSegment: nil + ) + } +} + +/// Build the host-side UDS path for a virtiofsd ↔ cloud-hypervisor socket. +/// +/// `tag` is the full source-hash (used as the FUSE tag advertised to the +/// guest); the socket *path* uses only an 8-char prefix because the full +/// path — `/virtiofs-.sock` with a 36-char tag — overshoots +/// Linux's 108-byte `SUN_LEN` limit. 32 bits of disambiguation is more +/// than enough within a single VM (handful of distinct virtiofs sources). +func chVirtiofsSocketURL(workDir: URL, tag: String) -> URL { + let short = String(tag.prefix(8)) + return workDir.appendingPathComponent("vfs-\(short).sock") +} diff --git a/Sources/Containerization/Mount.swift b/Sources/Containerization/Mount.swift index b72436bd0..b516deae8 100644 --- a/Sources/Containerization/Mount.swift +++ b/Sources/Containerization/Mount.swift @@ -21,6 +21,14 @@ import Foundation import Virtualization #endif +#if os(Linux) +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif +#endif + /// A filesystem mount exposed to a container. public struct Mount: Sendable { /// The filesystem or mount type. This is the string @@ -127,14 +135,23 @@ public struct Mount: Sendable { ) } - #if os(macOS) /// Clone the Mount to the provided path. /// - /// This uses `clonefile` to provide a copy-on-write copy of the Mount. + /// On macOS this uses `clonefile` (via `FileManager.copyItem`) for a + /// copy-on-write copy when the underlying filesystem supports it. On + /// Linux it tries `ioctl(FICLONE)` first (CoW on btrfs / xfs / bcachefs) + /// and falls back to a `SEEK_DATA`/`SEEK_HOLE` sparse copy that copies + /// only data ranges. This matters for EXT4 images produced by + /// `EXT4+Formatter`, which sparse-allocate via `lseek + 1-byte write` — + /// a non-sparse copy would inflate a ~50 MB alpine rootfs into a + /// fully-allocated 2 GiB clone and exhaust the integration suite's + /// writable layer in ~30 tests. public func clone(to: String) throws -> Self { - let fm = FileManager.default - let src = self.source - try fm.copyItem(atPath: src, toPath: to) + #if os(Linux) + try Self.linuxSparseCopy(from: self.source, to: to) + #else + try FileManager.default.copyItem(atPath: self.source, toPath: to) + #endif return .init( type: self.type, @@ -144,6 +161,121 @@ public struct Mount: Sendable { runtimeOptions: self.runtimeOptions ) } + + #if os(Linux) + /// Copy `src` to `dst`, preferring a CoW reflink (`ioctl(FICLONE)`) and + /// falling back to a SEEK_DATA/SEEK_HOLE sparse copy. The reflink path + /// succeeds on btrfs / xfs (`reflink=1`) / bcachefs; on ext4 / tmpfs / + /// overlayfs it fails fast with EOPNOTSUPP/EXDEV/EINVAL and we walk + /// the hole map instead. The sparse-copy path also handles + /// filesystems that don't support hole-seeking (the very first + /// SEEK_DATA returns EINVAL) by copying the remainder verbatim. Mode + /// bits are preserved from the source. + private static func linuxSparseCopy(from src: String, to dst: String) throws { + // Stable Linux ABI since 3.1 (ext4, tmpfs, overlayfs all support it). + // Re-declared here so the build doesn't depend on whether the + // Glibc/Musl Swift overlay re-exports them. + let SEEK_DATA: Int32 = 3 + let SEEK_HOLE: Int32 = 4 + // _IOW(0x94, 9, int) on every Linux arch we target (x86_64, aarch64). + let FICLONE: CUnsignedLong = 0x4004_9409 + + let srcFd = open(src, O_RDONLY | O_CLOEXEC) + guard srcFd >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { _ = close(srcFd) } + + var st = stat() + guard fstat(srcFd, &st) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + let size = off_t(st.st_size) + let mode = mode_t(st.st_mode & 0o7777) + + let dstFd = open(dst, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, mode) + guard dstFd >= 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + defer { _ = close(dstFd) } + + // FICLONE atomically replaces dst's contents with a CoW clone of + // src — sets size and contents in one shot, no ftruncate needed + // afterwards. On failure FICLONE guarantees dst is untouched, so + // we can safely fall through to the sparse-copy path. ioctl(2) is + // variadic; type-pun via a fixed-arity function pointer (same + // pattern as ContainerizationOS.Socket). + let ioctlFICLONE: @convention(c) (CInt, CUnsignedLong, CInt) -> CInt = ioctl + if ioctlFICLONE(dstFd, FICLONE, srcFd) == 0 { + return + } + + // Set the destination size up front so any trailing hole survives — + // we only ever pwrite data ranges, never zero-fill. + guard ftruncate(dstFd, size) == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + + let bufSize = 1 << 20 // 1 MiB + let buf = UnsafeMutableRawPointer.allocate(byteCount: bufSize, alignment: 16) + defer { buf.deallocate() } + + var pos: off_t = 0 + while pos < size { + let dataStart = lseek(srcFd, pos, SEEK_DATA) + if dataStart < 0 { + // ENXIO: no more data — rest is hole, already covered by ftruncate. + if errno == ENXIO { + break + } + // EINVAL/ENOTSUP: filesystem doesn't support SEEK_DATA. Treat + // the remainder as one big data range and copy it verbatim. + try Self.copyRange(srcFd: srcFd, dstFd: dstFd, start: pos, end: size, buf: buf, bufSize: bufSize) + break + } + + // SEEK_HOLE returns end-of-file when there's no trailing hole. + let dataEnd = lseek(srcFd, dataStart, SEEK_HOLE) + let endOff: off_t = dataEnd < 0 ? size : dataEnd + + try Self.copyRange(srcFd: srcFd, dstFd: dstFd, start: dataStart, end: endOff, buf: buf, bufSize: bufSize) + pos = endOff + } + } + + private static func copyRange( + srcFd: Int32, + dstFd: Int32, + start: off_t, + end: off_t, + buf: UnsafeMutableRawPointer, + bufSize: Int + ) throws { + var off = start + while off < end { + let want = Int(min(off_t(bufSize), end - off)) + let nread = pread(srcFd, buf, want, off) + if nread < 0 { + if errno == EINTR { continue } + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + if nread == 0 { + // Source shorter than fstat reported — shouldn't happen, but + // bail rather than spin. + return + } + var written = 0 + while written < nread { + let nwrite = pwrite(dstFd, buf.advanced(by: written), nread - written, off + off_t(written)) + if nwrite < 0 { + if errno == EINTR { continue } + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EIO) + } + written += nwrite + } + off += off_t(nread) + } + } #endif } diff --git a/Sources/Containerization/NATInterface.swift b/Sources/Containerization/NATInterface.swift index 22383627c..c37fbc063 100644 --- a/Sources/Containerization/NATInterface.swift +++ b/Sources/Containerization/NATInterface.swift @@ -19,12 +19,23 @@ import ContainerizationExtras public struct NATInterface: Interface { public var ipv4Address: CIDRv4 public var ipv4Gateway: IPv4Address? + public var ipv6Address: CIDRv6? + public var ipv6Gateway: IPv6Address? public var macAddress: MACAddress? public var mtu: UInt32 - public init(ipv4Address: CIDRv4, ipv4Gateway: IPv4Address?, macAddress: MACAddress? = nil, mtu: UInt32 = 1500) { + public init( + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address?, + ipv6Address: CIDRv6? = nil, + ipv6Gateway: IPv6Address? = nil, + macAddress: MACAddress? = nil, + mtu: UInt32 = 1500 + ) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway + self.ipv6Address = ipv6Address + self.ipv6Gateway = ipv6Gateway self.macAddress = macAddress self.mtu = mtu } diff --git a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift index 7018897bd..6776ed1b3 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.grpc.swift @@ -179,6 +179,19 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContext: Sendable { type: .unary ) } + /// Namespace for "FilesystemOperation" metadata. + public enum FilesystemOperation: Sendable { + /// Request type for "FilesystemOperation". + public typealias Input = Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest + /// Response type for "FilesystemOperation". + public typealias Output = Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse + /// Descriptor for "FilesystemOperation". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "com.apple.containerization.sandbox.v3.SandboxContext"), + method: "FilesystemOperation", + type: .unary + ) + } /// Namespace for "CreateProcess" metadata. public enum CreateProcess: Sendable { /// Request type for "CreateProcess". @@ -426,6 +439,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_SandboxContext: Sendable { WriteFile.descriptor, Copy.descriptor, Stat.descriptor, + FilesystemOperation.descriptor, CreateProcess.descriptor, DeleteProcess.descriptor, StartProcess.descriptor, @@ -673,6 +687,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { context: GRPCCore.ServerContext ) async throws -> GRPCCore.StreamingServerResponse + /// Handle the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A streaming request of `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` messages. + func filesystemOperation( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + /// Handle the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -1211,6 +1243,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { context: GRPCCore.ServerContext ) async throws -> GRPCCore.ServerResponse + /// Handle the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` message. + func filesystemOperation( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + /// Handle the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -1748,6 +1798,24 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { context: GRPCCore.ServerContext ) async throws -> Com_Apple_Containerization_Sandbox_V3_StatResponse + /// Handle the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` to respond with. + func filesystemOperation( + request: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, + context: GRPCCore.ServerContext + ) async throws -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse + /// Handle the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -2200,6 +2268,17 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.StreamingServiceP ) } ) + router.registerHandler( + forMethod: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Method.FilesystemOperation.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.filesystemOperation( + request: request, + context: context + ) + } + ) router.registerHandler( forMethod: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Method.CreateProcess.descriptor, deserializer: GRPCProtobuf.ProtobufDeserializer(), @@ -2525,6 +2604,17 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.ServiceProtocol { return GRPCCore.StreamingServerResponse(single: response) } + public func filesystemOperation( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.filesystemOperation( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + public func createProcess( request: GRPCCore.StreamingServerRequest, context: GRPCCore.ServerContext @@ -2874,6 +2964,19 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServiceProt ) } + public func filesystemOperation( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.filesystemOperation( + request: request.message, + context: context + ), + metadata: [:] + ) + } + public func createProcess( request: GRPCCore.ServerRequest, context: GRPCCore.ServerContext @@ -3377,6 +3480,29 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result ) async throws -> Result where Result: Sendable + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - serializer: A serializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func filesystemOperation( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -4187,6 +4313,40 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext { ) } + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - serializer: A serializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func filesystemOperation( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Com_Apple_Containerization_Sandbox_V3_SandboxContext.Method.FilesystemOperation.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -5124,6 +5284,35 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.ClientProtocol { ) } + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func filesystemOperation( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.filesystemOperation( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: @@ -6014,6 +6203,39 @@ extension Com_Apple_Containerization_Sandbox_V3_SandboxContext.ClientProtocol { ) } + /// Call the "FilesystemOperation" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform a filesystem operation on a mounted filesystem. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func filesystemOperation( + _ message: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.filesystemOperation( + request: request, + options: options, + onResponse: handleResponse + ) + } + /// Call the "CreateProcess" method. /// /// > Source IDL Documentation: diff --git a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift index 8157b5ae7..a091d8b25 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.pb.swift +++ b/Sources/Containerization/SandboxContext/SandboxContext.pb.swift @@ -36,13 +36,13 @@ import SwiftProtobuf // incompatible with the version of SwiftProtobuf to which you are linking. // Please ensure that you are building against the same version of the API // that was used to generate this file. -fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { +fileprivate nonisolated struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} typealias Version = _2 } /// Categories of statistics that can be requested. -public enum Com_Apple_Containerization_Sandbox_V3_StatCategory: SwiftProtobuf.Enum, Swift.CaseIterable { +public nonisolated enum Com_Apple_Containerization_Sandbox_V3_StatCategory: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case unspecified // = 0 case process // = 1 @@ -96,7 +96,7 @@ public enum Com_Apple_Containerization_Sandbox_V3_StatCategory: SwiftProtobuf.En } -public struct Com_Apple_Containerization_Sandbox_V3_Stdio: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_Stdio: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -137,7 +137,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_Stdio: Sendable { fileprivate var _stderrPort: Int32? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -161,7 +161,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest: Sendab public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SetupEmulatorResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SetupEmulatorResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -171,7 +171,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SetupEmulatorResponse: Senda public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SetTimeRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SetTimeRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -185,7 +185,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SetTimeRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SetTimeResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SetTimeResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -195,7 +195,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SetTimeResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SysctlRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SysctlRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -207,7 +207,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SysctlRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SysctlResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SysctlResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -217,7 +217,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SysctlResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -241,7 +241,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest: Sendable public var unknownFields = SwiftProtobuf.UnknownStorage() - public enum Action: SwiftProtobuf.Enum, Swift.CaseIterable { + public nonisolated enum Action: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int case into // = 0 case outOf // = 1 @@ -280,7 +280,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest: Sendable fileprivate var _guestSocketPermissions: UInt32? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_ProxyVsockResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ProxyVsockResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -290,7 +290,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ProxyVsockResponse: Sendable public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_StopVsockProxyRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_StopVsockProxyRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -302,7 +302,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_StopVsockProxyRequest: Senda public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_StopVsockProxyResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_StopVsockProxyResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -312,7 +312,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_StopVsockProxyResponse: Send public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_MountRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_MountRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -330,7 +330,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_MountRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_MountResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_MountResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -340,7 +340,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_MountResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_UmountRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_UmountRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -354,7 +354,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_UmountRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_UmountResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_UmountResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -364,7 +364,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_UmountResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SetenvRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SetenvRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -387,7 +387,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SetenvRequest: Sendable { fileprivate var _value: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_SetenvResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SetenvResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -397,7 +397,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SetenvResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_GetenvRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_GetenvRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -409,7 +409,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_GetenvRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_GetenvResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_GetenvResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -430,7 +430,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_GetenvResponse: Sendable { fileprivate var _value: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -505,7 +505,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest: Sendab fileprivate var _options: Data? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_CreateProcessResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_CreateProcessResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -515,7 +515,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CreateProcessResponse: Senda public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -538,7 +538,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest: Sendable fileprivate var _containerID: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -561,7 +561,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: Sendabl fileprivate var _exitedAt: SwiftProtobuf.Google_Protobuf_Timestamp? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -588,7 +588,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest: Sendab fileprivate var _containerID: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_ResizeProcessResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ResizeProcessResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -598,7 +598,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ResizeProcessResponse: Senda public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_DeleteProcessRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_DeleteProcessRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -621,7 +621,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_DeleteProcessRequest: Sendab fileprivate var _containerID: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_DeleteProcessResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_DeleteProcessResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -631,7 +631,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_DeleteProcessResponse: Senda public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_StartProcessRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_StartProcessRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -654,7 +654,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_StartProcessRequest: Sendabl fileprivate var _containerID: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_StartProcessResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_StartProcessResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -666,7 +666,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_StartProcessResponse: Sendab public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_KillProcessRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_KillProcessRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -691,7 +691,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_KillProcessRequest: Sendable fileprivate var _containerID: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_KillProcessResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_KillProcessResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -703,7 +703,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_KillProcessResponse: Sendabl public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -726,7 +726,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinRequest: Se fileprivate var _containerID: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -736,7 +736,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinResponse: S public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_MkdirRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_MkdirRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -752,7 +752,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_MkdirRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_MkdirResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_MkdirResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -762,7 +762,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_MkdirResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_WriteFileRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_WriteFileRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -784,7 +784,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_WriteFileRequest: Sendable { public var unknownFields = SwiftProtobuf.UnknownStorage() - public struct WriteFileFlags: Sendable { + public nonisolated struct WriteFileFlags: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -805,7 +805,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_WriteFileRequest: Sendable { fileprivate var _flags: Com_Apple_Containerization_Sandbox_V3_WriteFileRequest.WriteFileFlags? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -815,7 +815,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: Sendable public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CopyRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_CopyRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -840,7 +840,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyRequest: Sendable { public var unknownFields = SwiftProtobuf.UnknownStorage() - public enum Direction: SwiftProtobuf.Enum, Swift.CaseIterable { + public nonisolated enum Direction: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int /// Copy from host into guest. @@ -881,7 +881,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CopyResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_CopyResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -900,7 +900,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyResponse: Sendable { public var unknownFields = SwiftProtobuf.UnknownStorage() - public enum Status: SwiftProtobuf.Enum, Swift.CaseIterable { + public nonisolated enum Status: SwiftProtobuf.Enum, Swift.CaseIterable { public typealias RawValue = Int /// Transfer metadata (first message for COPY_OUT: is_archive, total_size). @@ -941,7 +941,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CopyResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_StatRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_StatRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -953,7 +953,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_StatRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_Stat: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_Stat: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1027,7 +1027,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_Stat: Sendable { fileprivate var _ctime: SwiftProtobuf.Google_Protobuf_Timestamp? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_StatResponse: @unchecked Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_StatResponse: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1054,7 +1054,144 @@ public struct Com_Apple_Containerization_Sandbox_V3_StatResponse: @unchecked Sen fileprivate var _storage = _StorageClass.defaultInstance } -public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FiTrimParams: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var schedule: Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneOf_Schedule? = nil + + public var oneShot: Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneShot { + get { + if case .oneShot(let v)? = schedule {return v} + return Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneShot() + } + set {schedule = .oneShot(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public nonisolated enum OneOf_Schedule: Equatable, Sendable { + case oneShot(Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneShot) + + } + + public nonisolated struct OneShot: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + } + + public init() {} +} + +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FiFreezeParams: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FiThawParams: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FiTrimResult: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var trimmedBytes: UInt64 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var path: String = String() + + public var operation: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.OneOf_Operation? = nil + + public var trim: Com_Apple_Containerization_Sandbox_V3_FiTrimParams { + get { + if case .trim(let v)? = operation {return v} + return Com_Apple_Containerization_Sandbox_V3_FiTrimParams() + } + set {operation = .trim(newValue)} + } + + public var freeze: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams { + get { + if case .freeze(let v)? = operation {return v} + return Com_Apple_Containerization_Sandbox_V3_FiFreezeParams() + } + set {operation = .freeze(newValue)} + } + + public var thaw: Com_Apple_Containerization_Sandbox_V3_FiThawParams { + get { + if case .thaw(let v)? = operation {return v} + return Com_Apple_Containerization_Sandbox_V3_FiThawParams() + } + set {operation = .thaw(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public nonisolated enum OneOf_Operation: Equatable, Sendable { + case trim(Com_Apple_Containerization_Sandbox_V3_FiTrimParams) + case freeze(Com_Apple_Containerization_Sandbox_V3_FiFreezeParams) + case thaw(Com_Apple_Containerization_Sandbox_V3_FiThawParams) + + } + + public init() {} +} + +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var result: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse.OneOf_Result? = nil + + public var trim: Com_Apple_Containerization_Sandbox_V3_FiTrimResult { + get { + if case .trim(let v)? = result {return v} + return Com_Apple_Containerization_Sandbox_V3_FiTrimResult() + } + set {result = .trim(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public nonisolated enum OneOf_Result: Equatable, Sendable { + case trim(Com_Apple_Containerization_Sandbox_V3_FiTrimResult) + + } + + public init() {} +} + +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1079,7 +1216,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: Sendable { fileprivate var _mtu: UInt32? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1089,7 +1226,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpLinkSetResponse: Sendable public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1098,12 +1235,23 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: Sendable { public var ipv4Address: String = String() + public var ipv6Address: String { + get {_ipv6Address ?? String()} + set {_ipv6Address = newValue} + } + /// Returns true if `ipv6Address` has been explicitly set. + public var hasIpv6Address: Bool {self._ipv6Address != nil} + /// Clears the value of `ipv6Address`. Subsequent reads from it will return its default value. + public mutating func clearIpv6Address() {self._ipv6Address = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _ipv6Address: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1113,7 +1261,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: Sendable public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1124,12 +1272,33 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: Senda public var srcIpv4Addr: String = String() + public var dstIpv6Addr: String { + get {_dstIpv6Addr ?? String()} + set {_dstIpv6Addr = newValue} + } + /// Returns true if `dstIpv6Addr` has been explicitly set. + public var hasDstIpv6Addr: Bool {self._dstIpv6Addr != nil} + /// Clears the value of `dstIpv6Addr`. Subsequent reads from it will return its default value. + public mutating func clearDstIpv6Addr() {self._dstIpv6Addr = nil} + + public var srcIpv6Addr: String { + get {_srcIpv6Addr ?? String()} + set {_srcIpv6Addr = newValue} + } + /// Returns true if `srcIpv6Addr` has been explicitly set. + public var hasSrcIpv6Addr: Bool {self._srcIpv6Addr != nil} + /// Clears the value of `srcIpv6Addr`. Subsequent reads from it will return its default value. + public mutating func clearSrcIpv6Addr() {self._srcIpv6Addr = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _dstIpv6Addr: String? = nil + fileprivate var _srcIpv6Addr: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1139,7 +1308,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: Send public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1148,12 +1317,23 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: Se public var ipv4Gateway: String = String() + public var ipv6Gateway: String { + get {_ipv6Gateway ?? String()} + set {_ipv6Gateway = newValue} + } + /// Returns true if `ipv6Gateway` has been explicitly set. + public var hasIpv6Gateway: Bool {self._ipv6Gateway != nil} + /// Clears the value of `ipv6Gateway`. Subsequent reads from it will return its default value. + public mutating func clearIpv6Gateway() {self._ipv6Gateway = nil} + public var unknownFields = SwiftProtobuf.UnknownStorage() public init() {} + + fileprivate var _ipv6Gateway: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1163,7 +1343,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: S public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1192,7 +1372,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: Sendabl fileprivate var _domain: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1202,7 +1382,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: Sendab public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1222,7 +1402,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: Senda public var unknownFields = SwiftProtobuf.UnknownStorage() - public struct HostsEntry: Sendable { + public nonisolated struct HostsEntry: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1252,7 +1432,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: Senda fileprivate var _comment: String? = nil } -public struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1262,7 +1442,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: Send public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SyncRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SyncRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1272,7 +1452,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SyncRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_SyncResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_SyncResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1282,7 +1462,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_SyncResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_KillRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_KillRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1296,7 +1476,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_KillRequest: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_KillResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_KillResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1308,7 +1488,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_KillResponse: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1324,7 +1504,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest: public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsResponse: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsResponse: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1336,7 +1516,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsResponse: public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_ContainerStats: @unchecked Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ContainerStats: @unchecked Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1403,7 +1583,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ContainerStats: @unchecked S fileprivate var _storage = _StorageClass.defaultInstance } -public struct Com_Apple_Containerization_Sandbox_V3_ProcessStats: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_ProcessStats: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1418,7 +1598,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_ProcessStats: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_MemoryStats: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_MemoryStats: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1460,7 +1640,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_MemoryStats: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_CPUStats: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_CPUStats: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1482,7 +1662,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_CPUStats: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_BlockIOStats: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_BlockIOStats: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1494,7 +1674,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_BlockIOStats: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_BlockIOEntry: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_BlockIOEntry: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1516,7 +1696,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_BlockIOEntry: Sendable { public init() {} } -public struct Com_Apple_Containerization_Sandbox_V3_NetworkStats: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_NetworkStats: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1541,7 +1721,7 @@ public struct Com_Apple_Containerization_Sandbox_V3_NetworkStats: Sendable { } /// Memory event counters from cgroup2's memory.events file. -public struct Com_Apple_Containerization_Sandbox_V3_MemoryEventStats: Sendable { +public nonisolated struct Com_Apple_Containerization_Sandbox_V3_MemoryEventStats: Sendable { // SwiftProtobuf.Message conformance is added in an extension below. See the // `Message` and `Message+*Additions` files in the SwiftProtobuf library for // methods supported on all messages. @@ -1571,13 +1751,13 @@ public struct Com_Apple_Containerization_Sandbox_V3_MemoryEventStats: Sendable { // MARK: - Code below here is support for the SwiftProtobuf runtime. -fileprivate let _protobuf_package = "com.apple.containerization.sandbox.v3" +fileprivate nonisolated let _protobuf_package = "com.apple.containerization.sandbox.v3" -extension Com_Apple_Containerization_Sandbox_V3_StatCategory: SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_StatCategory: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0STAT_CATEGORY_UNSPECIFIED\0\u{1}STAT_CATEGORY_PROCESS\0\u{1}STAT_CATEGORY_MEMORY\0\u{1}STAT_CATEGORY_CPU\0\u{1}STAT_CATEGORY_BLOCK_IO\0\u{1}STAT_CATEGORY_NETWORK\0\u{1}STAT_CATEGORY_MEMORY_EVENTS\0") } -extension Com_Apple_Containerization_Sandbox_V3_Stdio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_Stdio: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Stdio" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}stdinPort\0\u{1}stdoutPort\0\u{1}stderrPort\0") @@ -1621,7 +1801,7 @@ extension Com_Apple_Containerization_Sandbox_V3_Stdio: SwiftProtobuf.Message, Sw } } -extension Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SetupEmulatorRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}binary_path\0\u{1}name\0\u{1}type\0\u{1}offset\0\u{1}magic\0\u{1}mask\0\u{1}flags\0") @@ -1681,7 +1861,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SetupEmulatorRequest: SwiftProto } } -extension Com_Apple_Containerization_Sandbox_V3_SetupEmulatorResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SetupEmulatorResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SetupEmulatorResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -1700,7 +1880,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SetupEmulatorResponse: SwiftProt } } -extension Com_Apple_Containerization_Sandbox_V3_SetTimeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SetTimeRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SetTimeRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}sec\0\u{1}usec\0") @@ -1735,7 +1915,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SetTimeRequest: SwiftProtobuf.Me } } -extension Com_Apple_Containerization_Sandbox_V3_SetTimeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SetTimeResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SetTimeResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -1754,7 +1934,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SetTimeResponse: SwiftProtobuf.M } } -extension Com_Apple_Containerization_Sandbox_V3_SysctlRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SysctlRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SysctlRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}settings\0") @@ -1784,7 +1964,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SysctlRequest: SwiftProtobuf.Mes } } -extension Com_Apple_Containerization_Sandbox_V3_SysctlResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SysctlResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SysctlResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -1803,7 +1983,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SysctlResponse: SwiftProtobuf.Me } } -extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ProxyVsockRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{3}vsock_port\0\u{1}guestPath\0\u{1}guestSocketPermissions\0\u{1}action\0") @@ -1857,11 +2037,11 @@ extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest: SwiftProtobuf } } -extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest.Action: SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockRequest.Action: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0INTO\0\u{1}OUT_OF\0") } -extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ProxyVsockResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -1880,7 +2060,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ProxyVsockResponse: SwiftProtobu } } -extension Com_Apple_Containerization_Sandbox_V3_StopVsockProxyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_StopVsockProxyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".StopVsockProxyRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0") @@ -1910,7 +2090,7 @@ extension Com_Apple_Containerization_Sandbox_V3_StopVsockProxyRequest: SwiftProt } } -extension Com_Apple_Containerization_Sandbox_V3_StopVsockProxyResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_StopVsockProxyResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".StopVsockProxyResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -1929,7 +2109,7 @@ extension Com_Apple_Containerization_Sandbox_V3_StopVsockProxyResponse: SwiftPro } } -extension Com_Apple_Containerization_Sandbox_V3_MountRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_MountRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MountRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}source\0\u{1}destination\0\u{1}options\0") @@ -1974,7 +2154,7 @@ extension Com_Apple_Containerization_Sandbox_V3_MountRequest: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_MountResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_MountResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MountResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -1993,7 +2173,7 @@ extension Com_Apple_Containerization_Sandbox_V3_MountResponse: SwiftProtobuf.Mes } } -extension Com_Apple_Containerization_Sandbox_V3_UmountRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_UmountRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".UmountRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{1}flags\0") @@ -2028,7 +2208,7 @@ extension Com_Apple_Containerization_Sandbox_V3_UmountRequest: SwiftProtobuf.Mes } } -extension Com_Apple_Containerization_Sandbox_V3_UmountResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_UmountResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".UmountResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2047,7 +2227,7 @@ extension Com_Apple_Containerization_Sandbox_V3_UmountResponse: SwiftProtobuf.Me } } -extension Com_Apple_Containerization_Sandbox_V3_SetenvRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SetenvRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SetenvRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}key\0\u{1}value\0") @@ -2086,7 +2266,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SetenvRequest: SwiftProtobuf.Mes } } -extension Com_Apple_Containerization_Sandbox_V3_SetenvResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SetenvResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SetenvResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2105,7 +2285,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SetenvResponse: SwiftProtobuf.Me } } -extension Com_Apple_Containerization_Sandbox_V3_GetenvRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_GetenvRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".GetenvRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}key\0") @@ -2135,7 +2315,7 @@ extension Com_Apple_Containerization_Sandbox_V3_GetenvRequest: SwiftProtobuf.Mes } } -extension Com_Apple_Containerization_Sandbox_V3_GetenvResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_GetenvResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".GetenvResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}value\0") @@ -2169,7 +2349,7 @@ extension Com_Apple_Containerization_Sandbox_V3_GetenvResponse: SwiftProtobuf.Me } } -extension Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CreateProcessRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}containerID\0\u{1}stdin\0\u{1}stdout\0\u{1}stderr\0\u{1}ociRuntimePath\0\u{1}configuration\0\u{1}options\0") @@ -2238,7 +2418,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CreateProcessRequest: SwiftProto } } -extension Com_Apple_Containerization_Sandbox_V3_CreateProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CreateProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CreateProcessResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2257,7 +2437,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CreateProcessResponse: SwiftProt } } -extension Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".WaitProcessRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}containerID\0") @@ -2296,7 +2476,7 @@ extension Com_Apple_Containerization_Sandbox_V3_WaitProcessRequest: SwiftProtobu } } -extension Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".WaitProcessResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}exitCode\0\u{3}exited_at\0") @@ -2335,7 +2515,7 @@ extension Com_Apple_Containerization_Sandbox_V3_WaitProcessResponse: SwiftProtob } } -extension Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ResizeProcessRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}containerID\0\u{1}rows\0\u{1}columns\0") @@ -2384,7 +2564,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ResizeProcessRequest: SwiftProto } } -extension Com_Apple_Containerization_Sandbox_V3_ResizeProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ResizeProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ResizeProcessResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2403,7 +2583,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ResizeProcessResponse: SwiftProt } } -extension Com_Apple_Containerization_Sandbox_V3_DeleteProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_DeleteProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".DeleteProcessRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}containerID\0") @@ -2442,7 +2622,7 @@ extension Com_Apple_Containerization_Sandbox_V3_DeleteProcessRequest: SwiftProto } } -extension Com_Apple_Containerization_Sandbox_V3_DeleteProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_DeleteProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".DeleteProcessResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2461,7 +2641,7 @@ extension Com_Apple_Containerization_Sandbox_V3_DeleteProcessResponse: SwiftProt } } -extension Com_Apple_Containerization_Sandbox_V3_StartProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_StartProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".StartProcessRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}containerID\0") @@ -2500,7 +2680,7 @@ extension Com_Apple_Containerization_Sandbox_V3_StartProcessRequest: SwiftProtob } } -extension Com_Apple_Containerization_Sandbox_V3_StartProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_StartProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".StartProcessResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}pid\0") @@ -2530,7 +2710,7 @@ extension Com_Apple_Containerization_Sandbox_V3_StartProcessResponse: SwiftProto } } -extension Com_Apple_Containerization_Sandbox_V3_KillProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_KillProcessRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".KillProcessRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}containerID\0\u{1}signal\0") @@ -2574,7 +2754,7 @@ extension Com_Apple_Containerization_Sandbox_V3_KillProcessRequest: SwiftProtobu } } -extension Com_Apple_Containerization_Sandbox_V3_KillProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_KillProcessResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".KillProcessResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}result\0") @@ -2604,7 +2784,7 @@ extension Com_Apple_Containerization_Sandbox_V3_KillProcessResponse: SwiftProtob } } -extension Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CloseProcessStdinRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}containerID\0") @@ -2643,7 +2823,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinRequest: SwiftP } } -extension Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CloseProcessStdinResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2662,7 +2842,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CloseProcessStdinResponse: Swift } } -extension Com_Apple_Containerization_Sandbox_V3_MkdirRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_MkdirRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MkdirRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{1}all\0\u{1}perms\0") @@ -2702,7 +2882,7 @@ extension Com_Apple_Containerization_Sandbox_V3_MkdirRequest: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_MkdirResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_MkdirResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MkdirResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2721,7 +2901,7 @@ extension Com_Apple_Containerization_Sandbox_V3_MkdirResponse: SwiftProtobuf.Mes } } -extension Com_Apple_Containerization_Sandbox_V3_WriteFileRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_WriteFileRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".WriteFileRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{1}data\0\u{1}mode\0\u{1}flags\0") @@ -2770,7 +2950,7 @@ extension Com_Apple_Containerization_Sandbox_V3_WriteFileRequest: SwiftProtobuf. } } -extension Com_Apple_Containerization_Sandbox_V3_WriteFileRequest.WriteFileFlags: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_WriteFileRequest.WriteFileFlags: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = Com_Apple_Containerization_Sandbox_V3_WriteFileRequest.protoMessageName + ".WriteFileFlags" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}create_parent_dirs\0\u{1}append\0\u{3}create_if_missing\0") @@ -2810,7 +2990,7 @@ extension Com_Apple_Containerization_Sandbox_V3_WriteFileRequest.WriteFileFlags: } } -extension Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".WriteFileResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -2829,7 +3009,7 @@ extension Com_Apple_Containerization_Sandbox_V3_WriteFileResponse: SwiftProtobuf } } -extension Com_Apple_Containerization_Sandbox_V3_CopyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CopyRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CopyRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}direction\0\u{1}path\0\u{1}mode\0\u{3}create_parents\0\u{3}vsock_port\0\u{3}is_archive\0") @@ -2884,11 +3064,11 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyRequest: SwiftProtobuf.Messa } } -extension Com_Apple_Containerization_Sandbox_V3_CopyRequest.Direction: SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CopyRequest.Direction: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0COPY_IN\0\u{1}COPY_OUT\0") } -extension Com_Apple_Containerization_Sandbox_V3_CopyResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CopyResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CopyResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}status\0\u{3}is_archive\0\u{3}total_size\0\u{1}error\0") @@ -2933,11 +3113,11 @@ extension Com_Apple_Containerization_Sandbox_V3_CopyResponse: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_CopyResponse.Status: SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CopyResponse.Status: SwiftProtobuf._ProtoNameProviding { public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0METADATA\0\u{1}COMPLETE\0") } -extension Com_Apple_Containerization_Sandbox_V3_StatRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_StatRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".StatRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0") @@ -2967,7 +3147,7 @@ extension Com_Apple_Containerization_Sandbox_V3_StatRequest: SwiftProtobuf.Messa } } -extension Com_Apple_Containerization_Sandbox_V3_Stat: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_Stat: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".Stat" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}dev\0\u{1}ino\0\u{1}mode\0\u{1}nlink\0\u{1}uid\0\u{1}gid\0\u{1}rdev\0\u{1}size\0\u{1}blksize\0\u{1}blocks\0\u{1}atime\0\u{1}mtime\0\u{1}ctime\0") @@ -3061,7 +3241,7 @@ extension Com_Apple_Containerization_Sandbox_V3_Stat: SwiftProtobuf.Message, Swi } } -extension Com_Apple_Containerization_Sandbox_V3_StatResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_StatResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".StatResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}stat\0\u{1}error\0") @@ -3138,7 +3318,275 @@ extension Com_Apple_Containerization_Sandbox_V3_StatResponse: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_FiTrimParams: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FiTrimParams" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}one_shot\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneShot? + var hadOneofValue = false + if let current = self.schedule { + hadOneofValue = true + if case .oneShot(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.schedule = .oneShot(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if case .oneShot(let v)? = self.schedule { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FiTrimParams, rhs: Com_Apple_Containerization_Sandbox_V3_FiTrimParams) -> Bool { + if lhs.schedule != rhs.schedule {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneShot: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = Com_Apple_Containerization_Sandbox_V3_FiTrimParams.protoMessageName + ".OneShot" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneShot, rhs: Com_Apple_Containerization_Sandbox_V3_FiTrimParams.OneShot) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_FiFreezeParams: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FiFreezeParams" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams, rhs: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_FiThawParams: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FiThawParams" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FiThawParams, rhs: Com_Apple_Containerization_Sandbox_V3_FiThawParams) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_FiTrimResult: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FiTrimResult" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}trimmed_bytes\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularUInt64Field(value: &self.trimmedBytes) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.trimmedBytes != 0 { + try visitor.visitSingularUInt64Field(value: self.trimmedBytes, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FiTrimResult, rhs: Com_Apple_Containerization_Sandbox_V3_FiTrimResult) -> Bool { + if lhs.trimmedBytes != rhs.trimmedBytes {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FilesystemOperationRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}path\0\u{1}trim\0\u{1}freeze\0\u{1}thaw\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.path) }() + case 2: try { + var v: Com_Apple_Containerization_Sandbox_V3_FiTrimParams? + var hadOneofValue = false + if let current = self.operation { + hadOneofValue = true + if case .trim(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.operation = .trim(v) + } + }() + case 3: try { + var v: Com_Apple_Containerization_Sandbox_V3_FiFreezeParams? + var hadOneofValue = false + if let current = self.operation { + hadOneofValue = true + if case .freeze(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.operation = .freeze(v) + } + }() + case 4: try { + var v: Com_Apple_Containerization_Sandbox_V3_FiThawParams? + var hadOneofValue = false + if let current = self.operation { + hadOneofValue = true + if case .thaw(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.operation = .thaw(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.path.isEmpty { + try visitor.visitSingularStringField(value: self.path, fieldNumber: 1) + } + switch self.operation { + case .trim?: try { + guard case .trim(let v)? = self.operation else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .freeze?: try { + guard case .freeze(let v)? = self.operation else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .thaw?: try { + guard case .thaw(let v)? = self.operation else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case nil: break + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, rhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest) -> Bool { + if lhs.path != rhs.path {return false} + if lhs.operation != rhs.operation {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".FilesystemOperationResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}trim\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { + var v: Com_Apple_Containerization_Sandbox_V3_FiTrimResult? + var hadOneofValue = false + if let current = self.result { + hadOneofValue = true + if case .trim(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + self.result = .trim(v) + } + }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + try { if case .trim(let v)? = self.result { + try visitor.visitSingularMessageField(value: v, fieldNumber: 1) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse, rhs: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse) -> Bool { + if lhs.result != rhs.result {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpLinkSetRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}up\0\u{1}mtu\0") @@ -3182,7 +3630,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetRequest: SwiftProtobuf. } } -extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpLinkSetResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3201,9 +3649,9 @@ extension Com_Apple_Containerization_Sandbox_V3_IpLinkSetResponse: SwiftProtobuf } } -extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpAddrAddRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Address\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Address\0\u{1}ipv6Address\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3213,30 +3661,39 @@ extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest: SwiftProtobuf. switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() case 2: try { try decoder.decodeSingularStringField(value: &self.ipv4Address) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._ipv6Address) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } if !self.ipv4Address.isEmpty { try visitor.visitSingularStringField(value: self.ipv4Address, fieldNumber: 2) } + try { if let v = self._ipv6Address { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest, rhs: Com_Apple_Containerization_Sandbox_V3_IpAddrAddRequest) -> Bool { if lhs.interface != rhs.interface {return false} if lhs.ipv4Address != rhs.ipv4Address {return false} + if lhs._ipv6Address != rhs._ipv6Address {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } -extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpAddrAddResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3255,9 +3712,9 @@ extension Com_Apple_Containerization_Sandbox_V3_IpAddrAddResponse: SwiftProtobuf } } -extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpRouteAddLinkRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}dstIpv4Addr\0\u{1}srcIpv4Addr\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}dstIpv4Addr\0\u{1}srcIpv4Addr\0\u{1}dstIpv6Addr\0\u{1}srcIpv6Addr\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3268,12 +3725,18 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() case 2: try { try decoder.decodeSingularStringField(value: &self.dstIpv4Addr) }() case 3: try { try decoder.decodeSingularStringField(value: &self.srcIpv4Addr) }() + case 4: try { try decoder.decodeSingularStringField(value: &self._dstIpv6Addr) }() + case 5: try { try decoder.decodeSingularStringField(value: &self._srcIpv6Addr) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } @@ -3283,6 +3746,12 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt if !self.srcIpv4Addr.isEmpty { try visitor.visitSingularStringField(value: self.srcIpv4Addr, fieldNumber: 3) } + try { if let v = self._dstIpv6Addr { + try visitor.visitSingularStringField(value: v, fieldNumber: 4) + } }() + try { if let v = self._srcIpv6Addr { + try visitor.visitSingularStringField(value: v, fieldNumber: 5) + } }() try unknownFields.traverse(visitor: &visitor) } @@ -3290,12 +3759,14 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkRequest: SwiftProt if lhs.interface != rhs.interface {return false} if lhs.dstIpv4Addr != rhs.dstIpv4Addr {return false} if lhs.srcIpv4Addr != rhs.srcIpv4Addr {return false} + if lhs._dstIpv6Addr != rhs._dstIpv6Addr {return false} + if lhs._srcIpv6Addr != rhs._srcIpv6Addr {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } -extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpRouteAddLinkResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3314,9 +3785,9 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddLinkResponse: SwiftPro } } -extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpRouteAddDefaultRequest" - public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Gateway\0") + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}ipv4Gateway\0\u{1}ipv6Gateway\0") public mutating func decodeMessage(decoder: inout D) throws { while let fieldNumber = try decoder.nextFieldNumber() { @@ -3326,30 +3797,39 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest: SwiftP switch fieldNumber { case 1: try { try decoder.decodeSingularStringField(value: &self.interface) }() case 2: try { try decoder.decodeSingularStringField(value: &self.ipv4Gateway) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._ipv6Gateway) }() default: break } } } public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 if !self.interface.isEmpty { try visitor.visitSingularStringField(value: self.interface, fieldNumber: 1) } if !self.ipv4Gateway.isEmpty { try visitor.visitSingularStringField(value: self.ipv4Gateway, fieldNumber: 2) } + try { if let v = self._ipv6Gateway { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() try unknownFields.traverse(visitor: &visitor) } public static func ==(lhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest, rhs: Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultRequest) -> Bool { if lhs.interface != rhs.interface {return false} if lhs.ipv4Gateway != rhs.ipv4Gateway {return false} + if lhs._ipv6Gateway != rhs._ipv6Gateway {return false} if lhs.unknownFields != rhs.unknownFields {return false} return true } } -extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".IpRouteAddDefaultResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3368,7 +3848,7 @@ extension Com_Apple_Containerization_Sandbox_V3_IpRouteAddDefaultResponse: Swift } } -extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ConfigureDnsRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}location\0\u{1}nameservers\0\u{1}domain\0\u{1}searchDomains\0\u{1}options\0") @@ -3422,7 +3902,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsRequest: SwiftProtob } } -extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ConfigureDnsResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3441,7 +3921,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureDnsResponse: SwiftProto } } -extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ConfigureHostsRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}location\0\u{1}entries\0\u{1}comment\0") @@ -3485,7 +3965,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest: SwiftProt } } -extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.protoMessageName + ".HostsEntry" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}ipAddress\0\u{1}hostnames\0\u{1}comment\0") @@ -3529,7 +4009,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsRequest.HostsEntry } } -extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ConfigureHostsResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3548,7 +4028,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ConfigureHostsResponse: SwiftPro } } -extension Com_Apple_Containerization_Sandbox_V3_SyncRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SyncRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SyncRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3567,7 +4047,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SyncRequest: SwiftProtobuf.Messa } } -extension Com_Apple_Containerization_Sandbox_V3_SyncResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_SyncResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".SyncResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap() @@ -3586,7 +4066,7 @@ extension Com_Apple_Containerization_Sandbox_V3_SyncResponse: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_KillRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_KillRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".KillRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}pid\0\u{2}\u{2}signal\0") @@ -3621,7 +4101,7 @@ extension Com_Apple_Containerization_Sandbox_V3_KillRequest: SwiftProtobuf.Messa } } -extension Com_Apple_Containerization_Sandbox_V3_KillResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_KillResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".KillResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}result\0") @@ -3651,7 +4131,7 @@ extension Com_Apple_Containerization_Sandbox_V3_KillResponse: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ContainerStatisticsRequest" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}container_ids\0\u{1}categories\0") @@ -3686,7 +4166,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsRequest: Swif } } -extension Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ContainerStatisticsResponse" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}containers\0") @@ -3716,7 +4196,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ContainerStatisticsResponse: Swi } } -extension Com_Apple_Containerization_Sandbox_V3_ContainerStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ContainerStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ContainerStats" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}container_id\0\u{1}process\0\u{1}memory\0\u{1}cpu\0\u{3}block_io\0\u{1}networks\0\u{3}memory_events\0") @@ -3828,7 +4308,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ContainerStats: SwiftProtobuf.Me } } -extension Com_Apple_Containerization_Sandbox_V3_ProcessStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_ProcessStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".ProcessStats" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}current\0\u{1}limit\0") @@ -3863,7 +4343,7 @@ extension Com_Apple_Containerization_Sandbox_V3_ProcessStats: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_MemoryStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_MemoryStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MemoryStats" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}usage_bytes\0\u{3}limit_bytes\0\u{3}swap_usage_bytes\0\u{3}swap_limit_bytes\0\u{3}cache_bytes\0\u{3}kernel_stack_bytes\0\u{3}slab_bytes\0\u{3}page_faults\0\u{3}major_page_faults\0\u{3}inactive_file\0\u{1}anon\0\u{3}workingset_refault_anon\0\u{3}workingset_refault_file\0\u{3}pgsteal_kswapd\0\u{3}pgsteal_direct\0\u{3}pgsteal_khugepaged\0") @@ -3968,7 +4448,7 @@ extension Com_Apple_Containerization_Sandbox_V3_MemoryStats: SwiftProtobuf.Messa } } -extension Com_Apple_Containerization_Sandbox_V3_CPUStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_CPUStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".CPUStats" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}usage_usec\0\u{3}user_usec\0\u{3}system_usec\0\u{3}throttling_periods\0\u{3}throttled_periods\0\u{3}throttled_time_usec\0") @@ -4023,7 +4503,7 @@ extension Com_Apple_Containerization_Sandbox_V3_CPUStats: SwiftProtobuf.Message, } } -extension Com_Apple_Containerization_Sandbox_V3_BlockIOStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_BlockIOStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".BlockIOStats" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}devices\0") @@ -4053,7 +4533,7 @@ extension Com_Apple_Containerization_Sandbox_V3_BlockIOStats: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_BlockIOEntry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_BlockIOEntry: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".BlockIOEntry" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}major\0\u{1}minor\0\u{3}read_bytes\0\u{3}write_bytes\0\u{3}read_operations\0\u{3}write_operations\0") @@ -4108,7 +4588,7 @@ extension Com_Apple_Containerization_Sandbox_V3_BlockIOEntry: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_NetworkStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_NetworkStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".NetworkStats" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}interface\0\u{1}receivedPackets\0\u{1}transmittedPackets\0\u{1}receivedBytes\0\u{1}transmittedBytes\0\u{1}receivedErrors\0\u{1}transmittedErrors\0") @@ -4168,7 +4648,7 @@ extension Com_Apple_Containerization_Sandbox_V3_NetworkStats: SwiftProtobuf.Mess } } -extension Com_Apple_Containerization_Sandbox_V3_MemoryEventStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { +nonisolated extension Com_Apple_Containerization_Sandbox_V3_MemoryEventStats: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { public static let protoMessageName: String = _protobuf_package + ".MemoryEventStats" public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}low\0\u{1}high\0\u{1}max\0\u{1}oom\0\u{3}oom_kill\0\u{3}oom_group_kill\0") diff --git a/Sources/Containerization/SandboxContext/SandboxContext.proto b/Sources/Containerization/SandboxContext/SandboxContext.proto index dfe8cd667..24fb24256 100644 --- a/Sources/Containerization/SandboxContext/SandboxContext.proto +++ b/Sources/Containerization/SandboxContext/SandboxContext.proto @@ -30,6 +30,8 @@ service SandboxContext { rpc Copy(CopyRequest) returns (stream CopyResponse); // Stat a path in the guest filesystem. rpc Stat(StatRequest) returns (StatResponse); + // Perform a filesystem operation on a mounted filesystem. + rpc FilesystemOperation(FilesystemOperationRequest) returns (FilesystemOperationResponse); // Create a new process inside the container. rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse); @@ -292,6 +294,35 @@ message StatResponse { string error = 2; // Non-empty if stat failed. } +message FiTrimParams { + oneof schedule { + OneShot one_shot = 1; + } + + message OneShot {} +} +message FiFreezeParams {} +message FiThawParams {} + +message FiTrimResult { + uint64 trimmed_bytes = 1; +} + +message FilesystemOperationRequest { + string path = 1; + oneof operation { + FiTrimParams trim = 2; + FiFreezeParams freeze = 3; + FiThawParams thaw = 4; + } +} + +message FilesystemOperationResponse { + oneof result { + FiTrimResult trim = 1; + } +} + message IpLinkSetRequest { string interface = 1; bool up = 2; @@ -303,6 +334,7 @@ message IpLinkSetResponse {} message IpAddrAddRequest { string interface = 1; string ipv4Address = 2; + optional string ipv6Address = 3; } message IpAddrAddResponse {} @@ -311,6 +343,8 @@ message IpRouteAddLinkRequest { string interface = 1; string dstIpv4Addr = 2; string srcIpv4Addr = 3; + optional string dstIpv6Addr = 4; + optional string srcIpv6Addr = 5; } message IpRouteAddLinkResponse {} @@ -318,6 +352,7 @@ message IpRouteAddLinkResponse {} message IpRouteAddDefaultRequest { string interface = 1; string ipv4Gateway = 2; + optional string ipv6Gateway = 3; } message IpRouteAddDefaultResponse {} diff --git a/Sources/Containerization/SandboxOverrides.swift b/Sources/Containerization/SandboxOverrides.swift new file mode 100644 index 000000000..b94024e4f --- /dev/null +++ b/Sources/Containerization/SandboxOverrides.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import Foundation + +/// Per-component opt-ins to weaken the upstream-secure spawn flags for +/// cloud-hypervisor and virtiofsd. Each flag is independent so an operator +/// can target the minimum hardening that needs to come off — e.g. running +/// inside apple/container's `--virtualization` dev container needs both, +/// but a future bare-metal Linux host with a custom seccomp policy might +/// only need one. +/// +/// Read once per process at first reference. Default false (secure). +/// +/// **Legacy alias.** `CONTAINERIZATION_RELAXED_SANDBOX=1` continues to flip +/// every flag here for back-compat with the original combined toggle. New +/// callers should prefer the per-component vars below. +enum SandboxOverrides { + /// When set, cloud-hypervisor is launched with `--seccomp false`. + /// Disables CH's userspace seccomp BPF filter; the kernel's filter + /// (whatever the host policy is) still applies. + /// + /// Env: `CONTAINERIZATION_NO_CH_SECCOMP=1` + static let chSeccompDisabled: Bool = + boolEnv("CONTAINERIZATION_NO_CH_SECCOMP") || legacyRelaxedSandbox + + /// When set, virtiofsd is launched with `--sandbox none`. Disables + /// virtiofsd's userns + pivot_root + seccomp setup. Combined with the + /// vendored cap-drop patch (`scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch`) + /// at build time, the daemon retains its parent's capabilities — so + /// only enable this when the parent process is the trust boundary you + /// want. + /// + /// Env: `CONTAINERIZATION_NO_VIRTIOFSD_SANDBOX=1` + static let virtiofsdSandboxDisabled: Bool = + boolEnv("CONTAINERIZATION_NO_VIRTIOFSD_SANDBOX") || legacyRelaxedSandbox + + /// True if any override is currently in effect — used by callers that + /// want to log a single banner regardless of which flag is set. + static var anyEnabled: Bool { + chSeccompDisabled || virtiofsdSandboxDisabled + } + + /// Back-compat alias: enables every per-component flag in one shot. + private static let legacyRelaxedSandbox: Bool = + boolEnv("CONTAINERIZATION_RELAXED_SANDBOX") + + private static func boolEnv(_ name: String) -> Bool { + ProcessInfo.processInfo.environment[name] == "1" + } +} + +/// Minimal environment allowlist for child processes we spawn (`CHProcess`, +/// `VirtiofsdProcess`). Inheriting the parent's full env exposes any +/// secrets the calling tool happens to have set (`AWS_*`, `KUBE_*`, +/// `*_TOKEN`, etc.) to a binary that has no use for them. Only the +/// variables below are forwarded — extend this list when a new spawn-time +/// dependency surfaces, and document why. +/// +/// - `PATH`, `HOME`: minimum POSIX hygiene; some libc/setuid paths look at +/// these even for self-contained binaries. +/// - `RUST_LOG`, `RUST_BACKTRACE`: cloud-hypervisor and virtiofsd are Rust +/// binaries; pass these through if the operator has set them so +/// debugging is unimpaired. +enum ChildEnvironment { + /// Construct a minimal environment for the child as `KEY=value` strings + /// suitable for `Command.environment`. Variables not present in the + /// parent env are simply omitted. + static func minimal() -> [String] { + let allowlist = ["PATH", "HOME", "RUST_LOG", "RUST_BACKTRACE"] + let parent = ProcessInfo.processInfo.environment + var entries: [String] = [] + // PATH falls back to a sane default since Command's execve needs an + // absolute path anyway, but child Rust binaries occasionally probe + // PATH for helper tools. + let path = parent["PATH"] ?? "/usr/sbin:/usr/bin:/sbin:/bin" + entries.append("PATH=\(path)") + for key in allowlist where key != "PATH" { + if let value = parent[key] { + entries.append("\(key)=\(value)") + } + } + return entries + } +} +#endif diff --git a/Sources/Containerization/TAPDevice.swift b/Sources/Containerization/TAPDevice.swift new file mode 100644 index 000000000..bae6e5a2a --- /dev/null +++ b/Sources/Containerization/TAPDevice.swift @@ -0,0 +1,155 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import CShim +import ContainerizationError +import ContainerizationExtras +import ContainerizationNetlink +import Foundation +import Synchronization + +#if canImport(Musl) +import Musl +let osClose = Musl.close +#elseif canImport(Glibc) +import Glibc +let osClose = Glibc.close +#endif + +/// A Linux TAP network device whose kernel interface lives only as long as +/// this `TAPDevice` instance. Created via `/dev/net/tun` + `ioctl(TUNSETIFF)`, +/// optionally enslaved to a pre-existing bridge, with MTU/MAC/UP applied via +/// netlink. The fd is held internally; closing it (explicitly or via deinit) +/// removes the interface from the kernel. +/// +/// `TUNSETPERSIST` is never called, so process death also cleans up the +/// device automatically. Cloud-hypervisor opens the same TAP **by name**; +/// the held fd keeps the interface alive across CH's open/close cycle. +/// +/// Requires `CAP_NET_ADMIN`. +public final class TAPDevice: Sendable { + /// The kernel-resolved interface name. May differ from the `name` + /// parameter passed to `init` if the kernel substituted one (e.g. when + /// `nil` was passed and the kernel picked `tapN`). + public let name: String + + public let mtu: UInt32 + + /// The MAC address as set on init, or nil if the kernel auto-assigned one. + /// Not read back from the kernel. + public let macAddress: MACAddress? + + private let _fd: Mutex + + /// Create a TAP device. + /// + /// - Parameters: + /// - name: Desired interface name. Empty or nil = kernel picks (`tap%d`). + /// Length must be < 16 (`IFNAMSIZ - 1`). + /// - bridge: Name of an existing bridge to enslave the TAP to, or nil. + /// - mtu: MTU in bytes (default 1500). + /// - macAddress: Hardware address to set, or nil to leave kernel default. + public init( + name: String? = nil, + bridge: String? = nil, + mtu: UInt32 = 1500, + macAddress: MACAddress? = nil + ) throws { + if let n = name, n.utf8.count >= 16 { + throw ContainerizationError( + .invalidArgument, + message: "TAP name too long: \(n) (must be < 16 chars)" + ) + } + + // 1. Open + TUNSETIFF via CShim. Returns fd on success, -errno on failure. + var resolved = [CChar](repeating: 0, count: 16) + let fd: Int32 = resolved.withUnsafeMutableBufferPointer { buf in + (name ?? "").withCString { reqPtr in + cz_tap_create(reqPtr, buf.baseAddress, 16) + } + } + guard fd >= 0 else { + throw ContainerizationError( + .internalError, + message: "cz_tap_create failed: errno=\(-fd)" + ) + } + + // From here on, any failure must close `fd` to release the kernel iface. + var fdToClean: Int32? = fd + defer { + if let f = fdToClean { + _ = osClose(f) + } + } + + let resolvedName: String = resolved.withUnsafeBufferPointer { buf in + // String(cString:) is deprecated in newer toolchains. Build the + // String from the NUL-terminated UTF-8 bytes directly. + let bytes = buf.prefix(while: { $0 != 0 }).map { UInt8(bitPattern: $0) } + return String(decoding: bytes, as: UTF8.self) + } + + // 2. Apply MAC and master via netlink (single RTM_NEWLINK). + let session = try NetlinkSession(socket: DefaultNetlinkSocket()) + do { + try session.linkSetAttributes( + interface: resolvedName, + macAddress: macAddress, + master: bridge + ) + } catch { + throw ContainerizationError( + .internalError, + message: "linkSetAttributes failed for \(resolvedName): \(error)" + ) + } + + // 3. Bring UP and set MTU. + do { + try session.linkSet(interface: resolvedName, up: true, mtu: mtu) + } catch { + throw ContainerizationError( + .internalError, + message: "linkSet(up:mtu:) failed for \(resolvedName): \(error)" + ) + } + + // 4. Success — store and clear cleanup. + self.name = resolvedName + self.mtu = mtu + self.macAddress = macAddress + self._fd = Mutex(fd) + fdToClean = nil + } + + /// Close the held fd, removing the interface from the kernel. Idempotent. + public func close() { + _fd.withLock { fd in + if let f = fd { + _ = osClose(f) + fd = nil + } + } + } + + deinit { + close() + } +} +#endif diff --git a/Sources/Containerization/VZVirtualMachineInstance.swift b/Sources/Containerization/VZVirtualMachineInstance.swift index a711b2b5a..160c50267 100644 --- a/Sources/Containerization/VZVirtualMachineInstance.swift +++ b/Sources/Containerization/VZVirtualMachineInstance.swift @@ -191,7 +191,7 @@ extension VZVirtualMachineInstance: VirtualMachineInstance { try await self.vm.start(queue: self.queue) - let agent = try Vminitd( + let agent = try await Vminitd( connection: try await self.vm.waitForAgent(queue: self.queue), group: self.group ) @@ -260,7 +260,7 @@ extension VZVirtualMachineInstance: VirtualMachineInstance { port: Vminitd.port ) let handle = try conn.dupHandle() - return try Vminitd(connection: handle, group: self.group) + return try await Vminitd(connection: handle, group: self.group) } catch { if let err = error as? ContainerizationError { throw err @@ -566,38 +566,6 @@ extension VZVirtualMachineInstance.Configuration { } } -extension Kernel { - func linuxCommandline(initialFilesystem: Mount) -> String { - var args = self.commandLine.kernelArgs - - args.append("init=/sbin/vminitd") - // rootfs is always set as ro. - args.append("ro") - - switch initialFilesystem.type { - case "virtiofs": - args.append(contentsOf: [ - "rootfstype=virtiofs", - "root=rootfs", - ]) - case "ext4": - args.append(contentsOf: [ - "rootfstype=ext4", - "root=/dev/vda", - ]) - default: - fatalError("unsupported initfs filesystem \(initialFilesystem.type)") - } - - if self.commandLine.initArgs.count > 0 { - args.append("--") - args.append(contentsOf: self.commandLine.initArgs) - } - - return args.joined(separator: " ") - } -} - public protocol VZInterface { func device() throws -> VZVirtioNetworkDeviceConfiguration } diff --git a/Sources/Containerization/VirtiofsdProcess.swift b/Sources/Containerization/VirtiofsdProcess.swift new file mode 100644 index 000000000..7c73bd10f --- /dev/null +++ b/Sources/Containerization/VirtiofsdProcess.swift @@ -0,0 +1,187 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Logging +import Synchronization + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +/// A managed `virtiofsd` subprocess serving a single shared directory. +/// +/// One `VirtiofsdProcess` per virtio-fs share. Cloud Hypervisor connects to +/// the published UDS via its `FsConfig.socket` field. Lifecycle mirrors +/// `CHProcess`: spawn + wait-for-socket on `start()`, SIGTERM/SIGKILL on +/// `terminate()`. +final class VirtiofsdProcess: Sendable { + struct Config: Sendable { + let binary: URL + let socketPath: URL + let sharedDir: URL + let readonly: Bool + } + + private struct State { + var command: Command? + var exitTask: Task? + } + + private let config: Config + private let logger: Logger? + private let state: Mutex + + init(config: Config, logger: Logger?) { + self.config = config + self.logger = logger + self.state = Mutex(State(command: nil, exitTask: nil)) + } + + /// Spawn virtiofsd and wait for its UDS to accept connections. + func start() async throws { + var arguments = [ + "--socket-path", config.socketPath.path, + "--shared-dir", config.sharedDir.path, + ] + if SandboxOverrides.virtiofsdSandboxDisabled { + // virtiofsd defaults to `--sandbox namespace`, which sets up a + // userns + pivot_root + seccomp filter. Inside apple/container's + // --virtualization dev container the default seccomp profile + // SIGSYS-kills processes that hit unfiltered syscalls (same + // reason CH runs with `--seccomp false`). `--sandbox none` + // skips both userns setup and seccomp; safe inside the per-VM + // dev container only. Opt-in via + // CONTAINERIZATION_NO_VIRTIOFSD_SANDBOX=1. + logger?.warning( + "virtiofsd launching with --sandbox none (CONTAINERIZATION_NO_VIRTIOFSD_SANDBOX=1) — userns/pivot_root/seccomp setup disabled" + ) + arguments.append(contentsOf: ["--sandbox", "none"]) + } + if config.readonly { + arguments.append("--readonly") + } + + var command = Command( + config.binary.path, + arguments: arguments, + environment: ChildEnvironment.minimal() + ) + // Inherit stderr so virtiofsd's startup logs surface in the host's + // log stream rather than vanishing into /dev/null (Command's default). + command.stderr = FileHandle.standardError + // Same rationale as CHProcess: keep virtiofsd out of the parent's + // controlling-TTY signal group so Ctrl-C doesn't kill it before our + // own terminate() ladder runs. + command.attrs.setsid = true + do { + try command.start() + } catch { + throw error + } + + let exitTask = Task.detached { [command, logger] in + do { + _ = try command.wait() + } catch { + logger?.error("virtiofsd wait failed: \(error)") + } + } + + state.withLock { + $0.command = command + $0.exitTask = exitTask + } + + try await waitForSocket() + } + + /// SIGTERM → grace window → SIGKILL. Returns once virtiofsd is reaped. + func terminate(graceSeconds: UInt32) async { + guard let command = state.withLock({ $0.command }) else { return } + + _ = command.kill(SIGTERM) + + do { + try await Timeout.run(for: .seconds(Int(graceSeconds))) { + await self.waitForExit() + } + } catch { + logger?.warning("virtiofsd did not exit within \(graceSeconds)s, sending SIGKILL") + _ = command.kill(SIGKILL) + await waitForExit() + } + } + + // MARK: - Private helpers + + private static let socketDeadline: Duration = .seconds(10) + private static let socketPollInterval: Duration = .milliseconds(50) + + private func waitForExit() async { + guard let task = state.withLock({ $0.exitTask }) else { return } + await task.value + } + + private func waitForSocket() async throws { + let clock = ContinuousClock() + let started = clock.now + let deadline = started.advanced(by: Self.socketDeadline) + + while clock.now < deadline { + if Self.isSocketReady(at: config.socketPath) { + let elapsed = clock.now - started + logger?.debug("virtiofsd socket bound in \(elapsed) at \(config.socketPath.path)") + return + } + try? await Task.sleep(for: Self.socketPollInterval) + } + + // Capture diagnostic state before terminating. + let fm = FileManager.default + let socketExists = fm.fileExists(atPath: config.socketPath.path) + let parentExists = fm.fileExists(atPath: config.socketPath.deletingLastPathComponent().path) + let sharedExists = fm.fileExists(atPath: config.sharedDir.path) + let detail = "socketExists=\(socketExists) parentDirExists=\(parentExists) sharedDirExists=\(sharedExists)" + + await terminate(graceSeconds: 5) + throw ContainerizationError( + .timeout, + message: "virtiofsd socket not connectable at \(config.socketPath.path) within \(Self.socketDeadline) [\(detail)]" + ) + } + + private static func isSocketReady(at url: URL) -> Bool { + // Only check that the socket file exists. Do NOT connect — virtiofsd + // runs in vhost-user mode where the first incoming connection is + // treated as the VMM (cloud-hypervisor); when that connection closes, + // virtiofsd exits. A connect-then-close readiness probe therefore + // kills virtiofsd before CH ever gets to it, leaving CH's vm.boot + // failing with "vhost-user: can't connect to peer: No such file + // or directory". + var st = stat() + guard stat(url.path, &st) == 0 else { return false } + return (st.st_mode & S_IFMT) == S_IFSOCK + } + +} +#endif diff --git a/Sources/Containerization/VirtualMachineAgent+Interface.swift b/Sources/Containerization/VirtualMachineAgent+Interface.swift new file mode 100644 index 000000000..e2fe72279 --- /dev/null +++ b/Sources/Containerization/VirtualMachineAgent+Interface.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Logging + +extension VirtualMachineAgent { + /// Configure a single network interface inside the sandbox: assign addresses, + /// bring the link up, and (when requested) install the link/default routes. + func setupInterface( + _ interface: any Interface, + name: String, + setDefaultRoute: Bool, + logger: Logger? + ) async throws { + logger?.debug("setting up interface \(name) with v4 \(interface.ipv4Address) v6 \(interface.ipv6Address?.description ?? "")") + try await addressAdd( + name: name, + address: .init(ipv4Address: interface.ipv4Address, ipv6Address: interface.ipv6Address) + ) + try await up(name: name, mtu: interface.mtu) + + guard setDefaultRoute else { return } + + let ipv4Address = interface.ipv4Address + let ipv4Gateway = interface.ipv4Gateway + let ipv6Gateway = interface.ipv6Gateway + let ipv6Address = interface.ipv6Address + + let needsIPv4LinkRoute: Bool + if let ipv4Gateway { + needsIPv4LinkRoute = !ipv4Address.contains(ipv4Gateway) + } else { + needsIPv4LinkRoute = false + } + + let needsIPv6LinkRoute: Bool + if let ipv6Gateway, let ipv6Address { + needsIPv6LinkRoute = !ipv6Address.contains(ipv6Gateway) + } else { + needsIPv6LinkRoute = false + } + + if needsIPv4LinkRoute, let ipv4Gateway { + logger?.debug("v4 gateway \(ipv4Gateway) is outside subnet \(ipv4Address), adding a route first") + } + if needsIPv6LinkRoute, let ipv6Gateway, let ipv6Address { + logger?.debug("v6 gateway \(ipv6Gateway) is outside subnet \(ipv6Address), adding a route first") + } + + if needsIPv4LinkRoute || needsIPv6LinkRoute { + try await routeAddLink( + name: name, + route: .init( + ipv4Destination: needsIPv4LinkRoute ? ipv4Gateway : nil, + ipv4Source: needsIPv4LinkRoute ? ipv4Address.address : nil, + ipv6Destination: needsIPv6LinkRoute ? ipv6Gateway : nil, + ipv6Source: needsIPv6LinkRoute ? ipv6Address?.address : nil + ) + ) + } + + if ipv4Gateway == nil && ipv6Gateway == nil { + logger?.debug("no gateway for \(name)") + } + try await routeAddDefault( + name: name, + route: .init(ipv4Gateway: ipv4Gateway, ipv6Gateway: ipv6Gateway) + ) + } +} diff --git a/Sources/Containerization/VirtualMachineAgent.swift b/Sources/Containerization/VirtualMachineAgent.swift index 9ded5d7fc..05ea79505 100644 --- a/Sources/Containerization/VirtualMachineAgent.swift +++ b/Sources/Containerization/VirtualMachineAgent.swift @@ -25,6 +25,12 @@ public struct WriteFileFlags { public var create = false } +public enum FilesystemOperation: Sendable { + case freeze + case thaw + case trim +} + /// A protocol for the agent running inside a virtual machine. If an operation isn't /// supported the implementation MUST return a ContainerizationError with a code of /// `.unsupported`. @@ -34,6 +40,8 @@ public protocol VirtualMachineAgent: Sendable { func standardSetup() async throws /// Close any resources held by the agent. func close() async throws + // Perform a filesystem operation on the given path. + func filesystemOperation(operation: FilesystemOperation, path: String) async throws // POSIX-y func getenv(key: String) async throws -> String @@ -67,9 +75,9 @@ public protocol VirtualMachineAgent: Sendable { // Networking func up(name: String, mtu: UInt32?) async throws func down(name: String) async throws - func addressAdd(name: String, ipv4Address: CIDRv4) async throws - func routeAddLink(name: String, dstIPv4Addr: IPv4Address, srcIPv4Addr: IPv4Address?) async throws - func routeAddDefault(name: String, ipv4Gateway: IPv4Address?) async throws + func addressAdd(name: String, address: InterfaceAddress) async throws + func routeAddLink(name: String, route: LinkRoute) async throws + func routeAddDefault(name: String, route: DefaultRoute) async throws func configureDNS(config: DNS, location: String) async throws func configureHosts(config: Hosts, location: String) async throws diff --git a/Sources/Containerization/VirtualMachineInstance.swift b/Sources/Containerization/VirtualMachineInstance.swift index 1b8343849..302e97ae7 100644 --- a/Sources/Containerization/VirtualMachineInstance.swift +++ b/Sources/Containerization/VirtualMachineInstance.swift @@ -26,6 +26,17 @@ public enum VirtualMachineInstanceState: Sendable { case unknown } +/// How the VMM exposes virtiofs devices to the guest. +/// +/// - `unified`: a single virtio-fs device (tag `virtiofs`) carries all +/// shares as subdirectories (Apple's `VZMultipleDirectoryShare` model). +/// - `perTag`: one virtio-fs device per source-hash tag, each mounted +/// separately in the guest (cloud-hypervisor / virtiofsd model). +public enum VirtiofsLayout: Sendable { + case unified + case perTag +} + /// A live instance of a virtual machine. public protocol VirtualMachineInstance: Sendable { associatedtype Agent: VirtualMachineAgent @@ -34,6 +45,10 @@ public protocol VirtualMachineInstance: Sendable { var state: VirtualMachineInstanceState { get } var mounts: [String: [AttachedFilesystem]] { get } + + /// How this VMM exposes virtiofs devices to the guest. Defaults to + /// `.unified` (the VZ-shaped behavior); CH overrides to `.perTag`. + var virtiofsLayout: VirtiofsLayout { get } /// Dial the Agent. It's up the VirtualMachineInstance to determine /// what port the agent is listening on. func dialAgent() async throws -> Agent @@ -81,6 +96,7 @@ public protocol VirtualMachineInstance: Sendable { } extension VirtualMachineInstance { + public var virtiofsLayout: VirtiofsLayout { .unified } public func pause() async throws { throw ContainerizationError(.unsupported, message: "pause") } diff --git a/Sources/Containerization/Vminitd.swift b/Sources/Containerization/Vminitd.swift index 92e6ee8db..6af76662e 100644 --- a/Sources/Containerization/Vminitd.swift +++ b/Sources/Containerization/Vminitd.swift @@ -20,9 +20,8 @@ import ContainerizationOCI import ContainerizationOS import Foundation import GRPCCore -import GRPCNIOTransportCore -import NIOCore -import NIOPosix +import GRPCNIOTransportHTTP2 +import NIO /// A remote connection into the vminitd Linux guest agent via a port (vsock). /// Used to modify the runtime environment of the Linux sandbox. @@ -34,18 +33,24 @@ public struct Vminitd: Sendable { public let grpcClient: GRPCClient private let connectionTask: Task - public init(connection: FileHandle, group: any EventLoopGroup) throws { - let channel = try ClientBootstrap(group: group) - .channelInitializer { channel in - channel.eventLoop.makeCompletedFuture(withResultOf: { - try channel.pipeline.syncOperations.addHandler(HTTP2ConnectBufferingHandler()) - }) + public init(connection: FileHandle, group: any EventLoopGroup) async throws { + let transport = try await HTTP2ClientTransport.WrappedChannel.wrapping( + config: .defaults { $0.connection.maxIdleTime = nil }, + serviceConfig: .init() + ) { configure in + try await withCheckedThrowingContinuation { continuation in + ClientBootstrap(group: group) + .channelInitializer { channel in + configure(channel).map { configured in + continuation.resume(returning: configured) + } + } + .withConnectedSocket(connection.fileDescriptor) + .whenFailure { error in + continuation.resume(throwing: error) + } } - .withConnectedSocket(connection.fileDescriptor).wait() - let transport = HTTP2ClientTransport.WrappedChannel.wrapping( - channel: channel, - config: .defaults { $0.connection.maxIdleTime = nil } - ) + } let grpcClient = GRPCClient(transport: transport) self.grpcClient = grpcClient self.client = Com_Apple_Containerization_Sandbox_V3_SandboxContext.Client(wrapping: self.grpcClient) @@ -207,6 +212,15 @@ extension Vminitd: VirtualMachineAgent { }) } + /// Perform a filesystem operation on a path inside the sandbox's environment. + public func filesystemOperation(operation: FilesystemOperation, path: String) async throws { + _ = try await client.filesystemOperation( + .with { + $0.operation = operation.toProtoOperation() + $0.path = path + }) + } + public func createProcess( id: String, containerID: String?, @@ -397,33 +411,50 @@ extension Vminitd { } /// Add an IP address to the sandbox's network interfaces. - public func addressAdd(name: String, ipv4Address: CIDRv4) async throws { + public func addressAdd(name: String, address: InterfaceAddress) async throws { _ = try await client.ipAddrAdd( .with { $0.interface = name - $0.ipv4Address = ipv4Address.description + $0.ipv4Address = address.ipv4Address.description + if let ipv6Address = address.ipv6Address { + $0.ipv6Address = ipv6Address.description + } }) } - /// Add a route in the sandbox's environment. - public func routeAddLink(name: String, dstIPv4Addr: IPv4Address, srcIPv4Addr: IPv4Address? = nil) async throws { - let dstCIDR = "\(dstIPv4Addr.description)/32" + /// Add a link-scoped route in the sandbox's environment, used to install an + /// on-link host route (a /32 for v4, /128 for v6) to a gateway that lives + /// outside the interface's subnet so the kernel will accept the default route. + /// `route.ipv4Destination`/`route.ipv6Destination` carry the + /// gateway address; the wire format is a CIDR string with the per-family host prefix appended. + public func routeAddLink(name: String, route: LinkRoute) async throws { _ = try await client.ipRouteAddLink( .with { $0.interface = name - $0.dstIpv4Addr = dstCIDR - if let srcIPv4Addr { - $0.srcIpv4Addr = srcIPv4Addr.description + if let ipv4Destination = route.ipv4Destination { + $0.dstIpv4Addr = "\(ipv4Destination.description)/32" + } + if let ipv4Source = route.ipv4Source { + $0.srcIpv4Addr = ipv4Source.description + } + if let ipv6Destination = route.ipv6Destination { + $0.dstIpv6Addr = "\(ipv6Destination.description)/128" + } + if let ipv6Source = route.ipv6Source { + $0.srcIpv6Addr = ipv6Source.description } }) } /// Set the default route in the sandbox's environment. - public func routeAddDefault(name: String, ipv4Gateway: IPv4Address?) async throws { + public func routeAddDefault(name: String, route: DefaultRoute) async throws { _ = try await client.ipRouteAddDefault( .with { $0.interface = name - $0.ipv4Gateway = ipv4Gateway?.description ?? "" + $0.ipv4Gateway = route.ipv4Gateway?.description ?? "" + if let ipv6Gateway = route.ipv6Gateway { + $0.ipv6Gateway = ipv6Gateway.description + } }) } @@ -596,3 +627,20 @@ extension StatCategory { return categories } } + +extension FilesystemOperation { + /// Convert FilesystemOperation to proto oneof value. + fileprivate func toProtoOperation() -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest.OneOf_Operation { + switch self { + case .freeze: + return .freeze(.init()) + case .thaw: + return .thaw(.init()) + case .trim: + return .trim( + .with { + $0.oneShot = .init() + }) + } + } +} diff --git a/Sources/Containerization/VmnetNetwork.swift b/Sources/Containerization/VmnetNetwork.swift index 28ea32a86..86ec92cda 100644 --- a/Sources/Containerization/VmnetNetwork.swift +++ b/Sources/Containerization/VmnetNetwork.swift @@ -31,39 +31,82 @@ public struct VmnetNetwork: Network { /// The IPv4 subnet of this network. public let subnet: CIDRv4 + /// The IPv6 prefix of this network. + public let prefixV6: CIDRv6? + /// The IPv4 gateway address of this network. public var ipv4Gateway: IPv4Address { subnet.gateway } - struct Allocator: Sendable { - private let addressAllocator: any AddressAllocator - private let cidr: CIDRv4 - private var allocations: [String: UInt32] + /// The IPv6 gateway address of this network, if a prefix exists. + public var ipv6Gateway: IPv6Address? { + prefixV6?.gateway + } - init(cidr: CIDRv4) throws { - self.cidr = cidr + struct Allocator: Sendable { + private let indexAllocatorV4: any AddressAllocator + private let indexAllocatorV6: (any AddressAllocator)? + private let cidrV4: CIDRv4 + private let cidrV6: CIDRv6? + private var allocations: [String: (v4: UInt32, v6: UInt32?)] + + init(cidrV4: CIDRv4, cidrV6: CIDRv6?) throws { + self.cidrV4 = cidrV4 + self.cidrV6 = cidrV6 self.allocations = .init() - let size = Int(cidr.upper.value - cidr.lower.value - 3) - self.addressAllocator = try UInt32.rotatingAllocator( - lower: cidr.lower.value + 2, - size: UInt32(size) + let v4Size = Int(cidrV4.upper.value - cidrV4.lower.value - 3) + self.indexAllocatorV4 = try UInt32.rotatingAllocator( + lower: cidrV4.lower.value + 2, + size: UInt32(v4Size) ) + if cidrV6 != nil { + // Independent v6 allocator. The host portion is sourced from a + // UInt32 index regardless of prefix length, and we never need + // more v6 entries than v4 can serve. + self.indexAllocatorV6 = try UInt32.rotatingAllocator( + lower: 2, + size: UInt32(v4Size) + ) + } else { + self.indexAllocatorV6 = nil + } } - mutating func allocate(_ id: String) throws -> CIDRv4 { + mutating func allocate(_ id: String) throws -> (CIDRv4, CIDRv6?) { if allocations[id] != nil { throw ContainerizationError(.exists, message: "allocation with id \(id) already exists") } - let index = try addressAllocator.allocate() - allocations[id] = index - let ip = IPv4Address(index) - return try CIDRv4(ip, prefix: cidr.prefix) + let v4Index = try indexAllocatorV4.allocate() + let v4 = try CIDRv4(IPv4Address(v4Index), prefix: cidrV4.prefix) + + var v6Index: UInt32? = nil + let v6: CIDRv6? + if let indexAllocatorV6, let cidrV6 { + do { + let idx = try indexAllocatorV6.allocate() + v6Index = idx + let v6Value = (cidrV6.address.value & cidrV6.prefix.prefixMask128) | UInt128(idx) + v6 = try CIDRv6(IPv6Address(v6Value), prefix: cidrV6.prefix) + } catch { + // Roll back v4 so the pair stays atomic. + try? indexAllocatorV4.release(v4Index) + throw error + } + } else { + v6 = nil + } + + allocations[id] = (v4: v4Index, v6: v6Index) + return (v4, v6) } mutating func release(_ id: String) throws { - if let index = self.allocations[id] { - try addressAllocator.release(index) + if let entry = self.allocations[id] { + try indexAllocatorV4.release(entry.v4) + if let v6Index = entry.v6 { + try indexAllocatorV6?.release(v6Index) + } allocations.removeValue(forKey: id) } } @@ -73,6 +116,8 @@ public struct VmnetNetwork: Network { public struct Interface: Containerization.Interface, VZInterface, Sendable { public let ipv4Address: CIDRv4 public let ipv4Gateway: IPv4Address? + public let ipv6Address: CIDRv6? + public let ipv6Gateway: IPv6Address? public let macAddress: MACAddress? public let mtu: UInt32 @@ -83,11 +128,15 @@ public struct VmnetNetwork: Network { reference: vmnet_network_ref, ipv4Address: CIDRv4, ipv4Gateway: IPv4Address? = nil, + ipv6Address: CIDRv6? = nil, + ipv6Gateway: IPv6Address? = nil, macAddress: MACAddress? = nil, mtu: UInt32 = 1500 ) { self.ipv4Address = ipv4Address self.ipv4Gateway = ipv4Gateway + self.ipv6Address = ipv6Address + self.ipv6Gateway = ipv6Gateway self.macAddress = macAddress self.mtu = mtu self.reference = reference @@ -110,8 +159,13 @@ public struct VmnetNetwork: Network { /// Creates a new network. /// - Parameters: /// - mode: The vmnet operating mode. Defaults to `.VMNET_SHARED_MODE`. - /// - subnet: The subnet to use for this network. - public init(mode: vmnet.operating_modes_t = .VMNET_SHARED_MODE, subnet: CIDRv4? = nil) throws { + /// - subnetV4: The IPv4 subnet to use for this network. + /// - prefixV6: The IPv6 prefix to use for this network. + public init( + mode: vmnet.operating_modes_t = .VMNET_SHARED_MODE, + subnet: CIDRv4? = nil, + prefixV6: CIDRv6? = nil + ) throws { var status: vmnet_return_t = .VMNET_FAILURE guard let config = vmnet_network_configuration_create(mode, &status) else { throw ContainerizationError(.unsupported, message: "failed to create vmnet config with status \(status)") @@ -120,39 +174,38 @@ public struct VmnetNetwork: Network { vmnet_network_configuration_disable_dhcp(config) if let subnet { - try Self.configureSubnet(config, subnet: subnet) + try Self.configureSubnetV4(config, subnetV4: subnet) + } + if let prefixV6 { + try Self.configurePrefixV6(config, prefixV6: prefixV6) } guard let ref = vmnet_network_create(config, &status), status == .VMNET_SUCCESS else { throw ContainerizationError(.unsupported, message: "failed to create vmnet network with status \(status)") } - let cidr = try Self.getSubnet(ref) + let cidrV4 = try Self.getSubnetV4(ref) + let cidrV6 = Self.getPrefixV6(ref) - self.allocator = try .init(cidr: cidr) - self.subnet = cidr + self.allocator = try .init(cidrV4: cidrV4, cidrV6: cidrV6) + self.subnet = cidrV4 + self.prefixV6 = cidrV6 self.reference = ref } - /// Returns a new interface for use with a container. + /// Returns a new interface for use with a container. Allocates an IPv4 + /// address from the network's subnet, and — when the network has an IPv6 + /// prefix — an IPv6 address from that prefix. The two allocations are + /// independent. /// - Parameter id: The container ID. public mutating func createInterface(_ id: String) throws -> Containerization.Interface? { - let ipv4Address = try allocator.allocate(id) + let (v4, v6) = try allocator.allocate(id) return Self.Interface( reference: self.reference, - ipv4Address: ipv4Address, + ipv4Address: v4, ipv4Gateway: self.ipv4Gateway, - ) - } - - /// Returns a new interface without a default gateway route. - /// Use this for secondary interfaces where another interface already provides the default route. - /// - Parameter id: The container ID. - public mutating func createInterfaceWithoutGateway(_ id: String) throws -> Containerization.Interface? { - let ipv4Address = try allocator.allocate(id) - return Self.Interface( - reference: self.reference, - ipv4Address: ipv4Address, + ipv6Address: v6, + ipv6Gateway: self.ipv6Gateway ) } @@ -161,22 +214,37 @@ public struct VmnetNetwork: Network { /// - id: The container ID. /// - mtu: The MTU for the interface. public mutating func createInterface(_ id: String, mtu: UInt32) throws -> Containerization.Interface? { - let ipv4Address = try allocator.allocate(id) + let (v4, v6) = try allocator.allocate(id) return Self.Interface( reference: self.reference, - ipv4Address: ipv4Address, + ipv4Address: v4, ipv4Gateway: self.ipv4Gateway, + ipv6Address: v6, + ipv6Gateway: self.ipv6Gateway, mtu: mtu ) } + /// Returns a new interface without a default gateway route. Useful for + /// secondary interfaces where another interface already provides the + /// default route. + /// - Parameter id: The container ID. + public mutating func createInterfaceWithoutGateway(_ id: String) throws -> Containerization.Interface? { + let (v4, v6) = try allocator.allocate(id) + return Self.Interface( + reference: self.reference, + ipv4Address: v4, + ipv6Address: v6 + ) + } + /// Performs cleanup of an interface. /// - Parameter id: The container ID. public mutating func releaseInterface(_ id: String) throws { try allocator.release(id) } - private static func getSubnet(_ ref: vmnet_network_ref) throws -> CIDRv4 { + private static func getSubnetV4(_ ref: vmnet_network_ref) throws -> CIDRv4 { var subnet = in_addr() var mask = in_addr() vmnet_network_get_ipv4_subnet(ref, &subnet, &mask) @@ -190,18 +258,43 @@ public struct VmnetNetwork: Network { return try CIDRv4(lower: lower, upper: upper) } - private static func configureSubnet(_ config: vmnet_network_configuration_ref, subnet: CIDRv4) throws { - let gateway = subnet.gateway + private static func configureSubnetV4(_ config: vmnet_network_configuration_ref, subnetV4: CIDRv4) throws { + let gateway = subnetV4.gateway var ga = in_addr() inet_pton(AF_INET, gateway.description, &ga) - let mask = IPv4Address(subnet.prefix.prefixMask32) + let mask = IPv4Address(subnetV4.prefix.prefixMask32) var ma = in_addr() inet_pton(AF_INET, mask.description, &ma) guard vmnet_network_configuration_set_ipv4_subnet(config, &ga, &ma) == .VMNET_SUCCESS else { - throw ContainerizationError(.internalError, message: "failed to set subnet \(subnet) for network") + throw ContainerizationError(.internalError, message: "failed to set IPv4 subnet \(subnetV4) for network") + } + } + + private static func getPrefixV6(_ ref: vmnet_network_ref) -> CIDRv6? { + var p = in6_addr() + var len: UInt8 = 0 + vmnet_network_get_ipv6_prefix(ref, &p, &len) + + guard len > 0, let prefix = Prefix.ipv6(len) else { + return nil + } + + let bytes: [UInt8] = withUnsafeBytes(of: p) { Array($0) } + guard let address = try? IPv6Address(bytes) else { + return nil + } + return try? CIDRv6(address, prefix: prefix) + } + + private static func configurePrefixV6(_ config: vmnet_network_configuration_ref, prefixV6: CIDRv6) throws { + var p = in6_addr() + inet_pton(AF_INET6, prefixV6.lower.description, &p) + + guard vmnet_network_configuration_set_ipv6_prefix(config, &p, prefixV6.prefix.length) == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to set IPv6 prefix \(prefixV6) for network") } } } diff --git a/Sources/Containerization/Vsock+Linux.swift b/Sources/Containerization/Vsock+Linux.swift new file mode 100644 index 000000000..eacd874da --- /dev/null +++ b/Sources/Containerization/Vsock+Linux.swift @@ -0,0 +1,158 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ContainerizationError +import ContainerizationOS +import Foundation + +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + +// MARK: - Cloud Hypervisor hybrid vsock host-side helpers +// +// Cloud Hypervisor exposes its vsock device to the host as a Unix-domain +// socket pair, not the kernel AF_VSOCK (this avoids the host needing the +// vhost-vsock kernel module). +// +// - Host → guest dials use the "base" UDS (`VsockConfig.socket`) with a +// one-line `CONNECT \n` request, answered by `OK \n`. After +// that the connection is bridged transparently. +// - Guest → host dials are accepted on per-port UDS files at the +// conventional path `_` that the host pre-creates. +// +// Spec: `docs/vsock.md` in the cloud-hypervisor repository. + +/// Returns the conventional per-port UDS path for guest→host vsock connections, +/// derived by suffixing the base socket path with `_`. +func chVsockListenSocketPath(baseSocket: URL, port: UInt32) -> URL { + URL(fileURLWithPath: "\(baseSocket.path)_\(port)") +} + +/// Bind + listen a fresh AF_UNIX SOCK_STREAM at `path`, unlinking any stale +/// socket file at that path first. Returns the listening fd; ownership is +/// transferred to the caller. +/// +/// The socket file is created with mode `perms` (default `0o600`). The +/// per-VM workDir already restricts access via its own `0o700` mode, but +/// tightening the socket itself is cheap defense-in-depth — vminitd's gRPC +/// surface trusts whoever can `connect(2)` and exposes full container +/// control, so any local-user reach into these sockets is a privilege +/// escalation primitive. +func chVsockBindListener(at path: URL, perms: mode_t = 0o600) throws -> Int32 { + let unix = try UnixType(path: path.path, perms: perms, unlinkExisting: true) + let socket = try Socket(type: unix, closeOnDeinit: false) + do { + try socket.listen() + } catch { + try? socket.close() + throw error + } + return socket.fileDescriptor +} + +/// Dial guest port `port` over the cloud-hypervisor hybrid vsock at +/// `baseSocket`. Returns a `FileHandle` wrapping the connected fd; the +/// FileHandle does **not** close the fd on deinit — ownership of the fd +/// transfers to the caller (typically `Vminitd.init`, which hands it to +/// NIO via `withConnectedSocket`; NIO is then responsible for closing it +/// when the channel is torn down). Callers using the FileHandle directly +/// must close the underlying fd themselves. +func chVsockDial(baseSocket: URL, port: UInt32) async throws -> FileHandle { + try await Task.detached { + try chVsockDialSync(baseSocket: baseSocket, port: port) + }.value +} + +// MARK: - Internals + +private func chVsockDialSync(baseSocket: URL, port: UInt32) throws -> FileHandle { + let unix = try UnixType(path: baseSocket.path) + let socket = try Socket(type: unix, closeOnDeinit: false) + + do { + try socket.connect() + // Bound the bootstrap reply read so a hung cloud-hypervisor muxer + // can't pin this thread forever. CH replies within milliseconds in + // healthy operation; 30 s is well outside that and matches the + // CloudHypervisor REST client default. After bootstrap the fd is + // handed to NIO which puts it in non-blocking mode, where + // SO_RCVTIMEO has no effect — so leaving the timeout in place is + // harmless. + try socket.setTimeout(option: .receive, seconds: 30) + let request = "CONNECT \(port)\n" + _ = try socket.write(data: Data(request.utf8)) + let response = try readLine(fd: socket.fileDescriptor) + // Cloud Hypervisor responds with "OK \n" where + // is the local-side port the muxer allocated for this + // forwarded connection — NOT the peer port we asked for. So we just + // require the response to start with "OK " and parse a UInt32 after. + guard response.hasPrefix("OK "), + UInt32(response.dropFirst(3)) != nil + else { + throw ContainerizationError( + .invalidState, + message: "unexpected vsock CONNECT response: \(response.debugDescription)" + ) + } + return FileHandle(fileDescriptor: socket.fileDescriptor, closeOnDealloc: false) + } catch { + try? socket.close() + throw error + } +} + +/// Read CH's hybrid-vsock `CONNECT` reply line (`OK \n`) one +/// byte at a time. Reads from `fd` until a `\n` is seen or `maxLength` is +/// reached; the returned string excludes the terminating newline. We do +/// this by hand because the fd is still in blocking mode (NIO takes over +/// only after the bootstrap completes) and there's no Foundation / +/// NIO line reader that operates on a raw blocking POSIX fd. +private func readLine(fd: Int32, maxLength: Int = 256) throws -> String { + var bytes: [UInt8] = [] + bytes.reserveCapacity(maxLength) + while bytes.count < maxLength { + var byte: UInt8 = 0 + let n = withUnsafeMutablePointer(to: &byte) { ptr -> ssize_t in + read(fd, ptr, 1) + } + if n == 0 { + break + } + if n < 0 { + let savedErrno = errno + // SO_RCVTIMEO expiry surfaces as EAGAIN / EWOULDBLOCK on a + // blocking socket. Translate to a clear timeout error so callers + // don't have to inspect errno. + if savedErrno == EAGAIN || savedErrno == EWOULDBLOCK { + throw ContainerizationError( + .timeout, + message: "vsock CONNECT response not received within socket receive timeout" + ) + } + throw POSIXError(POSIXErrorCode(rawValue: savedErrno) ?? .EIO) + } + if byte == UInt8(ascii: "\n") { + break + } + bytes.append(byte) + } + return String(decoding: bytes, as: UTF8.self) +} +#endif diff --git a/Sources/Containerization/VsockListener.swift b/Sources/Containerization/VsockListener.swift index 7a7b36faa..0a20d81cf 100644 --- a/Sources/Containerization/VsockListener.swift +++ b/Sources/Containerization/VsockListener.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import Foundation +import Synchronization #if os(macOS) import Virtualization @@ -30,6 +31,7 @@ public final class VsockListener: NSObject, Sendable, AsyncSequence { private let connections: AsyncStream private let cont: AsyncStream.Continuation private let stopListening: @Sendable (_ port: UInt32) throws -> Void + private let finished: Mutex package init(port: UInt32, stopListen: @Sendable @escaping (_ port: UInt32) throws -> Void) { self.port = port @@ -37,13 +39,36 @@ public final class VsockListener: NSObject, Sendable, AsyncSequence { self.connections = stream self.cont = continuation self.stopListening = stopListen + self.finished = Mutex(false) } + /// Idempotent: calling more than once is a no-op. setupIO and the + /// caller-side defer can both call finish without double-closing the + /// listening fd (a double-close would target whatever fd was reallocated + /// in between, hanging the next operation that touched it). public func finish() throws { + let alreadyFinished = self.finished.withLock { state -> Bool in + if state { + return true + } + state = true + return false + } + if alreadyFinished { + return + } self.cont.finish() try self.stopListening(self.port) } + /// Push an accepted connection into the listener's stream. Used by + /// VMM-specific accept loops that don't go through a delegate (the + /// cloud-hypervisor backend on Linux). On macOS the VZ delegate hits + /// `cont.yield(_:)` directly via same-class access. + package func yield(_ handle: FileHandle) -> AsyncStream.Continuation.YieldResult { + cont.yield(handle) + } + public func makeAsyncIterator() -> AsyncStream.AsyncIterator { connections.makeAsyncIterator() } diff --git a/Sources/ContainerizationArchive/ArchiveReader.swift b/Sources/ContainerizationArchive/ArchiveReader.swift index 21d7dadeb..3c291d1a5 100644 --- a/Sources/ContainerizationArchive/ArchiveReader.swift +++ b/Sources/ContainerizationArchive/ArchiveReader.swift @@ -274,7 +274,7 @@ extension ArchiveReader { /// for an existing file at a path to be extracted. public func extractContents(to directory: URL) throws -> [String] { // Create the root directory with standard permissions - // and create a FileDescriptor for secure path traveral. + // and create a FileDescriptor for secure path traversal. let fm = FileManager.default let rootFilePath = FilePath(directory.path) try fm.createDirectory(atPath: directory.path, withIntermediateDirectories: true) @@ -398,7 +398,7 @@ extension ArchiveReader { } private func setFileAttributes(fd: Int32, entry: WriteEntry) { - fchmod(fd, entry.permissions) + fchmod(fd, entry.permissions & 0o777) if let owner = entry.owner, let group = entry.group { fchown(fd, owner, group) } diff --git a/Sources/ContainerizationEXT4/EXT4+FileTree.swift b/Sources/ContainerizationEXT4/EXT4+FileTree.swift index db69c9950..8d8069afe 100644 --- a/Sources/ContainerizationEXT4/EXT4+FileTree.swift +++ b/Sources/ContainerizationEXT4/EXT4+FileTree.swift @@ -15,6 +15,7 @@ //===----------------------------------------------------------------------===// import Foundation +import OrderedCollections import SystemPackage extension EXT4 { @@ -22,7 +23,11 @@ extension EXT4 { class FileTreeNode { let inode: InodeNumber let name: String - var children: [Ptr] = [] + // Children keyed by name for O(1) lookup, preserving insertion order. + private(set) var childrenByName: OrderedDictionary> = [:] + var children: OrderedDictionary>.Values { + childrenByName.values + } var blocks: (start: UInt32, end: UInt32)? var additionalBlocks: [(start: UInt32, end: UInt32)]? var link: InodeNumber? @@ -39,16 +44,17 @@ extension EXT4 { ) { self.inode = inode self.name = name - self.children = children self.blocks = blocks self.additionalBlocks = additionalBlocks self.link = link self.parent = parent + for child in children { + self.addChild(child) + } } deinit { - self.children.removeAll() - self.children = [] + self.childrenByName.removeAll() self.blocks = nil self.additionalBlocks = nil self.link = nil @@ -64,6 +70,14 @@ extension EXT4 { let path = components.reversed().joined(separator: "/") return FilePath(path).lexicallyNormalized() } + + func addChild(_ child: Ptr) { + childrenByName[child.pointee.name] = child + } + + func removeChild(named name: String) { + childrenByName.removeValue(forKey: name) + } } var root: Ptr @@ -82,18 +96,10 @@ extension EXT4 { return node } for component in components { - var found = false - for childPtr in node.pointee.children { - let child = childPtr.pointee - if child.name == component { - node = childPtr - found = true - break - } - } - guard found else { + guard let childPtr = node.pointee.childrenByName[component] else { return nil } + node = childPtr } return node } diff --git a/Sources/ContainerizationEXT4/EXT4+Formatter.swift b/Sources/ContainerizationEXT4/EXT4+Formatter.swift index 8cc44880e..708b9e1b0 100644 --- a/Sources/ContainerizationEXT4/EXT4+Formatter.swift +++ b/Sources/ContainerizationEXT4/EXT4+Formatter.swift @@ -166,6 +166,8 @@ extension EXT4 { if self.tree.lookup(path: link) != nil { try self.unlink(path: link) } + // create all predecessors recursively + try self.create(path: parentPath, mode: Inode.Mode(.S_IFDIR, 0o755), recursion: true) guard let parentTreeNodePtr = self.tree.lookup(path: parentPath) else { throw Error.notFound(parentPath) } @@ -184,7 +186,7 @@ extension EXT4 { blocks: nil, link: targetNode.inode )) - parentTreeNode.children.append(linkTreeNodePtr) + parentTreeNode.addChild(linkTreeNodePtr) parentTreeNodePtr.pointee = parentTreeNode } @@ -252,9 +254,7 @@ extension EXT4 { } } parentInodePtr.pointee = parentInode - parentNode.children.removeAll { childPtr in - childPtr.pointee.name == pathComponent - } + parentNode.removeChild(named: pathComponent) parentNodePtr.pointee = parentNode if let hardlink = pathNode.link { @@ -410,7 +410,7 @@ extension EXT4 { children: [], blocks: (startBlock, endBlock) )) - parentTreeNode.children.append(childTreeNodePtr) + parentTreeNode.addChild(childTreeNodePtr) parentTreeNodePtr.pointee = parentTreeNode } childInode.mode = mode @@ -936,7 +936,6 @@ extension EXT4 { if let config = journalConfig { compatFeatures |= CompatFeature.hasJournal.rawValue superblock.journalInum = EXT4.JournalInode - superblock.journalUUID = filesystemUUID superblock.journalBlocks = journalInodeBlockBackup() superblock.journalBackupType = 1 // s_jnl_backup_type: 1 = s_jnl_blocks[] holds a valid inode backup if let mode = config.defaultMode { diff --git a/Sources/ContainerizationEXT4/EXT4+Reader.swift b/Sources/ContainerizationEXT4/EXT4+Reader.swift index 640b16ff4..5b2fa90c4 100644 --- a/Sources/ContainerizationEXT4/EXT4+Reader.swift +++ b/Sources/ContainerizationEXT4/EXT4+Reader.swift @@ -102,7 +102,7 @@ extension EXT4 { itemTreeNode.blocks = blocks.first } let itemTreeNodePtr = Ptr(itemTreeNode) - root.children.append(itemTreeNodePtr) + root.addChild(itemTreeNodePtr) itemPtr.pointee = root let itemInode = try self.getInode(number: itemInodeNum) if itemInode.mode.isDir() { diff --git a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift index 2fd4109fc..762a7af4b 100644 --- a/Sources/ContainerizationEXT4/EXT4Reader+Export.swift +++ b/Sources/ContainerizationEXT4/EXT4Reader+Export.swift @@ -24,7 +24,7 @@ extension EXT4.EXT4Reader { format: .paxRestricted, filter: .none, options: [Options.xattrformat(.schily)]) let writer = try ArchiveWriter(configuration: config) try writer.open(file: archive.url) - var items = self.tree.root.pointee.children + var items = Array(self.tree.root.pointee.children) let hardlinkedInodes = Set(self.hardlinks.values) var hardlinkTargets: [EXT4.InodeNumber: FilePath] = [:] diff --git a/Sources/ContainerizationExtras/CIDRv4.swift b/Sources/ContainerizationExtras/CIDRv4.swift index c1fd86cf8..d7823b01a 100644 --- a/Sources/ContainerizationExtras/CIDRv4.swift +++ b/Sources/ContainerizationExtras/CIDRv4.swift @@ -112,3 +112,11 @@ extension CIDRv4: Codable { try container.encode(description) } } + +extension CIDRv4 { + /// The gateway address of the network. Conventionally the first usable + /// address in the subnet (`lower + 1`). + public var gateway: IPv4Address { + IPv4Address(self.lower.value + 1) + } +} diff --git a/Sources/ContainerizationExtras/NetworkConfiguration.swift b/Sources/ContainerizationExtras/NetworkConfiguration.swift new file mode 100644 index 000000000..a72a5f45f --- /dev/null +++ b/Sources/ContainerizationExtras/NetworkConfiguration.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +/// A network interface's addresses. +public struct InterfaceAddress: Sendable, Hashable { + public var ipv4Address: CIDRv4 + public var ipv6Address: CIDRv6? + + public init(ipv4Address: CIDRv4, ipv6Address: CIDRv6? = nil) { + self.ipv4Address = ipv4Address + self.ipv6Address = ipv6Address + } +} + +/// A link-scoped route — a destination directly reachable on an interface. +public struct LinkRoute: Sendable, Hashable { + public var ipv4Destination: IPv4Address? + public var ipv4Source: IPv4Address? + public var ipv6Destination: IPv6Address? + public var ipv6Source: IPv6Address? + + public init( + ipv4Destination: IPv4Address? = nil, + ipv4Source: IPv4Address? = nil, + ipv6Destination: IPv6Address? = nil, + ipv6Source: IPv6Address? = nil + ) { + self.ipv4Destination = ipv4Destination + self.ipv4Source = ipv4Source + self.ipv6Destination = ipv6Destination + self.ipv6Source = ipv6Source + } +} + +/// The default-route gateway for a network interface. +public struct DefaultRoute: Sendable, Hashable { + public var ipv4Gateway: IPv4Address? + public var ipv6Gateway: IPv6Address? + + public init(ipv4Gateway: IPv4Address? = nil, ipv6Gateway: IPv6Address? = nil) { + self.ipv4Gateway = ipv4Gateway + self.ipv6Gateway = ipv6Gateway + } +} diff --git a/Sources/ContainerizationNetlink/NetlinkSession.swift b/Sources/ContainerizationNetlink/NetlinkSession.swift index 19478f0bb..c6438ad13 100644 --- a/Sources/ContainerizationNetlink/NetlinkSession.swift +++ b/Sources/ContainerizationNetlink/NetlinkSession.swift @@ -118,6 +118,215 @@ public struct NetlinkSession { } } + /// Set link attributes (MAC and/or bridge master) on an existing interface. + /// Either argument may be omitted; if both are nil this is a no-op. + /// + /// Sends a single `RTM_NEWLINK` carrying any of: + /// - `IFLA_ADDRESS` — the new hardware address (6 bytes for an Ethernet MAC). + /// - `IFLA_MASTER` — the index of the bridge to enslave the link to. The + /// bridge is identified by name; the index is resolved internally. + /// + /// - Parameters: + /// - interface: The name of the interface to update. + /// - macAddress: If non-nil, the new MAC address. + /// - master: If non-nil, the name of a bridge to enslave the interface to. + public func linkSetAttributes( + interface: String, + macAddress: MACAddress? = nil, + master: String? = nil + ) throws { + if macAddress == nil && master == nil { + return + } + + let interfaceIndex = try getInterfaceIndex(interface) + + var masterIndex: Int32? = nil + if let master { + masterIndex = try getInterfaceIndex(master) + } + + // Build the attribute list. MAC is 6 raw bytes; master is a 4-byte + // integer holding the bridge's interface index. + let macAttr: RTAttribute? = + (macAddress != nil) + ? RTAttribute( + len: UInt16(RTAttribute.size + 6), + type: LinkAttributeType.IFLA_ADDRESS) + : nil + let masterAttr: RTAttribute? = + (masterIndex != nil) + ? RTAttribute( + len: UInt16(RTAttribute.size + MemoryLayout.size), + type: LinkAttributeType.IFLA_MASTER) + : nil + + let requestSize = + NetlinkMessageHeader.size + + InterfaceInfo.size + + (macAttr?.paddedLen ?? 0) + + (masterAttr?.paddedLen ?? 0) + + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let requestHeader = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWLINK, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK, + pid: socket.pid) + requestOffset = try requestHeader.appendBuffer(&requestBuffer, offset: requestOffset) + + // No flag changes — passing 0/0 means "do not modify IFF_* flags". + let requestInfo = InterfaceInfo( + family: UInt8(AddressFamily.AF_PACKET), + index: interfaceIndex, + flags: 0, + change: 0) + requestOffset = try requestInfo.appendBuffer(&requestBuffer, offset: requestOffset) + + if let macAttr, let macAddress { + requestOffset = try macAttr.appendBuffer(&requestBuffer, offset: requestOffset) + for byte in macAddress.bytes { + guard let next = requestBuffer.copyIn(as: UInt8.self, value: byte, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "IFLA_ADDRESS") + } + requestOffset = next + } + // Pad attribute payload to 4-byte boundary (NLA_ALIGN). + let payloadLen = 6 + let padded = ((payloadLen + 3) >> 2) << 2 + requestOffset += padded - payloadLen + } + + if let masterAttr, let masterIndex { + requestOffset = try masterAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard + let next = requestBuffer.copyIn(as: Int32.self, value: masterIndex, offset: requestOffset) + else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "IFLA_MASTER") + } + requestOffset = next + } + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWLINK) { InterfaceInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + + /// Create a Linux bridge link via `RTM_NEWLINK` carrying + /// `IFLA_LINKINFO/IFLA_INFO_KIND="bridge"`. + /// + /// Sends `NLM_F_CREATE | NLM_F_EXCL`, so the kernel returns `EEXIST` if a + /// link with the same name already exists. Callers wanting idempotent + /// creation should catch and inspect the thrown error. + public func linkAddBridge(name: String) throws { + let nameBytes = Array(name.utf8) + [0] + let ifnameAttr = RTAttribute( + len: UInt16(RTAttribute.size + nameBytes.count), + type: LinkAttributeType.IFLA_IFNAME) + + let kindBytes = Array("bridge".utf8) + [0] + let kindAttr = RTAttribute( + len: UInt16(RTAttribute.size + kindBytes.count), + type: LinkInfoAttributeType.IFLA_INFO_KIND) + // IFLA_LINKINFO is a nest containing IFLA_INFO_KIND. + let linkInfoAttr = RTAttribute( + len: UInt16(RTAttribute.size + kindAttr.paddedLen), + type: LinkAttributeType.IFLA_LINKINFO) + + let requestSize = + NetlinkMessageHeader.size + + InterfaceInfo.size + + ifnameAttr.paddedLen + + linkInfoAttr.paddedLen + + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWLINK, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK + | NetlinkFlags.NLM_F_CREATE | NetlinkFlags.NLM_F_EXCL, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let info = InterfaceInfo( + family: UInt8(AddressFamily.AF_UNSPEC), + index: 0, + flags: 0, + change: 0) + requestOffset = try info.appendBuffer(&requestBuffer, offset: requestOffset) + + // IFLA_IFNAME + requestOffset = try ifnameAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard let next = requestBuffer.copyIn(buffer: nameBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "IFLA_IFNAME") + } + // Pad NUL-terminated name to NLA 4-byte boundary. + requestOffset = next + (ifnameAttr.paddedLen - RTAttribute.size - nameBytes.count) + + // IFLA_LINKINFO -> IFLA_INFO_KIND + requestOffset = try linkInfoAttr.appendBuffer(&requestBuffer, offset: requestOffset) + requestOffset = try kindAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard let after = requestBuffer.copyIn(buffer: kindBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "IFLA_INFO_KIND") + } + requestOffset = after + (kindAttr.paddedLen - RTAttribute.size - kindBytes.count) + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWLINK) { InterfaceInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + + /// Remove a link by name via `RTM_DELLINK`. + /// + /// Throws on netlink error. Callers wanting idempotent removal should + /// catch and inspect the thrown error (e.g. `ENODEV` ⇒ already gone). + public func linkDel(name: String) throws { + let interfaceIndex = try getInterfaceIndex(name) + let requestSize = NetlinkMessageHeader.size + InterfaceInfo.size + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_DELLINK, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let info = InterfaceInfo( + family: UInt8(AddressFamily.AF_UNSPEC), + index: interfaceIndex, + flags: 0, + change: 0) + requestOffset = try info.appendBuffer(&requestBuffer, offset: requestOffset) + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_DELLINK) { InterfaceInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + /// Performs a link get command on an interface. /// Returns information about the interface. /// - Parameter interface: The name of the interface to query. @@ -253,7 +462,54 @@ public struct NetlinkSession { } try sendRequest(buffer: &requestBuffer) - let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWLINK) { AddressInfo() } + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWADDR) { AddressInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + + /// Adds an IPv6 address to an interface. + /// - Parameters: + /// - interface: The name of the interface. + /// - ipv6Address: The CIDRv6 address describing the interface IP and subnet prefix length. + public func addressAdd(interface: String, ipv6Address: CIDRv6) throws { + let interfaceIndex = try getInterfaceIndex(interface) + + let ipAddressBytes = ipv6Address.address.bytes + let addressAttrSize = RTAttribute.size + MemoryLayout.size * ipAddressBytes.count + let requestSize = NetlinkMessageHeader.size + AddressInfo.size + addressAttrSize + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWADDR, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK | NetlinkFlags.NLM_F_EXCL + | NetlinkFlags.NLM_F_CREATE, + seq: 0, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let requestInfo = AddressInfo( + family: UInt8(AddressFamily.AF_INET6), + prefixLength: ipv6Address.prefix.length, + flags: AddressFlags.IFA_F_PERMANENT | AddressFlags.IFA_F_NODAD, + scope: NetlinkScope.RT_SCOPE_UNIVERSE, + index: UInt32(interfaceIndex)) + requestOffset = try requestInfo.appendBuffer(&requestBuffer, offset: requestOffset) + + let ipAddressAttr = RTAttribute(len: UInt16(addressAttrSize), type: AddressAttributeType.IFA_ADDRESS) + requestOffset = try ipAddressAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard let requestOffset = requestBuffer.copyIn(buffer: ipAddressBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "IFA_ADDRESS") + } + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWADDR) { AddressInfo() } guard infos.count == 0 else { throw Error.unexpectedResultSet(count: infos.count, expected: 0) } @@ -339,7 +595,7 @@ public struct NetlinkSession { } try sendRequest(buffer: &requestBuffer) - let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWLINK) { AddressInfo() } + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWROUTE) { AddressInfo() } guard infos.count == 0 else { throw Error.unexpectedResultSet(count: infos.count, expected: 0) } @@ -415,7 +671,161 @@ public struct NetlinkSession { } try sendRequest(buffer: &requestBuffer) - let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWLINK) { AddressInfo() } + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWROUTE) { AddressInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + + /// Adds an IPv6 route to an interface. Used to install an on-link host + /// route (typically a /128) to a gateway that lives outside the interface's + /// subnet, so the kernel will accept the v6 default route. The chosen + /// `proto STATIC, scope LINK` matches what `iproute2` emits for explicit + /// `ip -6 route add /128 dev `. + /// - Parameters: + /// - interface: The name of the interface. + /// - dstIpv6Addr: The CIDRv6 address describing the destination network and prefix length. + /// - srcIpv6Addr: The source IPv6 address to route from. + public func routeAdd( + interface: String, + dstIpv6Addr: CIDRv6, + srcIpv6Addr: IPv6Address? + ) throws { + let interfaceIndex = try getInterfaceIndex(interface) + + let dstAddrBytes = dstIpv6Addr.address.bytes + let dstAddrAttrSize = RTAttribute.size + dstAddrBytes.count + let srcAddrAttrSize: Int + if let srcIpv6Addr { + let srcAddrBytes = srcIpv6Addr.bytes + srcAddrAttrSize = RTAttribute.size + srcAddrBytes.count + } else { + srcAddrAttrSize = 0 + } + let interfaceAttrSize = RTAttribute.size + MemoryLayout.size + let requestSize = + NetlinkMessageHeader.size + RouteInfo.size + dstAddrAttrSize + srcAddrAttrSize + interfaceAttrSize + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWROUTE, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK | NetlinkFlags.NLM_F_EXCL + | NetlinkFlags.NLM_F_CREATE, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let requestInfo = RouteInfo( + family: UInt8(AddressFamily.AF_INET6), + dstLen: dstIpv6Addr.prefix.length, + srcLen: 0, + tos: 0, + table: RouteTable.MAIN, + proto: RouteProtocol.STATIC, + scope: RouteScope.LINK, + type: RouteType.UNICAST, + flags: 0) + requestOffset = try requestInfo.appendBuffer(&requestBuffer, offset: requestOffset) + + let dstAddrAttr = RTAttribute(len: UInt16(dstAddrAttrSize), type: RouteAttributeType.DST) + requestOffset = try dstAddrAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard var requestOffset = requestBuffer.copyIn(buffer: dstAddrBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_DST") + } + + if let srcIpv6Addr { + let srcAddrBytes = srcIpv6Addr.bytes + let srcAddrAttr = RTAttribute(len: UInt16(srcAddrAttrSize), type: RouteAttributeType.PREFSRC) + requestOffset = try srcAddrAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard let newOffset = requestBuffer.copyIn(buffer: srcAddrBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_PREFSRC") + } + requestOffset = newOffset + } + + let interfaceAttr = RTAttribute(len: UInt16(interfaceAttrSize), type: RouteAttributeType.OIF) + requestOffset = try interfaceAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard + let requestOffset = requestBuffer.copyIn( + as: UInt32.self, + value: UInt32(interfaceIndex), + offset: requestOffset) + else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_OIF") + } + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWROUTE) { AddressInfo() } + guard infos.count == 0 else { + throw Error.unexpectedResultSet(count: infos.count, expected: 0) + } + } + + /// Adds a default IPv6 route to an interface. + /// - Parameters: + /// - interface: The name of the interface. + /// - ipv6Gateway: The gateway address. + public func routeAddDefault( + interface: String, + ipv6Gateway: IPv6Address + ) throws { + let gatewayBytes = ipv6Gateway.bytes + let gatewaySize = RTAttribute.size + gatewayBytes.count + + let interfaceAttrSize = RTAttribute.size + MemoryLayout.size + let interfaceIndex = try getInterfaceIndex(interface) + let requestSize = NetlinkMessageHeader.size + RouteInfo.size + gatewaySize + interfaceAttrSize + + var requestBuffer = [UInt8](repeating: 0, count: requestSize) + var requestOffset = 0 + + let header = NetlinkMessageHeader( + len: UInt32(requestBuffer.count), + type: NetlinkType.RTM_NEWROUTE, + flags: NetlinkFlags.NLM_F_REQUEST | NetlinkFlags.NLM_F_ACK | NetlinkFlags.NLM_F_EXCL + | NetlinkFlags.NLM_F_CREATE, + pid: socket.pid) + requestOffset = try header.appendBuffer(&requestBuffer, offset: requestOffset) + + let requestInfo = RouteInfo( + family: UInt8(AddressFamily.AF_INET6), + dstLen: 0, + srcLen: 0, + tos: 0, + table: RouteTable.MAIN, + proto: RouteProtocol.BOOT, + scope: RouteScope.UNIVERSE, + type: RouteType.UNICAST, + flags: 0) + requestOffset = try requestInfo.appendBuffer(&requestBuffer, offset: requestOffset) + + let dstAddrAttr = RTAttribute(len: UInt16(gatewaySize), type: RouteAttributeType.GATEWAY) + requestOffset = try dstAddrAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard var requestOffset = requestBuffer.copyIn(buffer: gatewayBytes, offset: requestOffset) else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_GATEWAY") + } + let interfaceAttr = RTAttribute(len: UInt16(interfaceAttrSize), type: RouteAttributeType.OIF) + requestOffset = try interfaceAttr.appendBuffer(&requestBuffer, offset: requestOffset) + guard + let requestOffset = requestBuffer.copyIn( + as: UInt32.self, + value: UInt32(interfaceIndex), + offset: requestOffset) + else { + throw BindError.sendMarshalFailure(type: "RTAttribute", field: "RTA_OIF") + } + + guard requestOffset == requestSize else { + throw Error.unexpectedOffset(offset: requestOffset, size: requestSize) + } + + try sendRequest(buffer: &requestBuffer) + let (infos, _) = try parseResponse(infoType: NetlinkType.RTM_NEWROUTE) { AddressInfo() } guard infos.count == 0 else { throw Error.unexpectedResultSet(count: infos.count, expected: 0) } diff --git a/Sources/ContainerizationNetlink/Types.swift b/Sources/ContainerizationNetlink/Types.swift index 2691fd1c3..81d62e2c9 100644 --- a/Sources/ContainerizationNetlink/Types.swift +++ b/Sources/ContainerizationNetlink/Types.swift @@ -85,10 +85,17 @@ struct LinkAttributeType { static let IFLA_BROADCAST: UInt16 = 2 static let IFLA_IFNAME: UInt16 = 3 static let IFLA_MTU: UInt16 = 4 + static let IFLA_MASTER: UInt16 = 10 + static let IFLA_LINKINFO: UInt16 = 18 static let IFLA_STATS64: UInt16 = 23 static let IFLA_EXT_MASK: UInt16 = 29 } +/// Nested attribute types inside `IFLA_LINKINFO`. +struct LinkInfoAttributeType { + static let IFLA_INFO_KIND: UInt16 = 1 +} + struct LinkAttributeMaskFilter { static let RTEXT_FILTER_VF: UInt32 = 1 << 0 static let RTEXT_FILTER_SKIP_STATS: UInt32 = 1 << 3 @@ -101,6 +108,11 @@ struct AddressAttributeType { static let IFA_LOCAL: UInt16 = 2 } +struct AddressFlags { + static let IFA_F_NODAD: UInt8 = 0x02 + static let IFA_F_PERMANENT: UInt8 = 0x80 +} + struct RouteTable { static let MAIN: UInt8 = 254 } diff --git a/Sources/ContainerizationOCI/Platform.swift b/Sources/ContainerizationOCI/Platform.swift index 3a54fbc9f..cd7a19d63 100644 --- a/Sources/ContainerizationOCI/Platform.swift +++ b/Sources/ContainerizationOCI/Platform.swift @@ -47,15 +47,27 @@ public struct Platform: Sendable, Equatable { return .init(arch: normalized.arch, os: "linux", variant: normalized.variant) } - /// The computed description, for example, `linux/arm64/v8`. + /// The computed description, for example, `linux/amd64` or `linux/arm/v7`. + /// + /// `arm64`'s only defined variant is `v8`, which `==` and `hash` already treat as + /// equivalent to a `nil` variant. The redundant `v8` is therefore omitted so that + /// two equal arm64 platforms (one with `variant == nil`, one with `"v8"`) describe + /// identically as `linux/arm64`, rather than drifting between `arm64` and + /// `arm64/v8`. public var description: String { let architecture = architecture - if let variant = variant { + if let variant, !Self.isRedundantVariant(variant, for: architecture) { return "\(os)/\(architecture)/\(variant)" } return "\(os)/\(architecture)" } + /// Whether `variant` is the canonical default for `architecture` and can be omitted + /// from the rendered description without losing information. + private static func isRedundantVariant(_ variant: String, for architecture: String) -> Bool { + architecture == "arm64" && variant == "v8" + } + /// The CPU architecture, for example, `amd64` or `arm64`. public var architecture: String { Self.normalizeArch(_rawArch).arch @@ -250,29 +262,33 @@ extension Platform: Hashable { /// `==` compares if **lhs** and **rhs** are the exact same platforms. public static func == (lhs: Platform, rhs: Platform) -> Bool { + guard lhs.os == rhs.os else { + return false + } + guard lhs.architecture == rhs.architecture else { + return false + } + // NOTE: // If the platform struct was created by setting the fields directly and not using (from: String) // then, there is a possibility that for arm64 architecture, the variant may be set to nil // In that case, the variant should be assumed to v8 - if lhs.architecture == "arm64" && rhs.architecture == "arm64" { - // The following checks effectively verify - // that one operand has nil value and other has "v8" - if lhs.variant == nil || rhs.variant == nil { - if lhs.variant == "v8" || rhs.variant == "v8" { - return true - } - } + if lhs.architecture == "arm64" { + return (lhs.variant ?? "v8") == (rhs.variant ?? "v8") } - let osEqual = lhs.os == rhs.os - let archEqual = lhs.architecture == rhs.architecture - let variantEqual = lhs.variant == rhs.variant - - return osEqual && archEqual && variantEqual + return lhs.variant == rhs.variant } public func hash(into hasher: inout Swift.Hasher) { - hasher.combine(description) + hasher.combine(os) + hasher.combine(architecture) + // arm64 with no variant is equivalent to arm64/v8 per the == implementation + if architecture == "arm64" { + hasher.combine(variant ?? "v8") + } else { + hasher.combine(variant) + } } } diff --git a/Sources/ContainerizationOCI/Spec+Redaction.swift b/Sources/ContainerizationOCI/Spec+Redaction.swift new file mode 100644 index 000000000..196bdb292 --- /dev/null +++ b/Sources/ContainerizationOCI/Spec+Redaction.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// Environment variables routinely carry secrets, so rendering a process or a +// hook as text must not expose their values. These conformances make the +// redacted form the *default* rendering rather than something a caller has to +// opt into: any `\(spec)` or `\(process)`, in this repo or downstream, is safe +// without the author knowing this file exists. +// +// Only the two types that own an `env` need conforming. Swift's reflection +// based description uses a nested value's own `description`, so `Spec` and +// `Hooks` inherit the redaction through the values they hold. +// +// This affects text rendering only. `Codable` is untouched, so an encoded spec +// still carries the real values, and the unredacted environment remains +// available to callers through `process.env`. + +extension Process: CustomStringConvertible { + public var description: String { + var copy = self + copy.env = redactingEnvironmentValues(copy.env) + return describeFields(of: copy) + } +} + +extension Hook: CustomStringConvertible { + public var description: String { + var copy = self + copy.env = redactingEnvironmentValues(copy.env) + return describeFields(of: copy) + } +} + +/// Replaces the value of every `NAME=value` entry with ``, keeping +/// the name, which is still useful for seeing *which* variables were set. +/// Entries without an `=` are kept as-is: they name a variable to inherit and +/// carry no value of their own. +private func redactingEnvironmentValues(_ env: [String]) -> [String] { + env.map { entry in + guard let separator = entry.firstIndex(of: "=") else { + return entry + } + return entry[.." + } +} + +/// Renders `TypeName(label: value, ...)`, the shape Swift's own description +/// produces. Going through a mirror rather than listing the fields by hand +/// keeps every field in the log line, and means a field added later shows up +/// without anyone remembering to edit this file. +private func describeFields(of value: T) -> String { + let fields = Mirror(reflecting: value).children.map { child in + "\(child.label ?? "_"): \(String(describing: child.value))" + } + return "\(T.self)(\(fields.joined(separator: ", ")))" +} diff --git a/Sources/Integration/ContainerTests.swift b/Sources/Integration/ContainerTests.swift index eda29b758..dd2a91656 100644 --- a/Sources/Integration/ContainerTests.swift +++ b/Sources/Integration/ContainerTests.swift @@ -648,6 +648,7 @@ extension IntegrationSuite { } } + #if os(macOS) func testNestedVirtualizationEnabled() async throws { let id = "test-nested-virt" @@ -850,6 +851,8 @@ extension IntegrationSuite { } } + #endif + func testContainerStatistics() async throws { let id = "test-container-statistics" @@ -1694,6 +1697,70 @@ extension IntegrationSuite { } } + func testDefaultMaskedAndReadonlyPaths() async throws { + let id = "test-masked-readonly-defaults" + + // A default container (default capabilities + default masked/readonly + // paths) must have the OCI standard set enforced by vmexec without any + // opt-in. Probe from inside the guest: + // 1. readonlyPaths: writing under /proc/sys fails with EROFS. EROFS + // (not EPERM) proves the read-only remount rather than a mere + // capability denial — the default (restricted) caps already lack + // CAP_SYS_ADMIN, so a writable /proc/sys would fail with EPERM. + // The write is wrapped in a brace group so the shell's redirection + // failure ("can't create ...: Read-only file system") is captured: + // a trailing `2>&1` on the command misses it, because the failing + // `>` redirection aborts before `2>&1` is applied. + // 2. maskedPaths: at least one default-masked path is mounted over + // (visible in /proc/self/mountinfo). Checked via mountinfo rather + // than reading the target, since some masked paths (e.g. + // /proc/kcore) require CAP_SYS_RAWIO to read and would appear empty + // even if masking were broken, yielding a false pass. + let probe = """ + set -u + rerr=$( { echo x > /proc/sys/kernel/hostname; } 2>&1 ) + case "$rerr" in + *"Read-only file system"*) ;; + *) echo "RO-FAIL: writing /proc/sys expected EROFS, got: ${rerr:-}"; exit 1 ;; + esac + masked=0 + for p in /proc/kcore /proc/keys /proc/scsi /proc/sched_debug /sys/firmware /sys/devices/virtual/powercap; do + grep -q " $p " /proc/self/mountinfo && masked=$((masked + 1)) + done + if [ "$masked" -eq 0 ]; then + echo "MASK-FAIL: no default masked path mounted" + cat /proc/self/mountinfo + exit 1 + fi + echo "MASKED-RO-OK masked=$masked" + """ + + let bs = try await bootstrap(id) + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + // Intentionally leave config.maskedPaths / readonlyPaths / + // process.capabilities untouched — the point is that the defaults + // are secure out of the box. + config.process.arguments = ["/bin/sh", "-c", probe] + config.process.stdout = buffer + config.bootLog = bs.bootLog + } + + try await container.create() + try await container.start() + + let status = try await container.wait() + try await container.stop() + + let output = String(data: buffer.data, encoding: .utf8) ?? "" + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "default masked/readonly enforcement failed (exit \(status.exitCode)): \(output)") + } + guard output.contains("MASKED-RO-OK") else { + throw IntegrationError.assert(msg: "expected MASKED-RO-OK sentinel, got: \(output)") + } + } + func testStat() async throws { let id = "test-stat" @@ -1730,7 +1797,7 @@ extension IntegrationSuite { try await assertExec(container, id: "create-fifo", cmd: "mkfifo /tmp/test-fifo") let vsock = try await container.dialVsock(port: 1024) - let vminitd = try Vminitd(connection: vsock, group: Self.eventLoop) + let vminitd = try await Vminitd(connection: vsock, group: Self.eventLoop) let root = URL(filePath: container.root) @@ -3188,6 +3255,7 @@ extension IntegrationSuite { } } + #if os(macOS) @available(macOS 26.0, *) func testInterfaceMTU() async throws { let id = "test-interface-mtu" @@ -3247,6 +3315,8 @@ extension IntegrationSuite { } } + #endif + func testSingleFileMount() async throws { let id = "test-single-file-mount" @@ -4215,6 +4285,231 @@ extension IntegrationSuite { } } + func testFrozenExt4Clone() async throws { + let id = "test-frozen-ext4-clone" + let bs = try await bootstrap(id) + + let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4") + try? FileManager.default.removeItem(at: diskImageURL) + + let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib()) + try filesystem.close() + + let cloneImageURL = Self.testDir.appending(component: "\(id)-data-clone.ext4") + try? FileManager.default.removeItem(at: cloneImageURL) + + let writerContainer = try LinuxContainer("\(id)-writer", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sleep", "1000"] + config.mounts.append( + Mount.block( + format: "ext4", + source: diskImageURL.absolutePath(), + destination: "/data" + )) + config.bootLog = bs.bootLog + } + + do { + try await writerContainer.create() + try await writerContainer.start() + + try await writerContainer.filesystemOperation(operation: .freeze, path: "/data") + + let writeExec = try await writerContainer.exec("write-hello") { config in + config.arguments = ["/bin/sh", "-c", "echo hello > /data/hello.txt"] + } + try await writeExec.start() + let writeStatus = try await writeExec.wait() + try await writeExec.delete() + guard writeStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "write exec failed with status \(writeStatus)") + } + + try FileManager.default.copyItem(at: diskImageURL, to: cloneImageURL) + + try await writerContainer.filesystemOperation(operation: .thaw, path: "/data") + + try await writerContainer.kill(.kill) + _ = try await writerContainer.wait() + try await writerContainer.stop() + } catch { + try? await writerContainer.filesystemOperation(operation: .thaw, path: "/data") + try? await writerContainer.stop() + throw error + } + + let verifyContainer = try LinuxContainer("\(id)-reader", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.mounts.append( + Mount.block( + format: "ext4", + source: cloneImageURL.absolutePath(), + destination: "/data" + )) + config.process.arguments = ["/bin/sleep", "1000"] + config.bootLog = bs.bootLog + } + + do { + try await verifyContainer.create() + try await verifyContainer.start() + + let mountBuffer = BufferWriter() + let mountExec = try await verifyContainer.exec("verify-mount") { config in + config.arguments = ["/bin/sh", "-c", "grep ' /data ' /proc/mounts"] + config.stdout = mountBuffer + } + try await mountExec.start() + var status = try await mountExec.wait() + try await mountExec.delete() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "failed to verify /data mount, status \(status)") + } + + let mountOutput = String(decoding: mountBuffer.data, as: UTF8.self) + guard mountOutput.contains(" /data ") && mountOutput.contains(" ext4 ") else { + throw IntegrationError.assert(msg: "expected ext4 mount at /data, got: \(mountOutput)") + } + + let lsBuffer = BufferWriter() + let lsExec = try await verifyContainer.exec("verify-no-hello") { config in + config.arguments = ["ls", "-1", "/data"] + config.stdout = lsBuffer + } + try await lsExec.start() + status = try await lsExec.wait() + try await lsExec.delete() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ls /data failed with status \(status)") + } + + let lsOutput = String(decoding: lsBuffer.data, as: UTF8.self) + let listedFiles = Set(lsOutput.split(whereSeparator: \.isNewline).map(String.init)) + guard !listedFiles.contains("hello.txt") else { + throw IntegrationError.assert(msg: "expected cloned /data to not contain hello.txt, got: \(lsOutput)") + } + + try await verifyContainer.kill(.kill) + _ = try await verifyContainer.wait() + try await verifyContainer.stop() + } catch { + try? await verifyContainer.stop() + throw error + } + } + + func testTrimExt4Clone() async throws { + let id = "test-trim-ext4-clone" + let bs = try await bootstrap(id) + + let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4") + try? FileManager.default.removeItem(at: diskImageURL) + + let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib()) + try filesystem.close() + + let cloneImageURL = Self.testDir.appending(component: "\(id)-data-clone.ext4") + try? FileManager.default.removeItem(at: cloneImageURL) + + let writerContainer = try LinuxContainer("\(id)-writer", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["/bin/sleep", "1000"] + config.mounts.append( + Mount.block( + format: "ext4", + source: diskImageURL.absolutePath(), + destination: "/data" + )) + config.bootLog = bs.bootLog + } + + do { + try await writerContainer.create() + try await writerContainer.start() + + let writeExec = try await writerContainer.exec("write-temp") { config in + config.arguments = [ + "/bin/sh", + "-c", + "dd if=/dev/zero of=/data/trim.dat bs=1M count=8 status=none && sync && rm /data/trim.dat && sync", + ] + } + try await writeExec.start() + let writeStatus = try await writeExec.wait() + try await writeExec.delete() + guard writeStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "trim setup exec failed with status \(writeStatus)") + } + + try await writerContainer.filesystemOperation(operation: .trim, path: "/data") + + try FileManager.default.copyItem(at: diskImageURL, to: cloneImageURL) + + try await writerContainer.kill(.kill) + _ = try await writerContainer.wait() + try await writerContainer.stop() + } catch { + try? await writerContainer.stop() + throw error + } + + let verifyContainer = try LinuxContainer("\(id)-reader", rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.mounts.append( + Mount.block( + format: "ext4", + source: cloneImageURL.absolutePath(), + destination: "/data" + )) + config.process.arguments = ["/bin/sleep", "1000"] + config.bootLog = bs.bootLog + } + + do { + try await verifyContainer.create() + try await verifyContainer.start() + + let mountBuffer = BufferWriter() + let mountExec = try await verifyContainer.exec("verify-mount") { config in + config.arguments = ["/bin/sh", "-c", "grep ' /data ' /proc/mounts"] + config.stdout = mountBuffer + } + try await mountExec.start() + var status = try await mountExec.wait() + try await mountExec.delete() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "failed to verify /data mount, status \(status)") + } + + let mountOutput = String(decoding: mountBuffer.data, as: UTF8.self) + guard mountOutput.contains(" /data ") && mountOutput.contains(" ext4 ") else { + throw IntegrationError.assert(msg: "expected ext4 mount at /data, got: \(mountOutput)") + } + + let lsBuffer = BufferWriter() + let lsExec = try await verifyContainer.exec("verify-no-hello") { config in + config.arguments = ["ls", "-1", "/data"] + config.stdout = lsBuffer + } + try await lsExec.start() + status = try await lsExec.wait() + try await lsExec.delete() + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ls /data failed with status \(status)") + } + + let lsOutput = String(decoding: lsBuffer.data, as: UTF8.self) + let listedFiles = Set(lsOutput.split(whereSeparator: \.isNewline).map(String.init)) + guard !listedFiles.contains("trim.dat") else { + throw IntegrationError.assert(msg: "expected cloned /data to not contain trim.dat, got: \(lsOutput)") + } + + try await verifyContainer.kill(.kill) + _ = try await verifyContainer.wait() + try await verifyContainer.stop() + } catch { + try? await verifyContainer.stop() + throw error + } + } + func testUseInitBasic() async throws { let id = "test-use-init-basic" @@ -4441,6 +4736,7 @@ extension IntegrationSuite { } } + #if os(macOS) @available(macOS 26.0, *) func testNetworkingDisabled() async throws { let id = "test-networking-disabled" @@ -4549,6 +4845,449 @@ extension IntegrationSuite { } } + @available(macOS 26.0, *) + func testNetworkingEnabledIPv6() async throws { + let id = "test-networking-enabled-ipv6" + let bs = try await bootstrap(id) + + let network = try VmnetNetwork() + var manager = try ContainerManager(vmm: bs.vmm, network: network) + defer { + try? manager.delete(id) + } + + let buffer = BufferWriter() + let container = try await manager.create( + id, + image: bs.image, + rootfs: bs.rootfs + ) { config in + config.process.arguments = ["ip", "-6", "addr", "show", "eth0", "scope", "global"] + config.process.stdout = buffer + 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: "ip -6 addr show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("inet6 fd") else { + throw IntegrationError.assert( + msg: "expected a global-scope IPv6 address on eth0, got: \(output)") + } + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6AddressAdd() async throws { + let id = "test-ipv6-address" + let bs = try await bootstrap(id) + + // Pin the v6 prefix so the allocator's first allocation yields fd00::2. + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Check that the IPv6 address was assigned to eth0. + let exec = try await container.exec("check-ipv6") { config in + config.arguments = ["ip", "-6", "addr", "show", "eth0"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 addr show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("fd00::2") else { + throw IntegrationError.assert( + msg: "expected fd00::2 in output, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6DefaultRoute() async throws { + let id = "test-ipv6-default-route" + let bs = try await bootstrap(id) + + // Pin the network's v6 prefix so the gateway is deterministically fd00::1 + // and the allocator's first allocation yields fd00::2. + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // Inspect IPv6 routes inside the container. + let exec = try await container.exec("check-v6-route") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + // The default v6 route must point at the gateway we configured, on eth0. + guard output.contains("default via fd00::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd00::1 dev eth0' in v6 routes, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6GatewayOutsideSubnet() async throws { + let id = "test-ipv6-gateway-outside-subnet" + let bs = try await bootstrap(id) + + // Address in fd00::/120, gateway in fd01::/120 — subnets don't overlap, so the + // LinuxContainer wiring must add a /128 link route to the gateway before the + // default route. The two prefixes are independent so we drive this directly + // via NATInterface rather than the VmnetNetwork allocator (which always + // derives the gateway from the network's own prefix). + let interface = NATInterface( + ipv4Address: try CIDRv4("192.0.2.2/24"), + ipv4Gateway: try IPv4Address("192.0.2.1"), + ipv6Address: try CIDRv6("fd00::2/120"), + ipv6Gateway: try IPv6Address("fd01::1")) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let exec = try await container.exec("check-v6-routes") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + // Both the link-scoped route to the gateway AND the default via that gateway + // must be present. Without the link route, the kernel would refuse the default. + // Match the link route on a line that starts with the gateway address (no "via") + // so it can't be satisfied by a substring of the default-via line. + let lines = output.split(separator: "\n").map(String.init) + let hasLinkRoute = lines.contains { $0.hasPrefix("fd01::1 ") && $0.contains("dev eth0") && !$0.contains("via") } + guard hasLinkRoute else { + throw IntegrationError.assert( + msg: "expected an on-link route 'fd01::1 ... dev eth0' (no 'via') in v6 routes, got: \(output)") + } + guard output.contains("default via fd01::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd01::1 dev eth0' in v6 routes, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6OnlyDefaultRoute() async throws { + let id = "test-ipv6-only-default-route" + let bs = try await bootstrap(id) + + // Construct a NATInterface with a nil IPv4 gateway and a v6 gateway, so + // LinuxContainer takes the no-v4-gateway branch in setupInterface. The v4 + // address comes from TEST-NET-1; nothing in the test traffics over v4. + let interface = NATInterface( + ipv4Address: try CIDRv4("192.0.2.2/24"), + ipv4Gateway: nil, + ipv6Address: try CIDRv6("fd00::2/64"), + ipv6Gateway: try IPv6Address("fd00::1")) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let exec = try await container.exec("check-v6-route") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("default via fd00::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd00::1 dev eth0' in v6 routes when ipv4Gateway is nil, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6OnlyGatewayOutsideSubnet() async throws { + let id = "test-ipv6-only-gateway-outside-subnet" + let bs = try await bootstrap(id) + + // No v4 gateway AND v6 gateway is outside the v6 subnet. Exercises + // setupInterface's "no v4 gateway, but v6 link route required before + // v6 default route" branch — the exact bug the helper extraction fixed. + let interface = NATInterface( + ipv4Address: try CIDRv4("192.0.2.2/24"), + ipv4Gateway: nil, + ipv6Address: try CIDRv6("fd00::2/120"), + ipv6Gateway: try IPv6Address("fd01::1")) + + let buffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let exec = try await container.exec("check-v6-routes") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + // Both the on-link route to the gateway AND the default via it must be present. + // Without the link route the kernel rejects the default — that was the bug. + let lines = output.split(separator: "\n").map(String.init) + let hasLinkRoute = lines.contains { $0.hasPrefix("fd01::1 ") && $0.contains("dev eth0") && !$0.contains("via") } + guard hasLinkRoute else { + throw IntegrationError.assert( + msg: "expected an on-link route 'fd01::1 ... dev eth0' (no 'via') in v6 routes, got: \(output)") + } + guard output.contains("default via fd01::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd01::1 dev eth0' in v6 routes, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + @available(macOS 26.0, *) + func testIPv6DualStack() async throws { + let id = "test-ipv6-dual-stack" + let bs = try await bootstrap(id) + + // Pin the network's v6 prefix so the gateway is deterministically fd00::1 + // and the allocator's first allocation yields fd00::2. + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + // Capture the v4 address vmnet allocated so we can assert it ends up on eth0. + let expectedV4 = interface.ipv4Address.address.description + + let addrBuffer = BufferWriter() + let routeBuffer = BufferWriter() + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.process.arguments = ["sleep", "100"] + config.interfaces = [interface] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + // `ip addr show` (no family flag) lists both v4 and v6. + let addrExec = try await container.exec("check-dual-stack-addr") { config in + config.arguments = ["ip", "addr", "show", "eth0"] + config.stdout = addrBuffer + } + try await addrExec.start() + let addrStatus = try await addrExec.wait() + try await addrExec.delete() + + guard addrStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip addr show failed with status \(addrStatus)") + } + + guard let addrOutput = String(data: addrBuffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert addr output to UTF8") + } + + guard addrOutput.contains(expectedV4) else { + throw IntegrationError.assert( + msg: "expected v4 address \(expectedV4) on eth0, got: \(addrOutput)") + } + guard addrOutput.contains("fd00::2") else { + throw IntegrationError.assert( + msg: "expected v6 address fd00::2 on eth0, got: \(addrOutput)") + } + + // The dual-stack default routes must both be installed. + let routeExec = try await container.exec("check-dual-stack-route") { config in + config.arguments = ["ip", "-6", "route", "show"] + config.stdout = routeBuffer + } + try await routeExec.start() + let routeStatus = try await routeExec.wait() + try await routeExec.delete() + + guard routeStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 route show failed with status \(routeStatus)") + } + + guard let routeOutput = String(data: routeBuffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert route output to UTF8") + } + + guard routeOutput.contains("default via fd00::1 dev eth0") else { + throw IntegrationError.assert( + msg: "expected 'default via fd00::1 dev eth0' in v6 routes, got: \(routeOutput)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + + #endif + func testSysctl() async throws { let id = "test-container-sysctl" @@ -4626,6 +5365,87 @@ extension IntegrationSuite { } } + func testExecJoinsInitNamespaces() async throws { + let id = "test-exec-joins-init-namespaces" + + // An exec must land in exactly the namespaces the container's init + // process is in. The namespace identity check (`/proc/self/ns/*` vs + // `/proc/1/ns/*`, PID 1 being the container init as seen from inside + // its own PID namespace) is the real invariant: it catches any + // namespace the exec path forgets, not just the one that regressed. + // + // `kernel.shm_rmid_forced` is asserted alongside it because it is what + // consumers actually observe. IPC-namespaced sysctls are resolved + // against the *reading* process's IPC namespace, so an exec left in the + // guest's root IPC namespace reads the guest default (0) rather than + // the value applied to the container — the shape of the CRI conformance + // failure "should support safe sysctls", which reads such a sysctl back + // over ExecSync. + // + // `net` is expected to match too: LinuxContainer declares no network + // namespace, so both sides sit in the guest root netns today, and + // asserting it guards the exec path if that ever changes. + let probe = """ + exec 2>&1 + set -u + fail=0 + for ns in ipc uts mnt pid cgroup net; do + mine=$(readlink /proc/self/ns/$ns) + init=$(readlink /proc/1/ns/$ns) + if [ "$mine" != "$init" ]; then + echo "NS-FAIL: $ns exec=$mine init=$init" + fail=1 + fi + done + shm=$(cat /proc/sys/kernel/shm_rmid_forced) + if [ "$shm" != "1" ]; then + echo "SYSCTL-FAIL: kernel.shm_rmid_forced=$shm expected 1" + fail=1 + fi + [ "$fail" -eq 0 ] || exit 1 + echo "NS-OK" + """ + + let bs = try await bootstrap(id) + let container = try LinuxContainer(id, rootfs: bs.rootfs, vmm: bs.vmm) { config in + config.sysctl = [ + "kernel.shm_rmid_forced": "1" + ] + config.process.arguments = ["/bin/sleep", "100"] + config.bootLog = bs.bootLog + } + + do { + try await container.create() + try await container.start() + + let buffer = BufferWriter() + let exec = try await container.exec("ns-probe") { config in + config.arguments = ["/bin/sh", "-c", probe] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + let output = String(data: buffer.data, encoding: .utf8) ?? "" + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "exec namespace probe failed (exit \(status.exitCode)): \(output)") + } + guard output.contains("NS-OK") else { + throw IntegrationError.assert(msg: "expected NS-OK sentinel, got: \(output)") + } + + try await container.kill(.kill) + try await container.wait() + try await container.stop() + } catch { + try? await container.stop() + throw error + } + } + func testNoNewPrivileges() async throws { let id = "test-no-new-privileges" diff --git a/Sources/Integration/NBDServer.swift b/Sources/Integration/NBDServer.swift index aed49ffd1..982c8752f 100644 --- a/Sources/Integration/NBDServer.swift +++ b/Sources/Integration/NBDServer.swift @@ -20,6 +20,7 @@ import Logging import NIOCore import NIOPosix +#if os(macOS) /// A minimal NBD server for integration testing. /// /// Serves a file-backed block device using the NBD newstyle handshake protocol. @@ -379,3 +380,4 @@ private final class NBDConnectionHandler: ChannelInboundHandler { buf.writeInteger(cookie) } } +#endif diff --git a/Sources/Integration/PodTests.swift b/Sources/Integration/PodTests.swift index 42b43df65..ae1caec86 100644 --- a/Sources/Integration/PodTests.swift +++ b/Sources/Integration/PodTests.swift @@ -16,11 +16,15 @@ import ArgumentParser import Containerization +import ContainerizationArchive +import ContainerizationEXT4 import ContainerizationError +import ContainerizationExtras import ContainerizationOCI import ContainerizationOS import Foundation import Logging +import SystemPackage extension IntegrationSuite { /// Clone a rootfs mount to a new location for use by a container in a pod @@ -2087,4 +2091,276 @@ extension IntegrationSuite { } } } + + #if os(macOS) + @available(macOS 26.0, *) + func testPodIPv6AddressAdd() async throws { + let id = "test-pod-ipv6-address" + let bs = try await bootstrap(id) + + var network = try VmnetNetwork(prefixV6: try CIDRv6("fd00::/64")) + defer { + try? network.releaseInterface(id) + } + + guard let interface = try network.createInterface(id) else { + throw IntegrationError.assert(msg: "failed to create network interface") + } + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + config.interfaces = [interface] + } + + try await pod.addContainer("container1", rootfs: bs.rootfs) { config in + config.process.arguments = ["/bin/sleep", "100"] + } + + try await pod.create() + try await pod.startContainer("container1") + + let buffer = BufferWriter() + let exec = try await pod.execInContainer("container1", processID: "check-v6") { config in + config.arguments = ["ip", "-6", "addr", "show", "eth0"] + config.stdout = buffer + } + + try await exec.start() + let status = try await exec.wait() + try await exec.delete() + + try await pod.killContainer("container1", signal: .kill) + try await pod.waitContainer("container1") + try await pod.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "ip -6 addr show failed with status \(status)") + } + + guard let output = String(data: buffer.data, encoding: .utf8) else { + throw IntegrationError.assert(msg: "failed to convert output to UTF8") + } + + guard output.contains("fd00::2") else { + throw IntegrationError.assert( + msg: "expected fd00::2 on eth0 inside pod container, got: \(output)") + } + } + #endif + + func testPodFilesystemOperation() async throws { + let id = "test-pod-filesystem-operation" + + let bs = try await bootstrap(id) + + let diskImageURL = Self.testDir.appending(component: "\(id)-data.ext4") + try? FileManager.default.removeItem(at: diskImageURL) + let filesystem = try EXT4.Formatter(FilePath(diskImageURL.absolutePath()), minDiskSize: 64.mib()) + try filesystem.close() + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + } + + try await pod.addContainer("container1", rootfs: bs.rootfs) { config in + config.process.arguments = ["/bin/sleep", "1000"] + config.mounts.append( + Mount.block( + format: "ext4", + source: diskImageURL.absolutePath(), + destination: "/data" + )) + } + + do { + try await pod.create() + try await pod.startContainer("container1") + + try await pod.filesystemOperation("container1", operation: .freeze, path: "/data") + + let writeExec = try await pod.execInContainer("container1", processID: "write-hello") { config in + config.arguments = ["/bin/sh", "-c", "echo hello > /data/hello.txt"] + } + try await writeExec.start() + let writeStatus = try await writeExec.wait() + try await writeExec.delete() + guard writeStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "write exec failed with status \(writeStatus)") + } + try await pod.filesystemOperation("container1", operation: .thaw, path: "/data") + try await pod.filesystemOperation("container1", operation: .trim, path: "/data") + + let readBuffer = BufferWriter() + let readExec = try await pod.execInContainer("container1", processID: "read-hello") { config in + config.arguments = ["/bin/cat", "/data/hello.txt"] + config.stdout = readBuffer + } + try await readExec.start() + let readStatus = try await readExec.wait() + try await readExec.delete() + guard readStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "read exec failed with status \(readStatus)") + } + + let readOutput = String(decoding: readBuffer.data, as: UTF8.self) + guard readOutput == "hello\n" else { + throw IntegrationError.assert( + msg: "expected 'hello\\n' in /data/hello.txt, got: '\(readOutput)'" + ) + } + + try await pod.killContainer("container1", signal: .kill) + _ = try await pod.waitContainer("container1") + try await pod.stop() + } catch { + try? await pod.filesystemOperation("container1", operation: .thaw, path: "/data") + try? await pod.stop() + throw error + } + } + + #if os(Linux) + /// Unpack an image's layers into a host directory to use as a virtiofs + /// (directory-share) rootfs. Assumes a single-layer image (the alpine + /// image used by the suite) so no OCI whiteout processing is required. + /// + /// The extracted dir lives under `Self.testDir`; do NOT `defer`-remove it + /// here — virtiofsd shares it for the whole test. It is swept by + /// `bootstrap`'s `maxConcurrency == 1` reaper on the next test and by the + /// suite-end `removeItem(at: Self.testDir)`. + private func unpackRootfsDirectory(_ image: Containerization.Image, testID: String) async throws -> Containerization.Mount { + let dir = Self.testDir.appending(component: "\(testID)-rootfs-dir") + try? FileManager.default.removeItem(at: dir) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let platform = Platform(arch: "arm64", os: "linux", variant: "v8") + let manifest = try await image.manifest(for: platform) + for layer in manifest.layers { + let content = try await image.getContent(digest: layer.digest) + let filter: ContainerizationArchive.Filter + switch layer.mediaType { + case MediaTypes.imageLayer, MediaTypes.dockerImageLayer: + filter = .none + case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip: + filter = .gzip + case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd: + filter = .zstd + default: + throw IntegrationError.assert(msg: "unsupported layer media type \(layer.mediaType)") + } + let reader = try ArchiveReader(format: .paxRestricted, filter: filter, file: content.path) + _ = try reader.extractContents(to: dir) + } + + return .share(source: dir.absolutePath(), destination: "/") + } + + /// Hotplug a container with a virtiofs (directory-share) rootfs into a + /// running pod VM, plus an additional virtiofs file-mount. CH-only: VZ has + /// no runtime hotplug. + func testPodHotplugVirtiofsRootfs() async throws { + let id = "test-pod-hotplug-virtiofs-rootfs" + let bs = try await bootstrap(id) + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + } + + // Boot-time seed container (block rootfs) so the target container is + // added strictly on the post-create (hotplug) path. + try await pod.addContainer("seed", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "seed")) { config in + config.process.arguments = ["/bin/sleep", "infinity"] + } + + try await pod.create() + + // Also hotplug a virtiofs file-mount onto the container. This exercises + // the /run/virtiofs holding-dir path for an additional virtiofs share on + // a container whose rootfs is itself virtiofs — the combination the + // rootfs-agnostic newVirtiofsTags derivation must handle. cat'ing the + // mounted file also confirms the virtiofs rootfs itself is usable. + let mountContent = "hello from hotplugged virtiofs file mount" + let hostFile = FileManager.default.uniqueTemporaryDirectory(create: true) + .appendingPathComponent("hotplug-mount.txt") + try mountContent.write(to: hostFile, atomically: true, encoding: .utf8) + + let virtiofsRootfs = try await unpackRootfsDirectory(bs.image, testID: id) + let buffer = BufferWriter() + try await pod.addContainer("hot", rootfs: virtiofsRootfs) { config in + config.process.arguments = ["/bin/cat", "/etc/hotplug-mount.txt"] + config.mounts.append(.share(source: hostFile.path, destination: "/etc/hotplug-mount.txt")) + config.process.stdout = buffer + } + + do { + try await pod.startContainer("hot") + let status = try await pod.waitContainer("hot") + + try await pod.stopContainer("hot") + try await pod.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "hot container status \(status) != 0") + } + guard String(data: buffer.data, encoding: .utf8) == mountContent else { + throw IntegrationError.assert( + msg: "expected '\(mountContent)', got '\(String(data: buffer.data, encoding: .utf8) ?? "nil")'") + } + } catch { + try? await pod.stop() + throw error + } + } + + /// Hotplug a container with a block rootfs into a running pod VM. Guards + /// the existing block hotplug path against the registry-consolidation + /// change. CH-only. + func testPodHotplugBlockRootfs() async throws { + let id = "test-pod-hotplug-block-rootfs" + let bs = try await bootstrap(id) + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 4 + config.memoryInBytes = 1024.mib() + config.bootLog = bs.bootLog + } + + try await pod.addContainer("seed", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "seed")) { config in + config.process.arguments = ["/bin/sleep", "infinity"] + } + + try await pod.create() + + let buffer = BufferWriter() + try await pod.addContainer("hot", rootfs: try cloneRootfs(bs.rootfs, testID: id, containerID: "hot")) { config in + config.process.arguments = ["/bin/echo", "hello from block rootfs"] + config.process.stdout = buffer + } + + do { + try await pod.startContainer("hot") + let status = try await pod.waitContainer("hot") + + try await pod.stopContainer("hot") + try await pod.stop() + + guard status.exitCode == 0 else { + throw IntegrationError.assert(msg: "hot container status \(status) != 0") + } + guard String(data: buffer.data, encoding: .utf8) == "hello from block rootfs\n" else { + throw IntegrationError.assert( + msg: "expected 'hello from block rootfs', got '\(String(data: buffer.data, encoding: .utf8) ?? "nil")'") + } + } catch { + try? await pod.stop() + throw error + } + } + #endif } diff --git a/Sources/Integration/NBDTests.swift b/Sources/Integration/PodVolumeTests.swift similarity index 71% rename from Sources/Integration/NBDTests.swift rename to Sources/Integration/PodVolumeTests.swift index 22783ade2..d799edc42 100644 --- a/Sources/Integration/NBDTests.swift +++ b/Sources/Integration/PodVolumeTests.swift @@ -23,8 +23,9 @@ import Foundation import Logging import SystemPackage +#if os(macOS) extension IntegrationSuite { - private func cloneRootfsForNBD(_ rootfs: Containerization.Mount, testID: String, containerID: String) throws -> Containerization.Mount { + private func cloneRootfsForContainer(_ rootfs: Containerization.Mount, testID: String, containerID: String) throws -> Containerization.Mount { let clonePath = Self.testDir.appending(component: "\(testID)-\(containerID).ext4").absolutePath() try? FileManager.default.removeItem(atPath: clonePath) return try rootfs.clone(to: clonePath) @@ -295,8 +296,8 @@ extension IntegrationSuite { let (server, diskURL) = try createNBDServer(testID: id, name: "shared") defer { server.stop() } - let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "writer") - let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "reader") + let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer") + let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader") let pod = try LinuxPod(id, vmm: bs.vmm) { config in config.cpus = 4 @@ -511,8 +512,8 @@ extension IntegrationSuite { let (server, _) = try createNBDServer(testID: id, name: "persistent") defer { server.stop() } - let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "writer") - let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "reader") + let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer") + let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader") let pod = try LinuxPod(id, vmm: bs.vmm) { config in config.cpus = 4 @@ -578,8 +579,8 @@ extension IntegrationSuite { let (server, _) = try createNBDServer(testID: id, name: "shared") defer { server.stop() } - let rootfs1 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "c1") - let rootfs2 = try cloneRootfsForNBD(bs.rootfs, testID: id, containerID: "c2") + let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c1") + let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "c2") let pod = try LinuxPod(id, vmm: bs.vmm) { config in config.cpus = 4 @@ -726,4 +727,223 @@ extension IntegrationSuite { } } } + + /// Attach an empty EXT4 disk-image file as a pod volume and have + /// multiple containers read from and write to the shared mount. + func testPodSharedDiskImageVolume() async throws { + let id = "test-pod-shared-disk-image-volume" + let bs = try await bootstrap(id) + + // Create an empty EXT4 disk image to back the shared volume. + let diskURL = try createEXT4DiskImage(testID: id, name: "shared") + + let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer") + let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "appender") + let rootfs3 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader") + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 1 + config.memoryInBytes = 512.mib() + config.bootLog = bs.bootLog + config.volumes = [ + .init( + name: "shared-data", + source: .diskImage(path: diskURL), + format: "ext4" + ) + ] + } + + // Container 1: writes a file to the shared volume and verifies mount type. + let writerBuffer = BufferWriter() + try await pod.addContainer("writer", rootfs: rootfs1) { config in + config.process.arguments = [ + "/bin/sh", "-c", + "echo shared-content > /data/shared.txt && grep /data /proc/mounts", + ] + config.process.stdout = writerBuffer + config.mounts.append(.sharedMount(name: "shared-data", destination: "/data")) + } + + // Container 2: reads what the writer produced and writes a second file, + // mounted at a different path to prove it's the same backing store. + let appenderBuffer = BufferWriter() + try await pod.addContainer("appender", rootfs: rootfs2) { config in + config.process.arguments = [ + "/bin/sh", "-c", + "cat /vol/shared.txt && echo more-content > /vol/second.txt", + ] + config.process.stdout = appenderBuffer + config.mounts.append(.sharedMount(name: "shared-data", destination: "/vol")) + } + + // Container 3: reads both files written by the previous containers. + let readerBuffer = BufferWriter() + try await pod.addContainer("reader", rootfs: rootfs3) { config in + config.process.arguments = [ + "/bin/sh", "-c", + "cat /shared/shared.txt && cat /shared/second.txt && grep /shared /proc/mounts", + ] + config.process.stdout = readerBuffer + config.mounts.append(.sharedMount(name: "shared-data", destination: "/shared")) + } + + do { + try await pod.create() + + // Run the containers sequentially so reads see prior writes. + try await pod.startContainer("writer") + let writerStatus = try await pod.waitContainer("writer") + guard writerStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)") + } + + try await pod.startContainer("appender") + let appenderStatus = try await pod.waitContainer("appender") + guard appenderStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "appender exited with status \(appenderStatus)") + } + + try await pod.startContainer("reader") + let readerStatus = try await pod.waitContainer("reader") + guard readerStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "reader exited with status \(readerStatus)") + } + try await pod.stop() + } catch { + try? await pod.stop() + throw error + } + + // Verify writer mounted a virtio block device at /data. + let writerOutput = String(data: writerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let writerLines = writerOutput.components(separatedBy: "\n") + guard !writerLines.isEmpty else { + throw IntegrationError.assert(msg: "writer produced no output") + } + try assertVirtioBlockMount(writerLines.last!, path: "/data") + + // Verify the appender read the writer's file. + let appenderOutput = String(data: appenderBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard appenderOutput == "shared-content" else { + throw IntegrationError.assert(msg: "appender: expected 'shared-content', got '\(appenderOutput)'") + } + + // Verify the reader saw both files and a virtio block mount. + let readerOutput = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let readerLines = readerOutput.components(separatedBy: "\n") + guard readerLines.count >= 3 else { + throw IntegrationError.assert(msg: "expected at least 3 lines from reader, got: \(readerOutput)") + } + guard readerLines[0] == "shared-content" else { + throw IntegrationError.assert(msg: "reader: expected 'shared-content', got '\(readerLines[0])'") + } + guard readerLines[1] == "more-content" else { + throw IntegrationError.assert(msg: "reader: expected 'more-content', got '\(readerLines[1])'") + } + try assertVirtioBlockMount(readerLines[2], path: "/shared") + + // Verify both writes landed on the host-side EXT4 disk image. + let firstContent = try readFileFromDiskImage(diskURL, path: "/shared.txt") + guard firstContent == "shared-content" else { + throw IntegrationError.assert(msg: "disk image /shared.txt: expected 'shared-content', got '\(firstContent)'") + } + let secondContent = try readFileFromDiskImage(diskURL, path: "/second.txt") + guard secondContent == "more-content" else { + throw IntegrationError.assert(msg: "disk image /second.txt: expected 'more-content', got '\(secondContent)'") + } + } + + /// Attach an in-memory tmpfs pod volume shared across two containers: one + /// writes a file and verifies the mount type/size, the other reads the file + /// back from a different mount path to prove it is the same backing store. + func testPodSharedTmpfsVolume() async throws { + let id = "test-pod-shared-tmpfs-volume" + let bs = try await bootstrap(id) + + let rootfs1 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "writer") + let rootfs2 = try cloneRootfsForContainer(bs.rootfs, testID: id, containerID: "reader") + + let pod = try LinuxPod(id, vmm: bs.vmm) { config in + config.cpus = 1 + config.memoryInBytes = 512.mib() + config.bootLog = bs.bootLog + config.volumes = [ + .init( + name: "mytmpfs", + source: .tmpfs(sizeBytes: 128.mib()), + format: "tmpfs" + ) + ] + } + + // Container 1: writes a file to the shared tmpfs volume and verifies mount type. + let writerBuffer = BufferWriter() + try await pod.addContainer("writer", rootfs: rootfs1) { config in + config.process.arguments = [ + "/bin/sh", "-c", + "echo bar > /my-tmpfs-mount/foo && grep /my-tmpfs-mount /proc/mounts", + ] + config.process.stdout = writerBuffer + config.mounts.append(.sharedMount(name: "mytmpfs", destination: "/my-tmpfs-mount")) + } + + // Container 2: reads the file the writer produced, mounted at a different + // path to prove it is the same in-memory backing store. + let readerBuffer = BufferWriter() + try await pod.addContainer("reader", rootfs: rootfs2) { config in + config.process.arguments = [ + "/bin/sh", "-c", + "cat /shared-tmpfs/foo && grep /shared-tmpfs /proc/mounts", + ] + config.process.stdout = readerBuffer + config.mounts.append(.sharedMount(name: "mytmpfs", destination: "/shared-tmpfs")) + } + + do { + try await pod.create() + + // Run sequentially so the reader observes the writer's file. + try await pod.startContainer("writer") + let writerStatus = try await pod.waitContainer("writer") + guard writerStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "writer exited with status \(writerStatus)") + } + + try await pod.startContainer("reader") + let readerStatus = try await pod.waitContainer("reader") + guard readerStatus.exitCode == 0 else { + throw IntegrationError.assert(msg: "reader exited with status \(readerStatus)") + } + try await pod.stop() + } catch { + try? await pod.stop() + throw error + } + + // Verify the writer mounted a tmpfs of the expected size. + let writerOut = String(data: writerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + guard writerOut.contains("tmpfs /my-tmpfs-mount tmpfs") else { + throw IntegrationError.assert(msg: "pod volume /my-tmpfs-mount is not backed by tmpfs: \(writerOut)") + } + // tmpfs reports its size in /proc/mounts in kibibytes with a trailing 'k'. + let expectedSizeKiB: UInt64 = 128 * 1024 + guard writerOut.contains("size=\(expectedSizeKiB)k") else { + throw IntegrationError.assert(msg: "tmpfs mount is not of expected size (\(expectedSizeKiB)k): \(writerOut)") + } + + // Verify the reader saw the writer's file on the shared tmpfs at its own path. + let readerOut = String(data: readerBuffer.data, encoding: .utf8)?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + let readerLines = readerOut.components(separatedBy: "\n") + guard readerLines.count >= 2 else { + throw IntegrationError.assert(msg: "expected at least 2 lines from reader, got: \(readerOut)") + } + guard readerLines[0] == "bar" else { + throw IntegrationError.assert(msg: "reader: expected 'bar', got '\(readerLines[0])'") + } + guard readerOut.contains("tmpfs /shared-tmpfs tmpfs") else { + throw IntegrationError.assert(msg: "reader mount /shared-tmpfs is not backed by tmpfs: \(readerOut)") + } + } } +#endif diff --git a/Sources/Integration/Suite.swift b/Sources/Integration/Suite.swift index 276fce0a0..9dae84351 100644 --- a/Sources/Integration/Suite.swift +++ b/Sources/Integration/Suite.swift @@ -26,6 +26,12 @@ import NIOCore import NIOPosix import Synchronization +#if canImport(Musl) +import Musl +#elseif canImport(Glibc) +import Glibc +#endif + actor UnpackCoordinator { private var inFlight: [String: Task] = [:] @@ -149,7 +155,23 @@ struct IntegrationSuite: AsyncParsableCommand { var bootlogDir: String = "./bin/integration-bootlogs" @Option(name: .shortAndLong, help: "Path to a kernel binary") - var kernel: String = "./bin/vmlinux" + var kernel: String = Self.defaultKernelPath + + #if arch(arm64) + private static let kernelCandidates = ["./bin/vmlinux-arm64"] + #elseif arch(x86_64) + private static let kernelCandidates = ["./bin/vmlinuz-x86_64", "./bin/vmlinux-x86_64"] + #else + private static let kernelCandidates = ["./bin/vmlinux"] + #endif + + private static let defaultKernelPath: String = { + let fm = FileManager.default + for candidate in kernelCandidates where fm.fileExists(atPath: candidate) { + return candidate + } + return kernelCandidates[0] + }() @Option(name: .shortAndLong, help: "Maximum number of concurrent tests") var maxConcurrency: Int = 4 @@ -157,6 +179,14 @@ struct IntegrationSuite: AsyncParsableCommand { @Option(name: .shortAndLong, help: "Only run tests whose names contain this string") var filter: String? + #if os(Linux) + @Option(name: .long, help: "Path to cloud-hypervisor binary (Linux only). Defaults to PATH lookup.") + var chBinary: String? + + @Option(name: .long, help: "Path to virtiofsd binary (Linux only). Defaults to PATH lookup.") + var virtiofsdBinary: String? + #endif + static func binPath(name: String) -> URL { URL(fileURLWithPath: FileManager.default.currentDirectoryPath) .appendingPathComponent("bin") @@ -187,8 +217,18 @@ struct IntegrationSuite: AsyncParsableCommand { } }() - var testKernel = Kernel(path: .init(filePath: kernel), platform: .linuxArm) - testKernel.commandLine.addDebug() + let testKernel = Kernel(path: .init(filePath: kernel), platform: .linuxArm) + // Intentionally NOT adding `debug` or `earlycon=pl011,...` here. + // Both look free, but each costs real wall-clock per VM boot: + // * `debug` floods printk through hvc0 (which CH writes to the + // bootlog file). + // * `earlycon=pl011,...` routes every early-boot printk character + // through pl011 MMIO traps into CH's serial emulator. With CH's + // pl011 wired to a file (see CHVirtualMachineInstance.serialConfig) + // each character is a synchronous file write and ~50–80 ms of + // dmesg quantization showed up in measurements — adding ~1.5 s + // to every VM boot before bootconsole hands over to virtio_console. + // Re-add either as a one-shot when actively diagnosing kernel boot. let image = try await Self.fetchImage(reference: reference, store: store) let platform = Platform(arch: "arm64", os: "linux", variant: "v8") @@ -196,7 +236,7 @@ struct IntegrationSuite: AsyncParsableCommand { let fsPath = Self.testDir.appending(component: image.digest) let fs = try await Self.unpackCoordinator.unpack(key: fsPath.absolutePath()) { do { - let unpacker = EXT4Unpacker(blockSizeInBytes: 2.gib()) + let unpacker = EXT4Unpacker(capacityInBytes: 2.gib()) return try await unpacker.unpack(image, for: platform, at: fsPath) } catch let err as ContainerizationError { if err.code == .exists { @@ -211,29 +251,106 @@ 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) let cl = try fs.clone(to: clPath) + // Per-test clone of the init.block. The init.block is supposed to be + // mounted read-only (kernel cmdline + readonly=true on the virtio-blk + // device for both VZ and CH), but sharing the same backing file across + // concurrent CH VMs has surfaced "internalError: mount" cascades on + // Linux/CH after a single test failure — symptomatic of the file + // entering a bad state when one CH instance is killed mid-flight. + // Cloning per test isolates each VM from any cross-test fallout. + let initClonePath = Self.testDir.appending(component: "\(testID).init.block").absolutePath() + try? FileManager.default.removeItem(atPath: initClonePath) + let initfsPerTest = try initfs.clone(to: initClonePath) + // Create bootLog directory and per-container bootLog path let bootlogDirURL = URL(filePath: bootlogDir) try? FileManager.default.createDirectory(at: bootlogDirURL, withIntermediateDirectories: true) let bootlogURL = bootlogDirURL.appendingPathComponent("\(testID).log") + let vmm: any VirtualMachineManager = try Self.makeVMM( + kernel: testKernel, + initialFilesystem: initfsPerTest, + chBinary: Self.chBinaryOverride(for: self), + virtiofsdBinary: Self.virtiofsdBinaryOverride(for: self) + ) + return ( cl, - VZVirtualMachineManager( - kernel: testKernel, - initialFilesystem: initfs, - group: Self.eventLoop - ), + vmm, image, BootLog.file(path: bootlogURL) ) } + private static func chBinaryOverride(for suite: IntegrationSuite) -> String? { + #if os(Linux) + return suite.chBinary + #else + _ = suite + return nil + #endif + } + + private static func virtiofsdBinaryOverride(for suite: IntegrationSuite) -> String? { + #if os(Linux) + return suite.virtiofsdBinary + #else + _ = suite + return nil + #endif + } + + private static func makeVMM( + kernel: Kernel, + initialFilesystem: Containerization.Mount, + chBinary: String?, + virtiofsdBinary: String? + ) throws -> any VirtualMachineManager { + #if os(macOS) + _ = chBinary + _ = virtiofsdBinary + return VZVirtualMachineManager( + kernel: kernel, + initialFilesystem: initialFilesystem, + group: Self.eventLoop + ) + #elseif os(Linux) + return try CHVirtualMachineManager( + kernel: kernel, + initialFilesystem: initialFilesystem, + chBinary: chBinary.map { URL(fileURLWithPath: $0) }, + virtiofsdBinary: virtiofsdBinary.map { URL(fileURLWithPath: $0) }, + group: Self.eventLoop, + logger: log + ) + #endif + } + static func fetchImage(reference: String, store: ImageStore) async throws -> Containerization.Image { do { return try await store.get(reference: reference) @@ -247,27 +364,43 @@ struct IntegrationSuite: AsyncParsableCommand { static func adjustLimits() throws { var limits = rlimit() - guard getrlimit(RLIMIT_NOFILE, &limits) == 0 else { + #if os(Linux) + let resource = __rlimit_resource_t(RLIMIT_NOFILE.rawValue) + #else + let resource = RLIMIT_NOFILE + #endif + + guard getrlimit(resource, &limits) == 0 else { throw POSIXError(.init(rawValue: errno)!) } limits.rlim_cur = 65536 limits.rlim_max = 65536 - guard setrlimit(RLIMIT_NOFILE, &limits) == 0 else { + guard setrlimit(resource, &limits) == 0 else { throw POSIXError(.init(rawValue: errno)!) } } + #if os(macOS) private func macOS26Tests() -> [Test] { if #available(macOS 26.0, *) { return [ Test("container interface custom MTU", testInterfaceMTU), Test("container networking disabled", testNetworkingDisabled), Test("container networking enabled", testNetworkingEnabled), + Test("container networking enabled ipv6", testNetworkingEnabledIPv6), + Test("container IPv6 address", testIPv6AddressAdd), + Test("container IPv6 default route", testIPv6DefaultRoute), + Test("container IPv6 gateway outside subnet", testIPv6GatewayOutsideSubnet), + Test("container IPv6 only default route", testIPv6OnlyDefaultRoute), + Test("container IPv6 only gateway outside subnet", testIPv6OnlyGatewayOutsideSubnet), + Test("container IPv6 dual stack", testIPv6DualStack), + Test("pod IPv6 address", testPodIPv6AddressAdd), ] } return [] } + #endif // Why does this exist? // @@ -285,164 +418,229 @@ struct IntegrationSuite: AsyncParsableCommand { let suiteStarted = Date().timeIntervalSinceReferenceDate log.info("starting integration suite\n") - let tests: [Test] = + let crossPlatformTests: [Test] = [ + // Process basics + Test("process true", testProcessTrue), + Test("process false", testProcessFalse), + Test("process echo hi", testProcessEchoHi), + Test("process no executable", testProcessNoExecutable), + Test("process user", testProcessUser), + Test("process stdin", testProcessStdin), + Test("process home envvar", testProcessHomeEnvvar), + Test("process custom home envvar", testProcessCustomHomeEnvvar), + Test("process tty ensure TERM", testProcessTtyEnvvar), + + // Hostname / hosts + Test("container hostname", testHostname), + Test("container hostname defaults to container id", testHostnameDefaultsToContainerID), + Test("container hosts", testHostsFile), + + // Statistics / cgroups / memory + Test("container statistics", testContainerStatistics), + Test("container cgroup limits", testCgroupLimits), + Test("container memory events OOM kill", testMemoryEventsOOMKill), + + // Console / boot / lifecycle + Test("container no serial console", testNoSerialConsole), + Test("container non-closure constructor", testNonClosureConstructor), + Test("container test large stdio ingest", testLargeStdioOutput), + Test("container bootlog using filehandle", testBootLogFileHandle), + Test("process delete idempotency", testProcessDeleteIdempotency), + Test("multiple execs without delete", testMultipleExecsWithoutDelete), + + // Capabilities + Test("container capabilities sys admin", testCapabilitiesSysAdmin), + Test("container capabilities net admin", testCapabilitiesNetAdmin), + Test("container capabilities OCI default", testCapabilitiesOCIDefault), + Test("container capabilities all capabilities", testCapabilitiesAllCapabilities), + Test("container capabilities file ownership", testCapabilitiesFileOwnership), + + // Masked / read-only paths + Test("container default masked and read-only paths", testDefaultMaskedAndReadonlyPaths), + + // Namespaces + Test("container exec joins init namespaces", testExecJoinsInitNamespaces), + + // Stat / Copy + Test("container stat", testStat), + Test("container copy in", testCopyIn), + Test("container copy in file to existing directory", testCopyInFileToExistingDirectory), + Test("container copy in file to missing directory fails", testCopyInFileToMissingDirectoryFails), + Test("container copy in directory over existing file fails", testCopyInDirectoryOverExistingFileFails), + Test("container copy out", testCopyOut), + Test("container copy large file", testCopyLargeFile), + Test("container copy in directory", testCopyInDirectory), + Test("container copy out directory", testCopyOutDirectory), + Test("container copy empty file", testCopyEmptyFile), + Test("container copy empty directory", testCopyEmptyDirectory), + Test("container copy binary file", testCopyBinaryFile), + Test("container copy multiple files", testCopyMultipleFiles), + Test("container copy directory round trip", testCopyDirectoryRoundTrip), + Test("container copy in create parents", testCopyInCreateParents), + Test("container copy file permissions", testCopyFilePermissions), + Test("container copy large directory", testCopyLargeDirectory), + + // Read-only / writable layers + Test("container read-only rootfs", testReadOnlyRootfs), + Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten), + Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured), + Test("container writable layer", testWritableLayer), + Test("container writable layer journal writeback", testWritableLayerJournalWriteback), + Test("container writable layer journal ordered", testWritableLayerJournalOrdered), + Test("container writable layer journal data", testWritableLayerJournalData), + Test("container writable layer preserves lower", testWritableLayerPreservesLowerLayer), + Test("container writable layer reads from lower", testWritableLayerReadsFromLower), + Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower), + Test("container writable layer size", testWritableLayerSize), + Test("container writable layer DNS and hosts", testWritableLayerWithDNSAndHosts), + + // Stdin / stdout / exec + Test("large stdin input", testLargeStdinInput), + Test("exec large stdin input", testExecLargeStdinInput), + Test("exec custom path resolution", testExecCustomPathResolution), + Test("stdin explicit close", testStdinExplicitClose), + Test("stdin binary data", testStdinBinaryData), + Test("stdin multiple chunks", testStdinMultipleChunks), + Test("stdin very large", testStdinVeryLarge), + + // RLimit + Test("container rlimit open files", testRLimitOpenFiles), + Test("container rlimit multiple", testRLimitMultiple), + Test("container rlimit exec", testRLimitExec), + + // useInit + Test("container useInit basic", testUseInitBasic), + Test("container useInit exit code propagation", testUseInitExitCodePropagation), + Test("container useInit signal forwarding", testUseInitSignalForwarding), + Test("container useInit zombie reaping", testUseInitZombieReaping), + Test("container useInit with terminal", testUseInitWithTerminal), + Test("container useInit with stdin", testUseInitWithStdin), + + // Sysctl / security / workingDir + Test("container sysctl", testSysctl), + Test("container sysctl multiple", testSysctlMultiple), + Test("container noNewPrivileges", testNoNewPrivileges), + Test("container noNewPrivileges disabled", testNoNewPrivilegesDisabled), + Test("container noNewPrivileges exec", testNoNewPrivilegesExec), + Test("container workingDir created", testWorkingDirCreated), + Test("container workingDir exec created", testWorkingDirExecCreated), + + // VM resource overhead + Test("container VM resource overhead", testVMResourceOverhead), + + // Pods + Test("pod single container", testPodSingleContainer), + Test("pod multiple containers", testPodMultipleContainers), + Test("pod container output", testPodContainerOutput), + Test("pod concurrent containers", testPodConcurrentContainers), + Test("pod exec in container", testPodExecInContainer), + Test("pod exec in container env", testPodExecInContainerEnv), + Test("pod container hostname", testPodContainerHostname), + Test("pod container hostname defaults to container id", testPodContainerHostnameDefaultsToContainerID), + Test("pod stop container idempotency", testPodStopContainerIdempotency), + Test("pod list containers", testPodListContainers), + Test("pod container statistics", testPodContainerStatistics), + Test("pod memory events OOM kill", testPodMemoryEventsOOMKill), + Test("pod container resource limits", testPodContainerResourceLimits), + Test("pod container filesystem isolation", testPodContainerFilesystemIsolation), + Test("pod container PID namespace isolation", testPodContainerPIDNamespaceIsolation), + Test("pod container independent resource limits", testPodContainerIndependentResourceLimits), + Test("pod shared PID namespace", testPodSharedPIDNamespace), + Test("pod read-only rootfs", testPodReadOnlyRootfs), + Test("pod read-only rootfs DNS", testPodReadOnlyRootfsDNSConfigured), + Test("pod container hosts config", testPodContainerHostsConfig), + Test("pod multiple containers different DNS", testPodMultipleContainersDifferentDNS), + Test("pod multiple containers different hosts", testPodMultipleContainersDifferentHosts), + Test("pod level DNS", testPodLevelDNS), + Test("pod level DNS with container override", testPodLevelDNSWithContainerOverride), + Test("pod level hosts", testPodLevelHosts), + Test("pod level hosts with container override", testPodLevelHostsWithContainerOverride), + Test("pod level hostname", testPodLevelHostname), + Test("pod level hostname with container override", testPodLevelHostnameWithContainerOverride), + Test("pod rlimit open files", testPodRLimitOpenFiles), + Test("pod rlimit exec", testPodRLimitExec), + Test("pod useInit basic", testPodUseInitBasic), + Test("pod useInit exit code propagation", testPodUseInitExitCodePropagation), + Test("pod useInit signal forwarding", testPodUseInitSignalForwarding), + Test("pod useInit multiple containers", testPodUseInitMultipleContainers), + Test("pod useInit with shared PID namespace", testPodUseInitWithSharedPIDNamespace), + Test("pod sysctl", testPodSysctl), + Test("pod sysctl multiple containers", testPodSysctlMultipleContainers), + Test("pod invalid volume reference", testPodInvalidVolumeReference), + Test("pod duplicate volume name", testPodDuplicateVolumeName), + + // Mounts / virtiofs shares (cross-platform: VZ on macOS, virtiofsd on Linux/CH). + Test("container mount", testMounts), + Test("container single file mount", testSingleFileMount), + Test("container single file mount read-only", testSingleFileMountReadOnly), + Test("container single file mount write-back", testSingleFileMountWriteBack), + Test("container single file mount symlink", testSingleFileMountSymlink), + Test("container duplicate virtiofs mount", testDuplicateVirtiofsMount), + Test("container duplicate virtiofs mount via symlink", testDuplicateVirtiofsMountViaSymlink), + Test("container mount sort by depth", testMountsSortedByDepth), + Test("pod single file mount", testPodSingleFileMount), + ] + + #if os(macOS) + let macOSOnlyTests: [Test] = [ - // Containers - Test("process true", testProcessTrue), - Test("process false", testProcessFalse), - Test("process echo hi", testProcessEchoHi), - Test("process no executable", testProcessNoExecutable), - Test("process user", testProcessUser), - Test("process stdin", testProcessStdin), - Test("process home envvar", testProcessHomeEnvvar), - Test("process custom home envvar", testProcessCustomHomeEnvvar), - Test("process tty ensure TERM", testProcessTtyEnvvar), - Test("multiple concurrent processes", testMultipleConcurrentProcesses), - Test("multiple concurrent processes with output stress", testMultipleConcurrentProcessesOutputStress), - Test("container hostname", testHostname), - Test("container hostname defaults to container id", testHostnameDefaultsToContainerID), - Test("container hosts", testHostsFile), - Test("container mount", testMounts), + // ContainerManager-based tests (ContainerManager is macOS-only) Test("container stop idempotency", testContainerStopIdempotency), - Test("nested virt", testNestedVirtualizationEnabled), Test("container manager", testContainerManagerCreate), Test("container reuse", testContainerReuse), Test("container /dev/console", testContainerDevConsole), - Test("container statistics", testContainerStatistics), - Test("container cgroup limits", testCgroupLimits), - Test("container memory events OOM kill", testMemoryEventsOOMKill), - Test("container no serial console", testNoSerialConsole), + + // Nested virtualization (VZ-only feature) + Test("nested virt", testNestedVirtualizationEnabled), + + // Filesystem operations (TODO: promote to cross-platform once verified on CH) + Test("container frozen ext4 clone", testFrozenExt4Clone), + Test("container trim ext4 clone", testTrimExt4Clone), + + // Unix socket forwarding (dynamic vsock listen exceeds CH's prebound stdio pool) Test("unix socket into guest", testUnixSocketIntoGuest), Test("unix socket into guest long container id", testUnixSocketIntoGuestLongContainerID), Test("unix socket into guest symlink", testUnixSocketIntoGuestSymlink), - Test("container non-closure constructor", testNonClosureConstructor), - Test("container test large stdio ingest", testLargeStdioOutput), - Test("process delete idempotency", testProcessDeleteIdempotency), - Test("multiple execs without delete", testMultipleExecsWithoutDelete), - Test("container bootlog using filehandle", testBootLogFileHandle), - Test("container capabilities sys admin", testCapabilitiesSysAdmin), - Test("container capabilities net admin", testCapabilitiesNetAdmin), - Test("container capabilities OCI default", testCapabilitiesOCIDefault), - Test("container capabilities all capabilities", testCapabilitiesAllCapabilities), - Test("container capabilities file ownership", testCapabilitiesFileOwnership), - Test("container stat", testStat), - Test("container copy in", testCopyIn), - Test("container copy in file to existing directory", testCopyInFileToExistingDirectory), - Test("container copy in file to missing directory fails", testCopyInFileToMissingDirectoryFails), - Test("container copy in directory over existing file fails", testCopyInDirectoryOverExistingFileFails), - Test("container copy out", testCopyOut), - Test("container copy large file", testCopyLargeFile), - Test("container copy in directory", testCopyInDirectory), - Test("container copy out directory", testCopyOutDirectory), - Test("container copy empty file", testCopyEmptyFile), - Test("container copy empty directory", testCopyEmptyDirectory), - Test("container copy binary file", testCopyBinaryFile), - Test("container copy multiple files", testCopyMultipleFiles), - Test("container copy directory round trip", testCopyDirectoryRoundTrip), - Test("container copy in create parents", testCopyInCreateParents), - Test("container copy file permissions", testCopyFilePermissions), - Test("container copy large directory", testCopyLargeDirectory), - Test("container read-only rootfs", testReadOnlyRootfs), - Test("container read-only rootfs hosts file", testReadOnlyRootfsHostsFileWritten), - Test("container read-only rootfs DNS", testReadOnlyRootfsDNSConfigured), - Test("container writable layer", testWritableLayer), - Test("container writable layer journal writeback", testWritableLayerJournalWriteback), - Test("container writable layer journal ordered", testWritableLayerJournalOrdered), - Test("container writable layer journal data", testWritableLayerJournalData), - Test("container writable layer preserves lower", testWritableLayerPreservesLowerLayer), - Test("container writable layer reads from lower", testWritableLayerReadsFromLower), - Test("container writable layer with ro lower", testWritableLayerWithReadOnlyLower), - Test("container writable layer size", testWritableLayerSize), - Test("container writable layer DNS and hosts", testWritableLayerWithDNSAndHosts), - Test("large stdin input", testLargeStdinInput), - Test("exec large stdin input", testExecLargeStdinInput), - Test("exec custom path resolution", testExecCustomPathResolution), - Test("stdin explicit close", testStdinExplicitClose), - Test("stdin binary data", testStdinBinaryData), - Test("stdin multiple chunks", testStdinMultipleChunks), - Test("stdin very large", testStdinVeryLarge), - Test("container single file mount", testSingleFileMount), - Test("container single file mount read-only", testSingleFileMountReadOnly), - Test("container single file mount write-back", testSingleFileMountWriteBack), - Test("container single file mount symlink", testSingleFileMountSymlink), - Test("container rlimit open files", testRLimitOpenFiles), - Test("container rlimit multiple", testRLimitMultiple), - Test("container rlimit exec", testRLimitExec), - Test("container duplicate virtiofs mount", testDuplicateVirtiofsMount), - Test("container duplicate virtiofs mount via symlink", testDuplicateVirtiofsMountViaSymlink), - Test("container useInit basic", testUseInitBasic), - Test("container useInit exit code propagation", testUseInitExitCodePropagation), - Test("container useInit signal forwarding", testUseInitSignalForwarding), - Test("container useInit zombie reaping", testUseInitZombieReaping), - Test("container useInit with terminal", testUseInitWithTerminal), - Test("container useInit with stdin", testUseInitWithStdin), - Test("container sysctl", testSysctl), - Test("container sysctl multiple", testSysctlMultiple), - Test("container noNewPrivileges", testNoNewPrivileges), - Test("container noNewPrivileges disabled", testNoNewPrivilegesDisabled), - Test("container noNewPrivileges exec", testNoNewPrivilegesExec), - Test("container workingDir created", testWorkingDirCreated), - Test("container workingDir exec created", testWorkingDirExecCreated), - Test("container mount sort by depth", testMountsSortedByDepth), - Test("container VM resource overhead", testVMResourceOverhead), + Test("pod unix socket into guest symlink", testPodUnixSocketIntoGuestSymlink), + + // High-concurrency stdio (exceeds CH's prebound stdio pool size) + Test("multiple concurrent processes", testMultipleConcurrentProcesses), + Test("multiple concurrent processes with output stress", testMultipleConcurrentProcessesOutputStress), + + // NBD volumes (test infra is macOS-only) Test("container NBD mount", testContainerNBDMount), Test("container NBD read-only", testContainerNBDReadOnly), Test("container NBD raw block", testContainerNBDRawBlock), Test("container NBD volume identity", testContainerNBDVolumeIdentity), - - // Pods - Test("pod single container", testPodSingleContainer), - Test("pod multiple containers", testPodMultipleContainers), - Test("pod container output", testPodContainerOutput), - Test("pod concurrent containers", testPodConcurrentContainers), - Test("pod exec in container", testPodExecInContainer), - Test("pod exec in container env", testPodExecInContainerEnv), - Test("pod container hostname", testPodContainerHostname), - Test("pod container hostname defaults to container id", testPodContainerHostnameDefaultsToContainerID), - Test("pod stop container idempotency", testPodStopContainerIdempotency), - Test("pod list containers", testPodListContainers), - Test("pod container statistics", testPodContainerStatistics), - Test("pod memory events OOM kill", testPodMemoryEventsOOMKill), - Test("pod container resource limits", testPodContainerResourceLimits), - Test("pod container filesystem isolation", testPodContainerFilesystemIsolation), - Test("pod container PID namespace isolation", testPodContainerPIDNamespaceIsolation), - Test("pod container independent resource limits", testPodContainerIndependentResourceLimits), - Test("pod shared PID namespace", testPodSharedPIDNamespace), - Test("pod read-only rootfs", testPodReadOnlyRootfs), - Test("pod read-only rootfs DNS", testPodReadOnlyRootfsDNSConfigured), - Test("pod single file mount", testPodSingleFileMount), - Test("pod container hosts config", testPodContainerHostsConfig), - Test("pod multiple containers different DNS", testPodMultipleContainersDifferentDNS), - Test("pod multiple containers different hosts", testPodMultipleContainersDifferentHosts), - Test("pod level DNS", testPodLevelDNS), - Test("pod level DNS with container override", testPodLevelDNSWithContainerOverride), - Test("pod level hosts", testPodLevelHosts), - Test("pod level hosts with container override", testPodLevelHostsWithContainerOverride), - Test("pod level hostname", testPodLevelHostname), - Test("pod level hostname with container override", testPodLevelHostnameWithContainerOverride), - Test("pod rlimit open files", testPodRLimitOpenFiles), - Test("pod rlimit exec", testPodRLimitExec), - Test("pod useInit basic", testPodUseInitBasic), - Test("pod useInit exit code propagation", testPodUseInitExitCodePropagation), - Test("pod useInit signal forwarding", testPodUseInitSignalForwarding), - Test("pod useInit multiple containers", testPodUseInitMultipleContainers), - Test("pod useInit with shared PID namespace", testPodUseInitWithSharedPIDNamespace), - Test("pod unix socket into guest symlink", testPodUnixSocketIntoGuestSymlink), - Test("pod sysctl", testPodSysctl), - Test("pod sysctl multiple containers", testPodSysctlMultipleContainers), Test("pod shared NBD volume", testPodSharedNBDVolume), Test("pod multiple NBD volumes", testPodMultipleNBDVolumes), Test("pod unreferenced NBD volume", testPodUnreferencedVolume), Test("pod NBD volume persistence", testPodNBDVolumePersistence), Test("pod NBD concurrent writes", testPodNBDConcurrentWrites), Test("pod NBD volume identity", testPodNBDVolumeIdentity), - Test("pod invalid volume reference", testPodInvalidVolumeReference), - Test("pod duplicate volume name", testPodDuplicateVolumeName), + Test("pod filesystem operation", testPodFilesystemOperation), + Test("pod shared disk image volume", testPodSharedDiskImageVolume), + Test("pod shared tmpfs volume", testPodSharedTmpfsVolume), ] + macOS26Tests() + let tests: [Test] = crossPlatformTests + macOSOnlyTests + #else + // Hotplug into a running pod VM is CH-only (VZ has no runtime hotplug), + // and no pod test elsewhere exercises addContainer-after-create. + let linuxOnlyTests: [Test] = [ + Test("pod hotplug block rootfs", testPodHotplugBlockRootfs), + Test("pod hotplug virtiofs rootfs", testPodHotplugVirtiofsRootfs), + ] + let tests: [Test] = crossPlatformTests + linuxOnlyTests + #endif let filteredTests: [Test] if let filter { - filteredTests = tests.filter { $0.name.contains(filter) } + // Comma-separated; ANY pattern matching the test name keeps it. + // E.g. `--filter "container mount,pod single file"`. + let patterns = filter.split(separator: ",").map { String($0) } + filteredTests = tests.filter { test in + patterns.contains { test.name.contains($0) } + } log.info("filter '\(filter)' matched \(filteredTests.count)/\(tests.count) tests") } else { filteredTests = tests diff --git a/Sources/cctl/BridgeCommand.swift b/Sources/cctl/BridgeCommand.swift new file mode 100644 index 000000000..2714acc27 --- /dev/null +++ b/Sources/cctl/BridgeCommand.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import ArgumentParser +import Containerization +import ContainerizationExtras +import Foundation + +extension Application { + struct Bridge: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "bridge", + abstract: "Manage the host bridge used by `cctl run` for container networking", + subcommands: [Create.self, Delete.self] + ) + + struct Create: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create (or reconfigure idempotently) the host bridge + NAT plumbing" + ) + + @Option(name: .long, help: "Bridge interface name") + var name: String = "cz0" + + @Option(name: .long, help: "IPv4 subnet in CIDR form") + var subnet: String = "192.168.64.0/24" + + @Option(name: .long, help: "Host-side IPv4 on the bridge (defaults to subnet.lower+1)") + var gateway: String? + + @Option(name: .long, help: "Egress interface for MASQUERADE (default: auto-detect from default route)") + var egress: String? + + @Option(name: .long, help: "Bridge MTU") + var mtu: UInt32 = 1500 + + @Flag( + name: .customLong("enable-nat"), + help: + "Program iptables MASQUERADE/FORWARD and enable net.ipv4.ip_forward so containers reach the outside network. Off by default — host firewall policy is left untouched." + ) + var enableNAT: Bool = false + + func run() async throws { + let cidr = try CIDRv4(subnet) + let gw = try gateway.map { try IPv4Address($0) } + let mgr = BridgeManager( + name: name, + subnet: cidr, + gateway: gw, + mtu: mtu, + egressInterface: egress, + enableNAT: enableNAT, + logger: log + ) + try mgr.create() + } + } + + struct Delete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Remove the bridge and revert the host plumbing this tool added" + ) + + @Option(name: .long, help: "Bridge interface name") + var name: String = "cz0" + + @Option(name: .long, help: "IPv4 subnet in CIDR form") + var subnet: String = "192.168.64.0/24" + + func run() async throws { + let cidr = try CIDRv4(subnet) + let mgr = BridgeManager(name: name, subnet: cidr, logger: log) + try mgr.delete() + } + } + } +} +#endif diff --git a/Sources/cctl/ImageCommand.swift b/Sources/cctl/ImageCommand.swift index 9c4a6e88c..6f9eba1a0 100644 --- a/Sources/cctl/ImageCommand.swift +++ b/Sources/cctl/ImageCommand.swift @@ -22,7 +22,6 @@ import ContainerizationExtras import ContainerizationOCI import Foundation -#if os(macOS) extension Application { struct Images: AsyncParsableCommand { static let configuration = CommandConfiguration( @@ -106,7 +105,8 @@ extension Application { }) var unpackPath: String? - @Flag(help: "Pull via plain text http") var http: Bool = false + @Flag(help: "Pull anonymously via plain-text HTTP.") + var http: Bool = false func run() async throws { let imageStore = Application.imageStore @@ -125,7 +125,7 @@ extension Application { } var startTime = ContinuousClock.now - let image = try await Images.withAuthentication(ref: normalizedReference) { auth in + let image = try await Images.withAuthentication(ref: normalizedReference, insecure: http) { auth in try await imageStore.pull(reference: normalizedReference, platform: platform, insecure: http, auth: auth) } @@ -146,7 +146,7 @@ extension Application { let unpackUrl = URL(filePath: unpackPath) try FileManager.default.createDirectory(at: unpackUrl, withIntermediateDirectories: true) - let unpacker = EXT4Unpacker.init(blockSizeInBytes: 2.gib()) + let unpacker = EXT4Unpacker.init(capacityInBytes: 2.gib()) startTime = ContinuousClock.now if let platform { @@ -178,7 +178,8 @@ extension Application { @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platformString: String? - @Flag(help: "Push via plain text http") var http: Bool = false + @Flag(help: "Push anonymously via plain-text HTTP.") + var http: Bool = false @Argument var ref: String @@ -198,7 +199,7 @@ extension Application { print("Reference resolved to \(reference.description)") } - try await Images.withAuthentication(ref: normalizedReference) { auth in + try await Images.withAuthentication(ref: normalizedReference, insecure: http) { auth in try await imageStore.push(reference: normalizedReference, platform: platform, insecure: http, auth: auth) } print("image pushed") @@ -265,20 +266,26 @@ extension Application { } private static func withAuthentication( - ref: String, _ body: @Sendable @escaping (_ auth: Authentication?) async throws -> T? + ref: String, insecure: Bool, + _ body: @Sendable @escaping (_ auth: Authentication?) async throws -> T? ) async throws -> T? { - var authentication: Authentication? - let ref = try Reference.parse(ref) - guard let host = ref.resolvedDomain else { + let parsed = try Reference.parse(ref) + guard let host = parsed.resolvedDomain else { throw ContainerizationError(.invalidArgument, message: "no host specified in image reference") } - authentication = Self.authenticationFromEnv(host: host) - if let authentication { - return try await body(authentication) + if insecure { + return try await body(nil) } + if let auth = Self.authenticationFromEnv(host: host) { + return try await body(auth) + } + #if os(macOS) let keychain = KeychainHelper(securityDomain: Application.keychainID) - authentication = try? keychain.lookup(hostname: host) + let authentication = try? keychain.lookup(hostname: host) return try await body(authentication) + #else + return try await body(nil) + #endif } private static func authenticationFromEnv(host: String) -> Authentication? { @@ -293,4 +300,3 @@ extension Application { } } } -#endif diff --git a/Sources/cctl/LoginCommand.swift b/Sources/cctl/LoginCommand.swift index 8389a6d4f..d48311ef9 100644 --- a/Sources/cctl/LoginCommand.swift +++ b/Sources/cctl/LoginCommand.swift @@ -41,8 +41,6 @@ extension Application { @Argument(help: "Registry server name") var server: String - @Flag(help: "Use plain text http to authenticate") var http: Bool = false - func run() async throws { var username = self.username var password = "" @@ -65,10 +63,9 @@ extension Application { } let server = Reference.resolveDomain(domain: self.server) - let scheme = http ? "http" : "https" let client = RegistryClient( host: server, - scheme: scheme, + scheme: "https", authentication: BasicAuthentication(username: username, password: password), retryOptions: .init( maxRetries: 10, diff --git a/Sources/cctl/RootfsCommand.swift b/Sources/cctl/RootfsCommand.swift index cd5fd36f8..e995a893a 100644 --- a/Sources/cctl/RootfsCommand.swift +++ b/Sources/cctl/RootfsCommand.swift @@ -16,11 +16,7 @@ import ArgumentParser import Containerization -import ContainerizationArchive -import ContainerizationEXT4 -import ContainerizationError import ContainerizationOCI -import ContainerizationOS import Foundation extension Application { @@ -34,147 +30,40 @@ extension Application { ) struct Create: AsyncParsableCommand { - @Option(name: [.short, .customLong("add-file")], help: "Additional file to add (format src-path:dst-path)") - var addFiles: [String] = [] - - @Option(name: .customLong("ext4"), help: "The path to an ext4 image to create.") - var ext4File: String? + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create an init image from a prebuilt rootfs tar archive" + ) @Option(name: .customLong("image"), help: "The name of the image to produce.") - var imageName: String? + var imageName: String @Option(name: .customLong("label"), help: "Label to add to the image (format: key=value)") var labels: [String] = [] - @Option(name: .long, help: "Platform of the built binaries being packaged into the block") + @Option(name: .long, help: "Platform of the binaries packaged into the rootfs") var platformString: String = Platform.current.description - @Option(name: .long, help: "Path to vmexec") - var vmexec: String - - @Option(name: .long, help: "Path to vminitd") - var vminitd: String - - @Option(name: .long, help: "Path to OCI runtime") - var ociRuntime: String? - - // The path where the intermediate tar archive is created. - @Argument var tarPath: String - - private static let directories = [ - "bin", - "sbin", - "dev", - "sys", - "proc/self", // hack for swift init's booting - "run", - "tmp", - "mnt", - "var", - ] + // The gzip-compressed rootfs tar archive whose contents make up the + // image layer — e.g. produced by `scripts/build-initfs.sh --tar`, + // which also builds the matching initfs.ext4. The rootfs layout is + // owned by that script; this command only wraps it into an image. + @Argument(help: "Path to the gzip-compressed rootfs tar archive") + var rootfs: String func run() async throws { - let path = URL(filePath: self.tarPath) - try await writeArchive(path: path) - - if let image = self.imageName { - print("creating initfs image \(image)...") - try await outputImage( - path: path, - reference: image - ) - } - - if let ext4Path = self.ext4File { - print("creating initfs ext4 image at \(ext4Path)...") - try await outputExt4( - archive: path, - to: URL(filePath: ext4Path) - ) - } - } - - private func outputExt4(archive: URL, to path: URL) async throws { - let unpacker = EXT4Unpacker(blockSizeInBytes: 256.mib()) - try await unpacker.unpack(archive: archive, compression: .gzip, at: path) - } - - private func outputImage(path: URL, reference: String) async throws { - let p = try Platform(from: platformString) + let platform = try Platform(from: platformString) let parsedLabels = Application.parseKeyValuePairs(from: labels) + print("creating initfs image \(imageName)...") _ = try await InitImage.create( - reference: reference, - rootfs: path, - platform: p, + reference: imageName, + rootfs: URL(filePath: rootfs), + platform: platform, labels: parsedLabels, imageStore: Application.imageStore, contentStore: Application.contentStore ) } - - private func writeArchive(path: URL) async throws { - let writer = try ArchiveWriter( - format: .pax, - filter: .gzip, - file: path, - ) - let ts = Date() - let entry = WriteEntry() - entry.permissions = 0o755 - entry.modificationDate = ts - entry.creationDate = ts - entry.group = 0 - entry.owner = 0 - entry.fileType = .directory - - // create the initial directory structure. - for dir in Self.directories { - entry.path = dir - try writer.writeEntry(entry: entry, data: nil) - } - - entry.fileType = .regular - entry.path = "sbin/vminitd" - - var src = URL(fileURLWithPath: vminitd) - var data = try Data(contentsOf: src) - entry.size = Int64(data.count) - try writer.writeEntry(entry: entry, data: data) - - src = URL(fileURLWithPath: vmexec) - data = try Data(contentsOf: src) - entry.path = "sbin/vmexec" - entry.size = Int64(data.count) - try writer.writeEntry(entry: entry, data: data) - - if let ociRuntimePath = self.ociRuntime { - src = URL(fileURLWithPath: ociRuntimePath) - let fileName = src.lastPathComponent - data = try Data(contentsOf: src) - entry.path = "sbin/\(fileName)" - entry.size = Int64(data.count) - try writer.writeEntry(entry: entry, data: data) - } - - for addFile in addFiles { - let paths = addFile.components(separatedBy: ":") - guard paths.count == 2 else { - throw ContainerizationError(.invalidArgument, message: "use src-path:dst-path for --add-file") - } - src = URL(fileURLWithPath: paths[0]) - data = try Data(contentsOf: src) - entry.path = paths[1] - entry.size = Int64(data.count) - try writer.writeEntry(entry: entry, data: data) - } - - entry.fileType = .symbolicLink - entry.path = "proc/self/exe" - entry.symlinkTarget = "sbin/vminitd" - entry.size = nil - try writer.writeEntry(entry: entry, data: nil) - try writer.finishEncoding() - } } } } diff --git a/Sources/cctl/RunCommand.swift b/Sources/cctl/RunCommand.swift index 3a3d579e9..fceff1724 100644 --- a/Sources/cctl/RunCommand.swift +++ b/Sources/cctl/RunCommand.swift @@ -106,14 +106,14 @@ extension Application { id, reference: imageReference, rootfsSizeInBytes: fsSizeInMB.mib(), - readOnly: readOnly + readOnly: readOnly, + networking: true ) { config in config.cpus = cpus config.memoryInBytes = memory.mib() config.process.setTerminalIO(terminal: current) config.process.arguments = arguments config.process.workingDirectory = cwd - config.process.capabilities = .allCapabilities for mount in self.mounts { let paths = mount.split(separator: ":") @@ -194,3 +194,340 @@ extension Application { } } #endif + +#if os(Linux) +extension Application { + /// Linux-side `cctl run` — boots a container in a cloud-hypervisor VM. + /// + /// Mirrors the macOS `cctl run` UX: `-i / --image` pulls and unpacks the + /// container image into an ext4 rootfs automatically. The Linux-specific + /// surface is `--initfs` (the deployment ships an `initfs.ext4` containing + /// vminitd; macOS resolves the equivalent via the local image store, but + /// on Linux the boot artifact is a path on disk). + struct Run: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run a container via cloud-hypervisor" + ) + + @Option(name: [.customLong("image"), .customShort("i")], help: "Image reference to base the container on") + var imageReference: String = "docker.io/library/alpine:3.16" + + @Option(name: .long, help: "id for the container") + var id: String = "cctl" + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate") + var cpus: Int = 2 + + @Option(name: [.customLong("memory"), .customShort("m")], help: "Amount of memory in MiB") + var memory: UInt64 = 1024 + + @Option(name: .customLong("fs-size"), help: "The size to create the container rootfs ext4 as (MiB)") + var fsSizeInMB: UInt64 = 2048 + + @Option(name: .customLong("mount"), help: "Directory to share into the container (Example: /foo:/bar)") + var mounts: [String] = [] + + @Option(name: .long, help: "Path to OCI runtime to use for spawning the container") + var ociRuntimePath: String? + + @Flag(name: .long, help: "Make rootfs readonly") + var readOnly: Bool = false + + @Flag(name: .long, help: "Run with an init process for signal forwarding and zombie reaping") + var `init`: Bool = false + + @Option( + name: [.customLong("kernel"), .customShort("k")], + help: "Path to the Linux kernel image", + completion: .file() + ) + var kernel: String + + @Option( + name: .customLong("initfs"), + help: "Path to the ext4 initfs containing vminitd (boots the VM as PID 1)", + completion: .file() + ) + var initfs: String + + @Option( + name: .customLong("bridge"), + help: "Bridge interface name to attach the container TAP to" + ) + var bridge: String = "cz0" + + @Option( + name: .customLong("subnet"), + help: "IPv4 subnet for the container network (CIDR)" + ) + var subnet: String = "192.168.64.0/24" + + @Option( + name: .customLong("gateway"), + help: "Host-side IPv4 on the bridge (defaults to subnet.lower+1)" + ) + var bridgeGateway: String? + + @Option( + name: .customLong("egress"), + help: "Egress interface for outbound NAT (default: auto-detect from default route)" + ) + var egress: String? + + @Flag(name: .customLong("no-network"), help: "Skip all host network setup; container has no interface") + var noNetwork: Bool = false + + @Flag( + name: .customLong("enable-nat"), + help: + "Program iptables MASQUERADE/FORWARD and enable ip_forward so the container can reach external networks. Off by default — the bridge stays internal-only." + ) + var enableNAT: Bool = false + + @Option(name: .customLong("ns"), help: "Nameserver addresses (default: read host /etc/resolv.conf)") + var nameservers: [String] = [] + + @Option( + name: .customLong("ch-binary"), + help: "Path to cloud-hypervisor binary (defaults to PATH lookup)" + ) + var chBinary: String? + + @Option( + name: .customLong("virtiofsd-binary"), + help: "Path to virtiofsd binary (defaults to PATH lookup)" + ) + var virtiofsdBinary: String? + + @Option(name: .long, help: "Current working directory") + var cwd: String = "/" + + @Argument(parsing: .captureForPassthrough) + var arguments: [String] = ["/bin/sh"] + + func run() async throws { + #if arch(arm64) + let kernelPlatform = SystemPlatform.linuxArm + #elseif arch(x86_64) + let kernelPlatform = SystemPlatform.linuxAmd + #else + #error("unsupported host architecture for `cctl run` (expected arm64 or x86_64)") + #endif + let imagePlatform = Platform.current + + let kernelObj = Kernel( + path: URL(fileURLWithPath: kernel), + platform: kernelPlatform + ) + + // Wire up the host TTY when there is one. `Terminal.current` walks + // STDERR/STDOUT/STDIN looking for a tty fd and throws if none of + // them is one (e.g. all stdio piped). In that case fall through to + // the non-interactive path so `cctl run /bin/true` still works. + let hostTerminal = try? Terminal.current + if let hostTerminal { + try hostTerminal.setraw() + } + defer { hostTerminal?.tryReset() } + let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH]) + + // Pull the container image and unpack to a per-container ext4 (same + // shape as ContainerManager.unpack on macOS: reuse the existing + // rootfs.ext4 if it's already there, fresh-unpack otherwise). + let imageStore = Application.imageStore + let reference = try Reference.parse(imageReference) + reference.normalize() + let normalizedRef = reference.description + if normalizedRef != imageReference { + print("Reference resolved to \(normalizedRef)") + } + let image = try await imageStore.get(reference: normalizedRef, pull: true) + + let containersRoot = Application.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(id) + try FileManager.default.createDirectory(at: containersRoot, withIntermediateDirectories: true) + let rootfsPath = containersRoot.appendingPathComponent("rootfs.ext4") + + var rootfsMount: Containerization.Mount + do { + let unpacker = EXT4Unpacker(capacityInBytes: fsSizeInMB.mib()) + rootfsMount = try await unpacker.unpack(image, for: imagePlatform, at: rootfsPath) + } catch let err as ContainerizationError where err.code == .exists { + rootfsMount = .block( + format: "ext4", + source: rootfsPath.absolutePath(), + destination: "/", + options: [] + ) + } + if readOnly { + rootfsMount.options.append("ro") + } + + let initfsMount = Mount.block( + format: "ext4", + source: initfs, + destination: "/", + options: ["ro"] + ) + + let manager = try CHVirtualMachineManager( + kernel: kernelObj, + initialFilesystem: initfsMount, + chBinary: chBinary.map { URL(fileURLWithPath: $0) }, + virtiofsdBinary: virtiofsdBinary.map { URL(fileURLWithPath: $0) }, + logger: log + ) + + // Seed process config from the image (entrypoint, env, cwd, user), + // then layer user-provided overrides on top — same precedence as + // ContainerManager + macOS Run. + let imageConfig = try await image.config(for: imagePlatform).config + var processConfig = LinuxProcessConfiguration() + if let imageConfig { + processConfig = .init(from: imageConfig) + } + processConfig.arguments = arguments + processConfig.workingDirectory = cwd + if let hostTerminal { + processConfig.setTerminalIO(terminal: hostTerminal) + } + + var interfaces: [any Interface] = [] + var dnsConfig: DNS? = nil + var hostsConfig: Hosts? = nil + + if !noNetwork { + let subnetCIDR = try CIDRv4(subnet) + let gw = try bridgeGateway.map { try IPv4Address($0) } + + let mgr = BridgeManager( + name: bridge, + subnet: subnetCIDR, + gateway: gw, + mtu: 1500, + egressInterface: egress, + enableNAT: enableNAT, + logger: log + ) + try mgr.create() + + var network = try LinuxBridgedNetwork( + subnet: subnetCIDR, + gateway: gw, + bridge: bridge, + mtu: 1500 + ) + if let iface = try network.createInterface(id) { + interfaces.append(iface) + + var h = Hosts.default + h.entries.append( + .init( + ipAddress: iface.ipv4Address.address.description, + hostnames: [id] + )) + hostsConfig = h + + let resolved = + nameservers.isEmpty + ? Self.readHostNameservers() + : nameservers + dnsConfig = DNS(nameservers: resolved) + } + } + + let cpusCount = cpus + let memoryBytes = memory.mib() + let networkInterfaces = interfaces + let useInit = self.`init` + let extraMounts = self.mounts + let runtimePath = self.ociRuntimePath + let dns = dnsConfig + let hosts = hostsConfig + + let container = try LinuxContainer( + id, + rootfs: rootfsMount, + vmm: manager, + logger: log + ) { config in + config.process = processConfig + config.cpus = cpusCount + config.memoryInBytes = memoryBytes + config.interfaces = networkInterfaces + config.useInit = useInit + if let dns { config.dns = dns } + if let hosts { config.hosts = hosts } + + for mount in extraMounts { + let paths = mount.split(separator: ":") + if paths.count != 2 { + throw ContainerizationError( + .invalidArgument, + message: "incorrect mount format detected: \(mount)" + ) + } + config.mounts.append( + Mount.share(source: String(paths[0]), destination: String(paths[1])) + ) + } + + if let runtimePath { + config.ociRuntimePath = runtimePath + config.mounts = LinuxContainer.defaultOCIMounts() + } + } + + try await container.create() + try await container.start() + + // Sync the guest pty winsize to the host on start, and on every + // SIGWINCH while running. Only meaningful when we have a tty. + if let hostTerminal { + try? await container.resize(to: try hostTerminal.size) + } + + let exit = try await withThrowingTaskGroup( + of: Void.self, + returning: ExitStatus.self + ) { group in + if let hostTerminal { + group.addTask { + for await _ in sigwinchStream.signals { + try await container.resize(to: try hostTerminal.size) + } + } + } + let result = try await container.wait() + group.cancelAll() + try await container.stop() + return result + } + + if exit.exitCode != 0 { + throw ExitCode(exit.exitCode) + } + } + + /// Read `nameserver` lines from `/etc/resolv.conf`. Returns + /// `["1.1.1.1"]` if the file is missing or has no entries. + private static func readHostNameservers() -> [String] { + guard let text = try? String(contentsOfFile: "/etc/resolv.conf", encoding: .utf8) else { + return ["1.1.1.1"] + } + let servers = + text + .split(separator: "\n") + .compactMap { line -> String? in + let parts = line.split(separator: " ", maxSplits: 1, omittingEmptySubsequences: true) + guard parts.count == 2, parts[0] == "nameserver" else { return nil } + return String(parts[1]).trimmingCharacters(in: .whitespaces) + } + return servers.isEmpty ? ["1.1.1.1"] : servers + } + } +} +#endif diff --git a/Sources/cctl/cctl.swift b/Sources/cctl/cctl.swift index 2069a99e9..e2c541370 100644 --- a/Sources/cctl/cctl.swift +++ b/Sources/cctl/cctl.swift @@ -63,14 +63,14 @@ struct Application: AsyncParsableCommand { version: "2.0.0", subcommands: { var commands: [any ParsableCommand.Type] = [ - Rootfs.self - ] - #if os(macOS) - commands += [ + Rootfs.self, Images.self, - Login.self, Run.self, ] + #if os(macOS) + commands.append(Login.self) + #elseif os(Linux) + commands.append(Bridge.self) #endif return commands }() diff --git a/Tests/CloudHypervisorTests/ClientTests.swift b/Tests/CloudHypervisorTests/ClientTests.swift new file mode 100644 index 000000000..a5678dcdf --- /dev/null +++ b/Tests/CloudHypervisorTests/ClientTests.swift @@ -0,0 +1,506 @@ +//===----------------------------------------------------------------------===// +// 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 NIOPosix +import Testing + +@testable import CloudHypervisor + +@Suite("CloudHypervisor.Client") +struct ClientTests { + private static let group = MultiThreadedEventLoopGroup.singleton + + // MARK: - Init + + @Test("Client init succeeds with file:// URL") + func initSucceeds() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let socketURL = URL(filePath: server.socketPath) + let _ = try CloudHypervisor.Client( + socketPath: socketURL, + eventLoopGroup: Self.group + ) + } + + // MARK: - Invalid socket path + + @Test("Client init throws .invalidSocketPath for non-file URL") + func initThrowsForNonFileURL() throws { + let url = try #require(URL(string: "https://example.com")) + #expect(throws: CloudHypervisor.Error.self) { + try CloudHypervisor.Client(socketPath: url, eventLoopGroup: Self.group) + } + } + + // MARK: - Non-2xx response + + @Test("Non-2xx response throws .http with correct status") + func non2xxThrowsHTTPError() async throws { + let body = Data("not found".utf8) + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.notFound, body: body) + } + defer { Task { try? await server.shutdown() } } + + let socketURL = URL(filePath: server.socketPath) + let client = try CloudHypervisor.Client(socketPath: socketURL, eventLoopGroup: Self.group) + + struct Dummy: Decodable, Sendable {} + + do { + let _: Dummy = try await client.get("/api/v1/missing") + Issue.record("Expected .http error but call succeeded") + } catch let err as CloudHypervisor.Error { + guard case .http(let status, let respBody) = err else { + Issue.record("Expected .http, got \(err)") + return + } + #expect(status == .notFound) + #expect(respBody == body) + } catch { + Issue.record("Expected CloudHypervisor.Error but got \(error)") + } + } + + // MARK: - vmmPing + + @Test("vmmPing sends GET /api/v1/vmm.ping and decodes VmmPingResponse") + func vmmPing() async throws { + let expected = CloudHypervisor.VmmPingResponse(version: "v40.0", pid: 12345) + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + (try? StubResponse.json(expected)) ?? StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + let result = try await client.vmmPing() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .GET) + #expect(recorded[0].uri == "/api/v1/vmm.ping") + #expect(recorded[0].body.isEmpty) + #expect(result.version == "v40.0") + #expect(result.pid == 12345) + } + + // MARK: - vmmShutdown + + @Test("vmmShutdown sends PUT /api/v1/vmm.shutdown and returns without throwing") + func vmmShutdown() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + try await client.vmmShutdown() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vmm.shutdown") + } + + // MARK: - vmmInfo + + @Test("vmmInfo sends GET /api/v1/vmm.info and decodes VmmInfo") + func vmmInfo() async throws { + let expected = CloudHypervisor.VmmInfo(version: "v40.0", pid: 99) + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + (try? StubResponse.json(expected)) ?? StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + let result = try await client.vmmInfo() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .GET) + #expect(recorded[0].uri == "/api/v1/vmm.info") + #expect(result.version == "v40.0") + } + + // MARK: - vmCreate + + @Test("vmCreate sends PUT /api/v1/vm.create with encoded body") + func vmCreate() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let config = CloudHypervisor.VmConfig( + cpus: .init(bootVcpus: 2, maxVcpus: 4), + memory: .init(size: 512 * 1024 * 1024), + payload: .init(kernel: "/boot/vmlinux"), + console: .init(mode: .Null), + serial: .init(mode: .Tty) + ) + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + try await client.vmCreate(config) + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.create") + + let decoded = try JSONDecoder().decode(CloudHypervisor.VmConfig.self, from: recorded[0].body) + #expect(decoded == config) + } + + // MARK: - vmBoot + + @Test("vmBoot sends PUT /api/v1/vm.boot with no body") + func vmBoot() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + try await client.vmBoot() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.boot") + #expect(recorded[0].body.isEmpty) + } + + // Regression: cloud-hypervisor's HTTP parser rejects body-less PUTs + // unless they carry an explicit `Content-Length: 0`. With the + // AsyncHTTPClient transport, that wire shape is produced by + // assigning `request.body = .bytes(ByteBuffer())` so AHC's + // RequestValidation re-derives framing as `known(0)` per RFC 7230 + // §3.3.2. This test asserts the on-the-wire result rather than how + // it's produced, so any future transport change that drops the + // empty-body framing surfaces here. + @Test("Body-less PUT sends Content-Length: 0 with empty body") + func bodylessPUTSendsContentLengthZero() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client( + socketPath: URL(filePath: server.socketPath), + eventLoopGroup: Self.group + ) + try await client.vmBoot() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + let req = try #require(recorded.first) + #expect(req.method == .PUT) + #expect(req.uri == "/api/v1/vm.boot") + #expect(req.body.isEmpty) + #expect(req.headers["Content-Length"].first == "0") + } + + // MARK: - vmShutdown + + @Test("vmShutdown sends PUT /api/v1/vm.shutdown with no body") + func vmShutdown() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + try await client.vmShutdown() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.shutdown") + #expect(recorded[0].body.isEmpty) + } + + // MARK: - vmInfo + + @Test("vmInfo sends GET /api/v1/vm.info and decodes VmInfo") + func vmInfo() async throws { + let expectedConfig = CloudHypervisor.VmConfig( + cpus: .init(bootVcpus: 1, maxVcpus: 1), + memory: .init(size: 256 * 1024 * 1024), + payload: .init(kernel: "/boot/vmlinux"), + console: .init(mode: .Null), + serial: .init(mode: .Null) + ) + let expected = CloudHypervisor.VmInfo(config: expectedConfig, state: .Running) + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + (try? StubResponse.json(expected)) ?? StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + let result = try await client.vmInfo() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .GET) + #expect(recorded[0].uri == "/api/v1/vm.info") + #expect(recorded[0].body.isEmpty) + #expect(result == expected) + } + + // MARK: - vmPause + + @Test("vmPause sends PUT /api/v1/vm.pause with no body") + func vmPause() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + try await client.vmPause() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.pause") + #expect(recorded[0].body.isEmpty) + } + + // MARK: - vmResume + + @Test("vmResume sends PUT /api/v1/vm.resume with no body") + func vmResume() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + try await client.vmResume() + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.resume") + #expect(recorded[0].body.isEmpty) + } + + // MARK: - vmAddDisk + + @Test("vmAddDisk sends PUT /api/v1/vm.add-disk and returns PciDeviceInfo") + func vmAddDisk() async throws { + let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0") + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + (try? StubResponse.json(pciInfo)) ?? StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let config = CloudHypervisor.DiskConfig(path: "/tmp/disk.img", readonly: true, id: "_disk0") + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + let result = try await client.vmAddDisk(config) + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.add-disk") + + let decoded = try JSONDecoder().decode(CloudHypervisor.DiskConfig.self, from: recorded[0].body) + #expect(decoded == config) + #expect(result == pciInfo) + } + + // MARK: - vmAddFs + + @Test("vmAddFs sends PUT /api/v1/vm.add-fs and returns PciDeviceInfo") + func vmAddFs() async throws { + let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0") + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + (try? StubResponse.json(pciInfo)) ?? StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let config = CloudHypervisor.FsConfig(tag: "myfs", socket: "/tmp/virtiofsd.sock", id: "_fs0") + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + let result = try await client.vmAddFs(config) + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.add-fs") + + let decoded = try JSONDecoder().decode(CloudHypervisor.FsConfig.self, from: recorded[0].body) + #expect(decoded == config) + #expect(result == pciInfo) + } + + // MARK: - vmAddNet + + @Test("vmAddNet sends PUT /api/v1/vm.add-net and returns PciDeviceInfo") + func vmAddNet() async throws { + let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0") + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + (try? StubResponse.json(pciInfo)) ?? StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let config = CloudHypervisor.NetConfig(tap: "tap0", mac: "AA:BB:CC:DD:EE:FF", id: "_net0") + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + let result = try await client.vmAddNet(config) + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.add-net") + + let decoded = try JSONDecoder().decode(CloudHypervisor.NetConfig.self, from: recorded[0].body) + #expect(decoded == config) + #expect(result == pciInfo) + } + + // MARK: - vmAddVsock + + @Test("vmAddVsock sends PUT /api/v1/vm.add-vsock and returns PciDeviceInfo") + func vmAddVsock() async throws { + let pciInfo = CloudHypervisor.PciDeviceInfo(id: "_disk0", bdf: "0000:00:01.0") + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + (try? StubResponse.json(pciInfo)) ?? StubResponse.ok() + } + defer { Task { try? await server.shutdown() } } + + let config = CloudHypervisor.VsockConfig(cid: 3, socket: "/tmp/vsock.sock", id: "_vsock0") + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + let result = try await client.vmAddVsock(config) + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.add-vsock") + + let decoded = try JSONDecoder().decode(CloudHypervisor.VsockConfig.self, from: recorded[0].body) + #expect(decoded == config) + #expect(result == pciInfo) + } + + // MARK: - vmRemoveDevice + + @Test("vmRemoveDevice sends PUT /api/v1/vm.remove-device with id body") + func vmRemoveDevice() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let client = try CloudHypervisor.Client(socketPath: URL(filePath: server.socketPath), eventLoopGroup: Self.group) + try await client.vmRemoveDevice(id: "_disk0") + + let recorded = server.recordedRequests() + #expect(recorded.count == 1) + #expect(recorded[0].method == .PUT) + #expect(recorded[0].uri == "/api/v1/vm.remove-device") + + struct RemoveRequest: Decodable { let id: String } + let decoded = try JSONDecoder().decode(RemoveRequest.self, from: recorded[0].body) + #expect(decoded.id == "_disk0") + } + + // MARK: - Malformed JSON + + @Test("Malformed JSON on 200 response throws .decoding") + func malformedJSONThrowsDecoding() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.ok(Data("not json".utf8)) + } + defer { Task { try? await server.shutdown() } } + + let socketURL = URL(filePath: server.socketPath) + let client = try CloudHypervisor.Client(socketPath: socketURL, eventLoopGroup: Self.group) + + struct Dummy: Decodable, Sendable {} + + do { + let _: Dummy = try await client.get("/api/v1/vmm.info") + Issue.record("Expected .decoding error but call succeeded") + } catch let err as CloudHypervisor.Error { + guard case .decoding = err else { + Issue.record("Expected .decoding, got \(err)") + return + } + // Expected path — decoding error correctly surfaced. + } catch { + Issue.record("Expected CloudHypervisor.Error but got \(error)") + } + } + + // MARK: - Shutdown ordering + + /// Regression: with a caller-supplied group, `Client.shutdown()` must + /// drain the underlying HTTPClient before the caller tears the group + /// down. Without this, AsyncHTTPClient's deferred connection-cleanup + /// runs on the (now-dead) event loops and SwiftNIO prints + /// "Cannot schedule tasks on an EventLoop that has already shut down". + /// The singleton group used by the rest of this suite can't surface + /// the bug because it never shuts down, so we spin up a dedicated + /// group for the client here. The server stays on the singleton so + /// only the client-side AHC channels are at risk when we shut the + /// owned group down — otherwise the server's own pipeline cleanup + /// would race the same group teardown and confound the test. + @Test("Client.shutdown drains HTTPClient before a caller-owned group is torn down") + func shutdownDrainsHTTPClientBeforeGroup() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + let clientGroup = MultiThreadedEventLoopGroup(numberOfThreads: 2) + let client = try CloudHypervisor.Client( + socketPath: URL(filePath: server.socketPath), + eventLoopGroup: clientGroup + ) + // Round-trip a real request so AHC actually opens a connection + // and parks its post-response cleanup on `clientGroup`. + try await client.vmmShutdown() + + try await client.shutdown() + // Idempotent — a second call must not throw. + try await client.shutdown() + + // The owned group should now be safe to tear down without NIO + // warnings. + try await clientGroup.shutdownGracefully() + } + + @Test("Client.shutdown also tears down the group when the client owns it") + func shutdownOwnsGroup() async throws { + let server = try await StubHTTPServer(eventLoopGroup: Self.group) { _ in + StubResponse.status(.noContent) + } + defer { Task { try? await server.shutdown() } } + + // No eventLoopGroup → client owns its own. + let client = try CloudHypervisor.Client( + socketPath: URL(filePath: server.socketPath) + ) + try await client.vmmShutdown() + try await client.shutdown() + // Idempotent. + try await client.shutdown() + } +} diff --git a/Tests/CloudHypervisorTests/ErrorsTests.swift b/Tests/CloudHypervisorTests/ErrorsTests.swift new file mode 100644 index 000000000..4674222c7 --- /dev/null +++ b/Tests/CloudHypervisorTests/ErrorsTests.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// 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 NIOHTTP1 +import Testing + +@testable import CloudHypervisor + +@Suite("CloudHypervisor.Error") +struct ErrorsTests { + @Test("http case carries status and body") + func httpCase() { + let err = CloudHypervisor.Error.http(status: .badRequest, body: Data("nope".utf8)) + guard case .http(let status, let body) = err else { + Issue.record("expected .http") + return + } + #expect(status == .badRequest) + #expect(String(data: body, encoding: .utf8) == "nope") + } +} diff --git a/Tests/CloudHypervisorTests/StubHTTPServer.swift b/Tests/CloudHypervisorTests/StubHTTPServer.swift new file mode 100644 index 000000000..9ea545afd --- /dev/null +++ b/Tests/CloudHypervisorTests/StubHTTPServer.swift @@ -0,0 +1,203 @@ +//===----------------------------------------------------------------------===// +// 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 NIOConcurrencyHelpers +import NIOCore +import NIOHTTP1 +import NIOPosix + +// MARK: - StubRequest / StubResponse + +/// An inbound HTTP request captured by the stub server. +struct StubRequest: Sendable { + let method: HTTPMethod + let uri: String + let body: Data + let headers: HTTPHeaders +} + +/// A canned HTTP response produced by the stub server. +struct StubResponse: Sendable { + let status: HTTPResponseStatus + let body: Data + let headers: HTTPHeaders + + static func ok(_ body: Data = .init()) -> StubResponse { + StubResponse(status: .ok, body: body, headers: [:]) + } + + static func json(_ value: T) throws -> StubResponse { + let data = try JSONEncoder().encode(value) + var headers = HTTPHeaders() + headers.add(name: "Content-Type", value: "application/json") + return StubResponse(status: .ok, body: data, headers: headers) + } + + static func status(_ status: HTTPResponseStatus, body: Data = .init()) -> StubResponse { + StubResponse(status: status, body: body, headers: [:]) + } +} + +// MARK: - StubHTTPServer + +/// An in-process HTTP/1.1 server bound to a Unix Domain Socket, used in tests. +/// +/// Example: +/// ```swift +/// let server = try await StubHTTPServer(eventLoopGroup: group) { req in +/// return StubResponse.ok(Data("{}".utf8)) +/// } +/// defer { Task { try? await server.shutdown() } } +/// ``` +final class StubHTTPServer: Sendable { + /// The path to the Unix Domain Socket this server is bound to. + let socketPath: String + + private let channel: Channel + /// Recorded requests, protected by a lock so the test thread can read safely. + private let requests: NIOLockedValueBox<[StubRequest]> + + init( + eventLoopGroup: any EventLoopGroup, + handler: @escaping @Sendable (StubRequest) -> StubResponse + ) async throws { + let sockPath = FileManager.default.temporaryDirectory + .appendingPathComponent("ch-stub-\(UUID().uuidString).sock") + .path + + let requestsBox = NIOLockedValueBox<[StubRequest]>([]) + + let bootstrap = ServerBootstrap(group: eventLoopGroup) + .serverChannelOption(.backlog, value: 256) + .serverChannelOption(.socketOption(.so_reuseaddr), value: 1) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.configureHTTPServerPipeline( + withPipeliningAssistance: false + ) + try channel.pipeline.syncOperations.addHandler( + StubRequestHandler(userHandler: handler, requests: requestsBox) + ) + } + } + + let boundChannel = + try await bootstrap + .bind(unixDomainSocketPath: sockPath, cleanupExistingSocketFile: true) + .get() + + self.socketPath = sockPath + self.channel = boundChannel + self.requests = requestsBox + } + + /// Stop accepting connections and close the listening socket. + func shutdown() async throws { + try await channel.close().get() + try? FileManager.default.removeItem(atPath: socketPath) + } + + /// Returns all requests recorded so far. + func recordedRequests() -> [StubRequest] { + requests.withLockedValue { $0 } + } +} + +// MARK: - StubRequestHandler + +/// Handles a single inbound HTTP/1.1 request, invokes the user handler, and +/// writes the stub response. +/// +/// All ChannelHandler callbacks run on the channel's event loop, so the mutable +/// inbound-state fields need no external synchronisation. The shared `requests` +/// box is still locked because the test thread reads it from outside the loop. +private final class StubRequestHandler: ChannelInboundHandler, @unchecked Sendable { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + private let userHandler: @Sendable (StubRequest) -> StubResponse + private let requests: NIOLockedValueBox<[StubRequest]> + + // Mutable inbound state — only touched on the event loop. + private var pendingMethod: HTTPMethod? + private var pendingURI: String? + private var pendingHeaders: HTTPHeaders = [:] + private var pendingBody: [UInt8] = [] + + init( + userHandler: @escaping @Sendable (StubRequest) -> StubResponse, + requests: NIOLockedValueBox<[StubRequest]> + ) { + self.userHandler = userHandler + self.requests = requests + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch unwrapInboundIn(data) { + case .head(let head): + pendingMethod = head.method + pendingURI = head.uri + pendingHeaders = head.headers + pendingBody = [] + case .body(var buf): + if let bytes = buf.readBytes(length: buf.readableBytes) { + pendingBody.append(contentsOf: bytes) + } + case .end: + guard let method = pendingMethod, let uri = pendingURI else { + context.close(promise: nil) + return + } + let request = StubRequest( + method: method, + uri: uri, + body: Data(pendingBody), + headers: pendingHeaders + ) + requests.withLockedValue { $0.append(request) } + let stubResp = userHandler(request) + writeResponse(context: context, response: stubResp) + } + } + + private func writeResponse(context: ChannelHandlerContext, response: StubResponse) { + var respHeaders = response.headers + respHeaders.replaceOrAdd(name: "Content-Length", value: "\(response.body.count)") + respHeaders.replaceOrAdd(name: "Connection", value: "close") + + let head = HTTPResponseHead(version: .http1_1, status: response.status, headers: respHeaders) + context.write(wrapOutboundOut(.head(head)), promise: nil) + + if !response.body.isEmpty { + var buf = context.channel.allocator.buffer(capacity: response.body.count) + buf.writeBytes(response.body) + context.write(wrapOutboundOut(.body(.byteBuffer(buf))), promise: nil) + } + + // Use NIOLoopBound to safely capture `context` in a @Sendable closure. + // The bound asserts event-loop access; the close runs on the same loop + // as the flush completion, which is correct. + let boundContext = NIOLoopBound(context, eventLoop: context.eventLoop) + context.writeAndFlush(wrapOutboundOut(.end(nil))).whenComplete { _ in + boundContext.value.close(promise: nil) + } + } + + func errorCaught(context: ChannelHandlerContext, error: any Error) { + context.close(promise: nil) + } +} diff --git a/Tests/CloudHypervisorTests/TypesTests.swift b/Tests/CloudHypervisorTests/TypesTests.swift new file mode 100644 index 000000000..8826643d1 --- /dev/null +++ b/Tests/CloudHypervisorTests/TypesTests.swift @@ -0,0 +1,328 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import CloudHypervisor + +@Suite("CloudHypervisor types") +struct TypesTests { + @Test("VmConfig round-trips through JSON") + func vmConfigRoundTrip() throws { + let cfg = CloudHypervisor.VmConfig( + cpus: CloudHypervisor.CpusConfig(bootVcpus: 2, maxVcpus: 2), + memory: CloudHypervisor.MemoryConfig(size: UInt64(1) << 30), + payload: CloudHypervisor.PayloadConfig( + kernel: "/path/to/vmlinux", + cmdline: "init=/sbin/vminitd ro" + ), + disks: nil, + net: nil, + fs: nil, + vsock: nil, + console: CloudHypervisor.ConsoleConfig(mode: .Null), + serial: CloudHypervisor.ConsoleConfig(mode: .Null) + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.VmConfig.self, from: data) + #expect(decoded == cfg) + + // Verify snake_case keys are emitted. + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"boot_vcpus\"")) + #expect(jsonString.contains("\"max_vcpus\"")) + } + + @Test("CpusConfig round-trips through JSON") + func cpusConfigRoundTrip() throws { + let cfg = CloudHypervisor.CpusConfig(bootVcpus: 4, maxVcpus: 8) + let data = try JSONEncoder().encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.CpusConfig.self, from: data) + #expect(decoded == cfg) + } + + @Test("MemoryConfig round-trips through JSON") + func memoryConfigRoundTrip() throws { + let cfg = CloudHypervisor.MemoryConfig(size: UInt64(2) << 30, hotplugSize: UInt64(1) << 30, mergeable: true) + let data = try JSONEncoder().encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.MemoryConfig.self, from: data) + #expect(decoded == cfg) + } + + @Test("MemoryConfig omits nil optional fields from JSON") + func memoryConfigNilOmission() throws { + let cfg = CloudHypervisor.MemoryConfig(size: UInt64(1) << 30) + let data = try JSONEncoder().encode(cfg) + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(!jsonString.contains("\"hotplug_size\"")) + #expect(!jsonString.contains("\"mergeable\"")) + } + + @Test("PayloadConfig round-trips through JSON") + func payloadConfigRoundTrip() throws { + let cfg = CloudHypervisor.PayloadConfig( + kernel: "/boot/vmlinux", + initramfs: "/boot/initrd", + cmdline: "console=ttyS0" + ) + let data = try JSONEncoder().encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.PayloadConfig.self, from: data) + #expect(decoded == cfg) + } + + @Test("ConsoleConfig round-trips through JSON with capitalized mode strings") + func consoleConfigRoundTrip() throws { + for mode in [ + CloudHypervisor.ConsoleConfig.Mode.Off, + .Pty, + .Tty, + .File, + .Socket, + .Null, + ] { + let cfg = CloudHypervisor.ConsoleConfig(mode: mode) + let data = try JSONEncoder().encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.ConsoleConfig.self, from: data) + #expect(decoded == cfg) + // CH uses capitalized strings: "Off", "Pty", etc. + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"" + mode.rawValue + "\"")) + } + } + + @Test("DiskConfig round-trips through JSON") + func diskConfigRoundTrip() throws { + let cfg = CloudHypervisor.DiskConfig( + path: "/var/lib/disk.raw", + readonly: true, + direct: false, + iommu: nil, + id: "disk0", + pciSegment: 0 + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.DiskConfig.self, from: data) + #expect(decoded == cfg) + + // Verify snake_case key for pci_segment. + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"pci_segment\"")) + } + + @Test("DiskConfig omits nil optional fields from JSON") + func diskConfigNilOmission() throws { + let cfg = CloudHypervisor.DiskConfig(path: "/var/lib/disk.raw") + let data = try JSONEncoder().encode(cfg) + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(!jsonString.contains("\"readonly\"")) + #expect(!jsonString.contains("\"direct\"")) + #expect(!jsonString.contains("\"iommu\"")) + #expect(!jsonString.contains("\"id\"")) + #expect(!jsonString.contains("\"pci_segment\"")) + } + + @Test("NetConfig round-trips through JSON") + func netConfigRoundTrip() throws { + let cfg = CloudHypervisor.NetConfig( + tap: "tap0", + ip: "192.168.0.1", + mask: "255.255.255.0", + mac: "AA:BB:CC:DD:EE:FF", + mtu: 1500, + numQueues: 2, + queueSize: 256, + id: "net0" + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.NetConfig.self, from: data) + #expect(decoded == cfg) + + // Verify snake_case keys. + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"num_queues\"")) + #expect(jsonString.contains("\"queue_size\"")) + } + + @Test("FsConfig round-trips through JSON") + func fsConfigRoundTrip() throws { + let cfg = CloudHypervisor.FsConfig( + tag: "virtiofs0", + socket: "/run/virtiofs.sock", + numQueues: 1, + queueSize: 1024, + id: "fs0", + pciSegment: nil + ) + let data = try JSONEncoder().encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.FsConfig.self, from: data) + #expect(decoded == cfg) + } + + @Test("VsockConfig round-trips through JSON") + func vsockConfigRoundTrip() throws { + let cfg = CloudHypervisor.VsockConfig( + cid: 3, + socket: "/run/vsock.sock", + iommu: false, + id: "vsock0" + ) + let data = try JSONEncoder().encode(cfg) + let decoded = try JSONDecoder().decode(CloudHypervisor.VsockConfig.self, from: data) + #expect(decoded == cfg) + } + + @Test("PciDeviceInfo round-trips through JSON") + func pciDeviceInfoRoundTrip() throws { + let info = CloudHypervisor.PciDeviceInfo(id: "disk0", bdf: "0000:00:03.0") + let data = try JSONEncoder().encode(info) + let decoded = try JSONDecoder().decode(CloudHypervisor.PciDeviceInfo.self, from: data) + #expect(decoded == info) + } + + // MARK: - VmInfo / VmState + + @Test("VmState round-trips through JSON with CH literal strings") + func vmStateRoundTrip() throws { + for state in [ + CloudHypervisor.VmState.Created, + .Running, + .Shutdown, + .Paused, + .BreakPoint, + ] { + let data = try JSONEncoder().encode(state) + let decoded = try JSONDecoder().decode(CloudHypervisor.VmState.self, from: data) + #expect(decoded == state) + // CH uses the capitalized raw string literals exactly. + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"" + state.rawValue + "\"")) + } + } + + @Test("VmInfo round-trips through JSON") + func vmInfoRoundTrip() throws { + let cfg = CloudHypervisor.VmConfig( + cpus: CloudHypervisor.CpusConfig(bootVcpus: 2, maxVcpus: 2), + memory: CloudHypervisor.MemoryConfig(size: UInt64(1) << 30), + payload: CloudHypervisor.PayloadConfig(kernel: "/boot/vmlinux"), + console: CloudHypervisor.ConsoleConfig(mode: .Null), + serial: CloudHypervisor.ConsoleConfig(mode: .Null) + ) + let info = CloudHypervisor.VmInfo( + config: cfg, + state: .Running, + memoryActualSize: 1_073_741_824 + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(info) + let decoded = try JSONDecoder().decode(CloudHypervisor.VmInfo.self, from: data) + #expect(decoded == info) + + // Verify snake_case key is emitted. + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"memory_actual_size\"")) + } + + @Test("VmInfo omits nil optional fields from JSON") + func vmInfoNilOmission() throws { + let cfg = CloudHypervisor.VmConfig( + cpus: CloudHypervisor.CpusConfig(bootVcpus: 1, maxVcpus: 1), + memory: CloudHypervisor.MemoryConfig(size: UInt64(512) << 20), + payload: CloudHypervisor.PayloadConfig(kernel: "/boot/vmlinux"), + console: CloudHypervisor.ConsoleConfig(mode: .Off), + serial: CloudHypervisor.ConsoleConfig(mode: .Off) + ) + let info = CloudHypervisor.VmInfo(config: cfg, state: .Created) + let data = try JSONEncoder().encode(info) + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(!jsonString.contains("\"memory_actual_size\"")) + } + + // MARK: - VmmPingResponse + + @Test("VmmPingResponse round-trips through JSON") + func vmmPingResponseRoundTrip() throws { + let ping = CloudHypervisor.VmmPingResponse( + version: "v40.0", + pid: 12345, + features: ["acpi", "kvm"], + buildVersion: "abc123" + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(ping) + let decoded = try JSONDecoder().decode(CloudHypervisor.VmmPingResponse.self, from: data) + #expect(decoded == ping) + + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"build_version\"")) + } + + @Test("VmmPingResponse omits nil optional fields from JSON") + func vmmPingResponseNilOmission() throws { + let ping = CloudHypervisor.VmmPingResponse(version: "v40.0") + let data = try JSONEncoder().encode(ping) + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(!jsonString.contains("\"pid\"")) + #expect(!jsonString.contains("\"features\"")) + #expect(!jsonString.contains("\"build_version\"")) + } + + // MARK: - VmmInfo + + @Test("VmmInfo round-trips through JSON") + func vmmInfoRoundTrip() throws { + let cfg = CloudHypervisor.VmConfig( + cpus: CloudHypervisor.CpusConfig(bootVcpus: 2, maxVcpus: 2), + memory: CloudHypervisor.MemoryConfig(size: UInt64(1) << 30), + payload: CloudHypervisor.PayloadConfig(kernel: "/boot/vmlinux"), + console: CloudHypervisor.ConsoleConfig(mode: .Null), + serial: CloudHypervisor.ConsoleConfig(mode: .Null) + ) + let vmmInfo = CloudHypervisor.VmmInfo( + version: "v40.0", + pid: 99, + buildVersion: "deadbeef", + config: cfg + ) + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys] + let data = try encoder.encode(vmmInfo) + let decoded = try JSONDecoder().decode(CloudHypervisor.VmmInfo.self, from: data) + #expect(decoded == vmmInfo) + + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(jsonString.contains("\"build_version\"")) + } + + @Test("VmmInfo omits nil optional fields from JSON") + func vmmInfoNilOmission() throws { + let vmmInfo = CloudHypervisor.VmmInfo(version: "v40.0") + let data = try JSONEncoder().encode(vmmInfo) + let jsonString = try #require(String(data: data, encoding: .utf8)) + #expect(!jsonString.contains("\"pid\"")) + #expect(!jsonString.contains("\"build_version\"")) + #expect(!jsonString.contains("\"config\"")) + } +} diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift index ab570520e..37c6b3ad9 100644 --- a/Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Format+Link.swift @@ -43,6 +43,25 @@ struct Ext4FormatLinkTests { #expect(try EXT4.EXT4Reader(blockDevice: afterUnlink).stat("/original").inode.linksCount == 1) } + @Test func hardlinkCreatesMissingParents() throws { + let path = FilePath( + FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString, isDirectory: false)) + defer { try? FileManager.default.removeItem(at: path.url) } + let fmt = try EXT4.Formatter(path, minDiskSize: 32.kib()) + try fmt.create(path: "/original", mode: EXT4.Inode.Mode(.S_IFREG, 0o755), buf: nil) + // Parent dirs /a and /a/b do not exist yet; link must create them implicitly. + try fmt.link(link: "/a/b/hardlink", target: "/original") + try fmt.close() + + let reader = try EXT4.EXT4Reader(blockDevice: path) + #expect(try reader.stat("/a").inode.mode.isDir()) + #expect(try reader.stat("/a/b").inode.mode.isDir()) + let target = try reader.stat("/original") + #expect(try reader.stat("/a/b/hardlink").inodeNumber == target.inodeNumber) + #expect(target.inode.linksCount == 2) + } + @Test func unlinkFirstInodeFreesInode() throws { let emptyPath = FilePath(FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString, isDirectory: false)) defer { try? FileManager.default.removeItem(at: emptyPath.url) } diff --git a/Tests/ContainerizationEXT4Tests/TestEXT4Reader+IO.swift b/Tests/ContainerizationEXT4Tests/TestEXT4Reader+IO.swift index 40d241d27..37b775669 100644 --- a/Tests/ContainerizationEXT4Tests/TestEXT4Reader+IO.swift +++ b/Tests/ContainerizationEXT4Tests/TestEXT4Reader+IO.swift @@ -617,10 +617,10 @@ struct EXT4PathIOTests { let tree = EXT4.FileTree(EXT4.RootInode, "/") let dirPtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 3, name: "dir", parent: tree.root)) - tree.root.pointee.children.append(dirPtr) + tree.root.pointee.addChild(dirPtr) let filePtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 4, name: "file", parent: dirPtr)) - dirPtr.pointee.children.append(filePtr) + dirPtr.pointee.addChild(filePtr) #expect(dirPtr.pointee.path == FilePath("/dir")) #expect(filePtr.pointee.path == FilePath("/dir/file")) @@ -631,10 +631,10 @@ struct EXT4PathIOTests { let tree = EXT4.FileTree(EXT4.RootInode, ".") let dirPtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 3, name: "dir", parent: tree.root)) - tree.root.pointee.children.append(dirPtr) + tree.root.pointee.addChild(dirPtr) let filePtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 4, name: "file", parent: dirPtr)) - dirPtr.pointee.children.append(filePtr) + dirPtr.pointee.addChild(filePtr) #expect(dirPtr.pointee.path == FilePath("dir")) #expect(filePtr.pointee.path == FilePath("dir/file")) @@ -645,7 +645,7 @@ struct EXT4PathIOTests { let tree = EXT4.FileTree(EXT4.RootInode, "dir") let filePtr = EXT4.Ptr(EXT4.FileTree.FileTreeNode(inode: 3, name: "file", parent: tree.root)) - tree.root.pointee.children.append(filePtr) + tree.root.pointee.addChild(filePtr) #expect(filePtr.pointee.path == FilePath("dir/file")) } diff --git a/Tests/ContainerizationEXT4Tests/TestFormatterUnpack.swift b/Tests/ContainerizationEXT4Tests/TestFormatterUnpack.swift index ee4e4ac74..3b8590f91 100644 --- a/Tests/ContainerizationEXT4Tests/TestFormatterUnpack.swift +++ b/Tests/ContainerizationEXT4Tests/TestFormatterUnpack.swift @@ -361,6 +361,97 @@ struct UnpackProgressTest { let childNames = Set(children.map { $0.0 }) #expect(childNames.contains("test"), "Directory 'test' should exist in unpacked filesystem") } + + @Test func unpackCreatesImplicitParentsForHardlink() async throws { + // A hardlink whose parent dir has no explicit archive entry must unpack: the + // missing parents are created implicitly instead of failing with "... not found". + // This is the exact repro shape (e.g. Bazel rules_img runfiles trees). + let tempDir = FileManager.default.uniqueTemporaryDirectory() + let archivePath = tempDir.appendingPathComponent("hardlink.tar.gz", isDirectory: false) + let fsPath = FilePath(tempDir.appendingPathComponent("hardlink.ext4.img", isDirectory: false)) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let archiver = try ArchiveWriter( + configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip)) + try archiver.open(file: archivePath) + // The hardlink target. /bin itself has no explicit dir entry either. + let payload = Data("hello".utf8) + try archiver.writeEntry( + entry: WriteEntry.file(path: "/bin/app", permissions: 0o755, size: Int64(payload.count)), + data: payload) + // The parent dir /bin/app.runfiles/_main/app_ has no explicit archive entry. + try archiver.writeEntry( + entry: WriteEntry.hardlink(path: "/bin/app.runfiles/_main/app_/app", target: "/bin/app"), + data: nil) + try archiver.finishEncoding() + + let formatter = try EXT4.Formatter(fsPath) + try await formatter.unpack(source: archivePath) // must not throw notFound + try formatter.close() + + let reader = try EXT4.EXT4Reader(blockDevice: fsPath) + // Implicitly-created parent directories exist and are directories. + #expect(try reader.stat("/bin/app.runfiles").inode.mode.isDir()) + #expect(try reader.stat("/bin/app.runfiles/_main").inode.mode.isDir()) + #expect(try reader.stat("/bin/app.runfiles/_main/app_").inode.mode.isDir()) + // Hardlink resolves to the target inode and bumps the link count to 2. + let target = try reader.stat("/bin/app") + let hardlink = try reader.stat("/bin/app.runfiles/_main/app_/app") + #expect(hardlink.inodeNumber == target.inodeNumber) + #expect(target.inode.linksCount == 2) + } + + @Test func unpackCreatesImplicitParentsForSymlink() async throws { + // A symlink whose parent dir has no explicit archive entry must unpack. + let tempDir = FileManager.default.uniqueTemporaryDirectory() + let archivePath = tempDir.appendingPathComponent("symlink.tar.gz", isDirectory: false) + let fsPath = FilePath(tempDir.appendingPathComponent("symlink.ext4.img", isDirectory: false)) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let archiver = try ArchiveWriter( + configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip)) + try archiver.open(file: archivePath) + // The parent dir /etc/links has no explicit archive entry. + try archiver.writeEntry( + entry: WriteEntry.link(path: "/etc/links/cur", permissions: 0o777, target: "/bin/app"), + data: nil) + try archiver.finishEncoding() + + let formatter = try EXT4.Formatter(fsPath) + try await formatter.unpack(source: archivePath) // must not throw notFound + try formatter.close() + + let reader = try EXT4.EXT4Reader(blockDevice: fsPath) + #expect(try reader.stat("/etc/links").inode.mode.isDir()) + #expect(try reader.stat("/etc/links/cur", followSymlinks: false).inode.mode.isLink()) + } + + @Test func unpackCreatesImplicitParentsForRegularFile() async throws { + // A regular file whose parent dir has no explicit archive entry must unpack. + let tempDir = FileManager.default.uniqueTemporaryDirectory() + let archivePath = tempDir.appendingPathComponent("file.tar.gz", isDirectory: false) + let fsPath = FilePath(tempDir.appendingPathComponent("file.ext4.img", isDirectory: false)) + defer { try? FileManager.default.removeItem(at: tempDir) } + + let archiver = try ArchiveWriter( + configuration: ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip)) + try archiver.open(file: archivePath) + // The parent dir /var/lib/data has no explicit archive entry. + let payload = Data("world".utf8) + try archiver.writeEntry( + entry: WriteEntry.file(path: "/var/lib/data/file.txt", permissions: 0o644, size: Int64(payload.count)), + data: payload) + try archiver.finishEncoding() + + let formatter = try EXT4.Formatter(fsPath) + try await formatter.unpack(source: archivePath) // must not throw notFound + try formatter.close() + + let reader = try EXT4.EXT4Reader(blockDevice: fsPath) + #expect(try reader.stat("/var/lib/data").inode.mode.isDir()) + #expect(try reader.stat("/var/lib/data/file.txt").inode.mode.isReg()) + #expect(try reader.readFile(at: "/var/lib/data/file.txt") == payload) + } } extension ContainerizationArchive.WriteEntry { @@ -391,6 +482,14 @@ extension ContainerizationArchive.WriteEntry { entry.symlinkTarget = target return entry } + + static func hardlink(path: String, target: String) -> WriteEntry { + let entry = WriteEntry() + entry.path = path + entry.fileType = .regular + entry.hardlink = target + return entry + } } extension EXT4.EXT4Reader { diff --git a/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift b/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift index d0f3a9566..6c31a0caa 100644 --- a/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift +++ b/Tests/ContainerizationNetlinkTests/NetlinkSessionTest.swift @@ -290,6 +290,46 @@ struct NetlinkSessionTest { #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) } + @Test func testNetworkAddressAddIPv6() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name, truncated response with no attributes (not needed at present). + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add IPv6 address to interface. + let expectedAddRequest = + "2c00000014000506000000000cc00cc0" // Netlink header (16 B): len=44 + + "0a40820002000000" // ifaddrmsg (8 B): AF_INET6, /64, flags=PERMANENT|NODAD, ifindex 2 + + "14000100fd000000000000000000000000000001" // RT attr: IFA_ADDRESS fd00::1 + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "0000000040000000140005060000000000000000" // nlmsg_err payload (20 B) + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.addressAdd(interface: "eth0", ipv6Address: try CIDRv6("fd00::1/64")) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + @Test func testNetworkRouteAddIpLink() throws { let mockSocket = try MockNetlinkSocket() mockSocket.pid = 0xc00c_c00c @@ -389,6 +429,149 @@ struct NetlinkSessionTest { #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) } + @Test func testNetworkRouteAddIpv6Link() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name. + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add IPv6 link route with source. + let expectedAddRequest = + "4c00000018000506000000000cc00cc0" // Netlink header (16 B): len=76 + + "0a400000fe04fd0100000000" // struct rtmsg (12 B): AF_INET6, dst/64, + // table=MAIN(0xfe), proto=STATIC(0x04), scope=LINK(0xfd), type=UNICAST(0x01) + + "14000100fd000000000000000000000000000000" // RTA_DST fd00:: + + "14000700fd000000000000000000000000000001" // RTA_PREFSRC fd00::1 + + "0800040002000000" // RTA_OIF ifindex 2 (eth0) + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "00000000280000001400050600000000" // nlmsg_err payload (16 B) + + "1f000000" + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.routeAdd( + interface: "eth0", + dstIpv6Addr: try CIDRv6("fd00::/64"), + srcIpv6Addr: try IPv6Address("fd00::1") + ) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + mockSocket.requests[1][8..<12] = [0, 0, 0, 0] + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + + @Test func testNetworkRouteAddIpv6LinkWithoutSrc() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name. + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add IPv6 link route without source. + let expectedAddRequest = + "3800000018000506000000000cc00cc0" // Netlink header (16 B): len=56 + + "0a400000fe04fd0100000000" // struct rtmsg (12 B): AF_INET6, dst/64 + + "14000100fd000000000000000000000000000000" // RTA_DST fd00:: + + "0800040002000000" // RTA_OIF ifindex 2 (eth0) + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "00000000280000001400050600000000" // nlmsg_err payload (16 B) + + "1f000000" + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.routeAdd( + interface: "eth0", + dstIpv6Addr: try CIDRv6("fd00::/64"), + srcIpv6Addr: nil + ) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + mockSocket.requests[1][8..<12] = [0, 0, 0, 0] + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + + @Test func testNetworkRouteAddDefaultIpv6() throws { + let mockSocket = try MockNetlinkSocket() + mockSocket.pid = 0xc00c_c00c + + // Lookup interface by name. + let expectedLookupRequest = + "3400000012000100000000000cc00cc0" // Netlink header (16 B) + + "110000000000000001000000ffffffff" // struct ifinfomsg (16 B) + + "08001d00090000000c0003006574683000000000" // RT attrs: IFLA_EXT_MASK + IFLA_IFNAME ("eth0") + mockSocket.responses.append( + [UInt8]( + hex: + "2000000010000000000000000cc00cc0" // Netlink header (16 B) + + "00000100020000004310010000000000" // struct ifinfomsg (16 B) – no attributes + ) + ) + + // Add default IPv6 route via gateway. + let expectedAddRequest = + "3800000018000506000000000cc00cc0" // Netlink header (16 B): len=56 + + "0a000000fe03000100000000" // struct rtmsg (12 B): AF_INET6, dst/0, + // table=MAIN(0xfe), proto=BOOT(0x03), scope=UNIVERSE(0x00), type=UNICAST(0x01) + + "14000500fd000000000000000000000000000001" // RTA_GATEWAY fd00::1 + + "0800040002000000" // RTA_OIF ifindex 2 (eth0) + mockSocket.responses.append( + [UInt8]( + hex: + "2400000002000001000000000cc00cc0" // Netlink header (16 B) + + "00000000280000001400050600000000" // nlmsg_err payload (16 B) + + "1f000000" + ) + ) + + let session = NetlinkSession(socket: mockSocket) + try session.routeAddDefault( + interface: "eth0", + ipv6Gateway: try IPv6Address("fd00::1") + ) + + #expect(mockSocket.requests.count == 2) + #expect(mockSocket.responseIndex == 2) + mockSocket.requests[0][8..<12] = [0, 0, 0, 0] + #expect(expectedLookupRequest == mockSocket.requests[0].hexEncodedString()) + mockSocket.requests[1][8..<12] = [0, 0, 0, 0] + #expect(expectedAddRequest == mockSocket.requests[1].hexEncodedString()) + } + @Test func testNetworkLinkGetMultipleMessagesInSingleBuffer() throws { let mockSocket = try MockNetlinkSocket() mockSocket.pid = 0x8765_4321 diff --git a/Tests/ContainerizationOCITests/OCIPlatformTests.swift b/Tests/ContainerizationOCITests/OCIPlatformTests.swift index 8977a526c..b118dac59 100644 --- a/Tests/ContainerizationOCITests/OCIPlatformTests.swift +++ b/Tests/ContainerizationOCITests/OCIPlatformTests.swift @@ -33,7 +33,7 @@ struct OCIPlatformTests { @Test func differentOS() { let lhs = Platform(arch: "arm64", os: "linux") - let rhs = Platform(arch: "arm64", os: "darwin") + let rhs = Platform(arch: "arm64", os: "windows") #expect(lhs != rhs, "Different OS should not be equal") } @@ -66,4 +66,89 @@ struct OCIPlatformTests { let rhs = Platform(arch: "arm64", os: "linux", variant: nil) #expect(lhs == rhs, "Both nil variants => variantEqual is true => overall equal") } + + @Test func arm64_nilAndV8_sameHashValue() { + let withoutVariant = Platform(arch: "arm64", os: "linux", variant: nil) + let withV8 = Platform(arch: "arm64", os: "linux", variant: "v8") + // Equal platforms must produce the same hash — violating this breaks Set/Dictionary lookups + #expect(withoutVariant.hashValue == withV8.hashValue, "arm64 nil variant and v8 must hash identically") + } + + @Test func arm64_nilAndV8_setLookup() { + let withoutVariant = Platform(arch: "arm64", os: "linux", variant: nil) + let withV8 = Platform(arch: "arm64", os: "linux", variant: "v8") + var set = Set() + set.insert(withoutVariant) + #expect(set.contains(withV8), "arm64/v8 must be found in a Set that contains arm64 with nil variant") + } + + @Test func arm64_differentOS_nilAndV8() { + let linux = Platform(arch: "arm64", os: "linux", variant: nil) + let windows = Platform(arch: "arm64", os: "windows", variant: "v8") + #expect(linux != windows, "The arm64 nil/v8 variant rule must not ignore a differing OS") + #expect(windows != linux, "The arm64 nil/v8 variant rule must not ignore a differing OS") + } + + @Test func arm64_differentOS_bothV8() { + let linux = Platform(arch: "arm64", os: "linux", variant: "v8") + let windows = Platform(arch: "arm64", os: "windows", variant: "v8") + #expect(linux != windows, "Same arch and variant but different OS => not equal") + } + + @Test func arm64_normalizedArchDifferentOS() { + // aarch64 normalizes to arm64, so both sides hit the arm64 variant rule. + let linux = Platform(arch: "aarch64", os: "linux", variant: nil) + let windows = Platform(arch: "arm64", os: "windows", variant: "v8") + #expect(linux != windows, "Normalized arm64 platforms with a differing OS => not equal") + } + + @Test func arm64_differentOS_setLookup() { + let linux = Platform(arch: "arm64", os: "linux", variant: nil) + let windows = Platform(arch: "arm64", os: "windows", variant: "v8") + var set = Set() + set.insert(linux) + #expect(!set.contains(windows), "windows/arm64/v8 must not be found in a Set holding linux/arm64") + } + + @Test func arm64_platformMatcherDifferentOS() { + let matcher = createPlatformMatcher(for: Platform(arch: "arm64", os: "linux", variant: nil)) + #expect(!matcher(Platform(arch: "arm64", os: "windows", variant: "v8")), "matcher must reject a differing OS") + #expect(matcher(Platform(arch: "arm64", os: "linux", variant: "v8")), "matcher must accept the same OS with an implied v8 variant") + } + + // MARK: - description consistency + + @Test func arm64_nilAndV8_sameDescription() { + let withoutVariant = Platform(arch: "arm64", os: "linux", variant: nil) + let withV8 = Platform(arch: "arm64", os: "linux", variant: "v8") + #expect( + withoutVariant.description == withV8.description, + "equal arm64 platforms must produce the same description" + ) + } + + @Test func arm64_descriptionDropsRedundantV8() { + let withV8 = Platform(arch: "arm64", os: "linux", variant: "v8") + #expect(withV8.description == "linux/arm64", "arm64/v8 is canonical arm64, rendered without the redundant variant") + } + + @Test func arm64_nilVariantDescription() { + let withoutVariant = Platform(arch: "arm64", os: "linux", variant: nil) + #expect(withoutVariant.description == "linux/arm64") + } + + @Test func arm64_fromStringWithV8DescriptionIsCanonical() throws { + let parsed = try Platform(from: "linux/arm64/v8") + #expect(parsed.description == "linux/arm64", "parsing arm64/v8 then describing must yield the canonical short form") + } + + @Test func arm_v7_descriptionKeepsVariant() { + let armv7 = Platform(arch: "arm", os: "linux", variant: "v7") + #expect(armv7.description == "linux/arm/v7", "non-redundant variants such as arm/v7 must be preserved") + } + + @Test func amd64_descriptionUnaffected() { + let amd64 = Platform(arch: "amd64", os: "linux") + #expect(amd64.description == "linux/amd64") + } } diff --git a/Tests/ContainerizationOCITests/SpecRedactionTests.swift b/Tests/ContainerizationOCITests/SpecRedactionTests.swift new file mode 100644 index 000000000..19af840fb --- /dev/null +++ b/Tests/ContainerizationOCITests/SpecRedactionTests.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerizationOCI + +@Suite("Spec redaction") +struct SpecRedactionTests { + private static let secret = "hunter2" + private static let hookSecret = "abc123" + + private func spec() -> Spec { + Spec( + hooks: Hooks( + prestart: [], + createRuntime: [], + createContainer: [], + startContainer: [], + poststart: [Hook(path: "/hook", args: [], env: ["HOOK_TOKEN=\(Self.hookSecret)"], timeout: nil)], + poststop: [] + ), + process: Process(env: ["PATH=/usr/bin", "PASSWORD=\(Self.secret)", "INHERIT_ME"]) + ) + } + + // The property that matters: a whole spec interpolated into a log line + // must not carry the values, whether or not the author redacted it. + @Test func interpolatingASpecNeverRendersEnvironmentValues() { + let rendered = "\(spec())" + #expect(!rendered.contains(Self.secret)) + #expect(!rendered.contains(Self.hookSecret)) + #expect(rendered.contains("PASSWORD=")) + #expect(rendered.contains("HOOK_TOKEN=")) + } + + // print(), String(describing:) and String(reflecting:) all resolve through + // description, so none of them is a way around the redaction. + @Test func everyTextRenderingIsRedacted() { + let s = spec() + for rendered in ["\(s)", String(describing: s), String(reflecting: s)] { + #expect(!rendered.contains(Self.secret)) + #expect(!rendered.contains(Self.hookSecret)) + } + } + + @Test func variableNamesSurviveSoLogsStayUseful() { + let rendered = "\(spec())" + #expect(rendered.contains("PATH=")) + #expect(rendered.contains("PASSWORD=")) + } + + // A bare NAME names a variable to inherit from the parent and carries no + // value, so there is nothing to hide and it passes through untouched. + @Test func inheritedEntriesArePreserved() { + #expect("\(spec())".contains("INHERIT_ME")) + } + + @Test(arguments: [ + "EMPTY=", + "CONNECTION=postgres://user:pw@host/db?sslmode=require", + ]) + func valuesAreMaskedWholeIncludingAnyFurtherEquals(_ entry: String) { + let name = String(entry[entry.startIndex..")) + if let value = entry.split(separator: "=", maxSplits: 1).last, entry.hasSuffix(String(value)), value != name { + #expect(!rendered.contains(String(value))) + } + } + + // Redaction is a rendering concern. Encoding must still produce the real + // spec, or we would be corrupting what gets written to disk and sent to + // the guest rather than just cleaning up a log line. + @Test func encodingIsUnaffected() throws { + let data = try JSONEncoder().encode(spec()) + let json = String(decoding: data, as: UTF8.self) + #expect(json.contains(Self.secret)) + #expect(json.contains(Self.hookSecret)) + + let decoded = try JSONDecoder().decode(Spec.self, from: data) + #expect(decoded.process?.env == ["PATH=/usr/bin", "PASSWORD=\(Self.secret)", "INHERIT_ME"]) + #expect(decoded.hooks?.poststart.first?.env == ["HOOK_TOKEN=\(Self.hookSecret)"]) + } + + @Test func theValuesRemainAvailableToCallers() { + #expect(spec().process?.env.contains("PASSWORD=\(Self.secret)") == true) + } + + @Test func renderingIsNonMutating() { + let s = spec() + _ = "\(s)" + #expect(s.process?.env == ["PATH=/usr/bin", "PASSWORD=\(Self.secret)", "INHERIT_ME"]) + } + + // Redacting must not cost the log line its other fields, which is the + // reason description mirrors every field rather than listing a chosen few. + @Test func theOtherFieldsAreStillRendered() { + let rendered = "\(Process(args: ["/bin/sh"], cwd: "/work", env: ["A=b"], terminal: true))" + #expect(rendered.contains("cwd:")) + #expect(rendered.contains("/work")) + #expect(rendered.contains("args:")) + #expect(rendered.contains("/bin/sh")) + #expect(rendered.contains("terminal:")) + } +} diff --git a/Tests/ContainerizationOSTests/BidirectionalRelayTests.swift b/Tests/ContainerizationOSTests/BidirectionalRelayTests.swift index 518940251..e15401a9e 100644 --- a/Tests/ContainerizationOSTests/BidirectionalRelayTests.swift +++ b/Tests/ContainerizationOSTests/BidirectionalRelayTests.swift @@ -30,6 +30,10 @@ import Musl @Suite("BidirectionalRelay tests") final class BidirectionalRelayTests { + // Raw write() to a socketpair whose peer is closed must not kill the + // `swift test` process with SIGPIPE on Linux. See ignoreSIGPIPEForTests(). + init() { ignoreSIGPIPEForTests() } + /// Creates a Unix domain socket pair and returns (fd0, fd1). private func makeSocketPair() throws -> (Int32, Int32) { var fds: [Int32] = [0, 0] diff --git a/Tests/ContainerizationOSTests/EpollTests.swift b/Tests/ContainerizationOSTests/EpollTests.swift index 636d73994..6ebbbede4 100644 --- a/Tests/ContainerizationOSTests/EpollTests.swift +++ b/Tests/ContainerizationOSTests/EpollTests.swift @@ -30,6 +30,10 @@ import Glibc @Suite("Epoll tests") final class EpollTests { + // write() to a pipe whose read end is closed must not kill the + // `swift test` process with SIGPIPE on Linux. See ignoreSIGPIPEForTests(). + init() { ignoreSIGPIPEForTests() } + @Suite("Mask option set") struct MaskTests { @Test diff --git a/Tests/ContainerizationOSTests/IgnoreSIGPIPE.swift b/Tests/ContainerizationOSTests/IgnoreSIGPIPE.swift new file mode 100644 index 000000000..96d4abfe8 --- /dev/null +++ b/Tests/ContainerizationOSTests/IgnoreSIGPIPE.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if canImport(Darwin) +import Darwin +#elseif canImport(Glibc) +import Glibc +#elseif canImport(Musl) +import Musl +#endif + +/// Ignore SIGPIPE for the test process. +/// +/// Several suites in this target drive sockets/pipes with raw `write(2)` / +/// `sendmsg(2)` whose peer may already be closed (e.g. the BidirectionalRelay, +/// SCM_RIGHTS, and epoll pipe tests). On Linux a write to a closed peer raises +/// SIGPIPE, whose default disposition terminates the entire `swift test` +/// process (signal 13); swift-testing runs suites concurrently, so this shows +/// up as an intermittent whole-run crash. macOS masks it per-socket, so this is +/// Linux-specific. +/// +/// swift-testing has no global setUp hook, so suites that touch sockets/pipes +/// call this from `init()`. `signal` sets a process-wide disposition and the +/// call is idempotent, so invoking it before each such suite's test bodies run +/// is enough to keep any of their writes from killing the run. +func ignoreSIGPIPEForTests() { + _ = signal(SIGPIPE, SIG_IGN) +} diff --git a/Tests/ContainerizationOSTests/SocketTests.swift b/Tests/ContainerizationOSTests/SocketTests.swift index e941cd4a6..908f7860d 100644 --- a/Tests/ContainerizationOSTests/SocketTests.swift +++ b/Tests/ContainerizationOSTests/SocketTests.swift @@ -31,6 +31,10 @@ import Musl @Suite("Socket SCM_RIGHTS tests") final class SocketTests { + // sendmsg() over a socketpair whose peer is closed must not kill the + // `swift test` process with SIGPIPE on Linux. See ignoreSIGPIPEForTests(). + init() { ignoreSIGPIPEForTests() } + /// Helper function to send a file descriptor via SCM_RIGHTS private func sendFileDescriptor(socket: Socket, fd: Int32) throws { var msg = msghdr() diff --git a/Tests/ContainerizationTests/AllocatorTests.swift b/Tests/ContainerizationTests/AllocatorTests.swift new file mode 100644 index 000000000..8bb80d2a9 --- /dev/null +++ b/Tests/ContainerizationTests/AllocatorTests.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(macOS) + +import ContainerizationError +import ContainerizationExtras +import Testing + +@testable import Containerization + +struct AllocatorTests { + + @Test func allocateDualStackReturnsDistinctPairs() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + + let (a4, a6) = try alloc.allocate("a") + let (b4, b6) = try alloc.allocate("b") + + #expect(a4 != b4) + #expect(a6 != nil && b6 != nil) + #expect(a6 != b6) + + // The v4 allocator starts at lower + 2 (skipping network base + gateway), + // so the first two allocations are .2 and .3. + #expect(a4 == (try CIDRv4("192.168.64.2/24"))) + #expect(b4 == (try CIDRv4("192.168.64.3/24"))) + } + + @Test func allocateWithNoV6PrefixReturnsNilV6() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: nil) + + let (_, a6) = try alloc.allocate("a") + #expect(a6 == nil) + } + + @Test func duplicateIdThrows() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + _ = try alloc.allocate("a") + #expect(throws: ContainerizationError.self) { + _ = try alloc.allocate("a") + } + } + + @Test func releaseAllowsIdReuse() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + + _ = try alloc.allocate("a") + // Re-allocating 'a' would throw .exists if release didn't clear it. + try alloc.release("a") + _ = try alloc.allocate("a") + } + + @Test func releaseUnknownIdIsNoOp() throws { + guard #available(macOS 26, *) else { return } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + try alloc.release("never-allocated") + } + + @Test func v6HostPortionUsesOrdinalIndex() throws { + guard #available(macOS 26, *) else { + return + } + var alloc = try VmnetNetwork.Allocator( + cidrV4: try CIDRv4("192.168.64.0/24"), + cidrV6: try CIDRv6("fd00::/64")) + + let (_, a6) = try alloc.allocate("a") + let (_, b6) = try alloc.allocate("b") + + let aHost = a6!.address.value & a6!.prefix.suffixMask128 + let bHost = b6!.address.value & b6!.prefix.suffixMask128 + #expect(aHost == 2) + #expect(bHost == 3) + } + + @Test func cidrV6Gateway() throws { + // The network gateway is the lowest address + 1. + #expect((try CIDRv6("fd00::/64")).gateway == (try IPv6Address("fd00::1"))) + #expect((try CIDRv6("fd00:abcd:1234::/48")).gateway == (try IPv6Address("fd00:abcd:1234::1"))) + } + + @available(macOS 26, *) + private actor SerialAllocator { + private var inner: VmnetNetwork.Allocator + init(cidrV4: CIDRv4, cidrV6: CIDRv6?) throws { + self.inner = try VmnetNetwork.Allocator(cidrV4: cidrV4, cidrV6: cidrV6) + } + func allocate(_ id: String) throws -> (CIDRv4, CIDRv6?) { + try inner.allocate(id) + } + func release(_ id: String) throws { + try inner.release(id) + } + } + + @Test func returnsUniqueAddressesUnderConcurrentLoad() async throws { + guard #available(macOS 26, *) else { return } + // /16 host space is much larger than `count`, so we won't hit the + // pool ceiling — we're testing for collisions/state corruption. + let alloc = try SerialAllocator( + cidrV4: try CIDRv4("10.0.0.0/16"), + cidrV6: try CIDRv6("fd00::/64")) + + let count = 1000 + let pairs = try await withThrowingTaskGroup(of: (CIDRv4, CIDRv6?).self) { group in + for i in 0.. URL { + let archiveURL = FileManager.default.uniqueTemporaryDirectory() + .appendingPathComponent("ext4-unpacker-journal.tar", isDirectory: false) + let writer = try ArchiveWriter(format: .paxRestricted, filter: .none, file: archiveURL) + try writer.writeEntry(entry: .dir(path: "/data", permissions: 0o755), data: nil) + let payload = Data("hello".utf8) + try writer.writeEntry( + entry: .file(path: "/data/hello.txt", permissions: 0o644, size: Int64(payload.count)), + data: payload) + try writer.finishEncoding() + return archiveURL + } +} + +extension ContainerizationArchive.WriteEntry { + fileprivate static func dir(path: String, permissions: mode_t) -> WriteEntry { + let entry = WriteEntry() + entry.path = path + entry.fileType = .directory + entry.permissions = permissions + return entry + } + + fileprivate static func file(path: String, permissions: mode_t, size: Int64) -> WriteEntry { + let entry = WriteEntry() + entry.path = path + entry.fileType = .regular + entry.permissions = permissions + entry.size = size + return entry + } +} diff --git a/Tests/ContainerizationTests/InterfaceTests.swift b/Tests/ContainerizationTests/InterfaceTests.swift new file mode 100644 index 000000000..c00ecca1b --- /dev/null +++ b/Tests/ContainerizationTests/InterfaceTests.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerizationExtras +import Testing + +@testable import Containerization + +struct InterfaceTests { + + /// A minimal `Interface` conformer that only sets the IPv4 surface, relying on the + /// protocol's default extensions to fill in `ipv6Address`, `ipv6Gateway`, and `mtu`. + private struct V4OnlyInterface: Interface { + let ipv4Address: CIDRv4 + let ipv4Gateway: IPv4Address? + let macAddress: MACAddress? + } + + @Test func interfaceProtocolV6Defaults() throws { + let i = V4OnlyInterface( + ipv4Address: try CIDRv4("10.0.0.2/24"), + ipv4Gateway: try IPv4Address("10.0.0.1"), + macAddress: nil) + #expect(i.ipv6Address == nil) + #expect(i.ipv6Gateway == nil) + #expect(i.mtu == 1500) + } + + @Test func natInterfaceRoundTripsV6Fields() throws { + let nat = NATInterface( + ipv4Address: try CIDRv4("10.0.0.2/24"), + ipv4Gateway: try IPv4Address("10.0.0.1"), + ipv6Address: try CIDRv6("fd00::2/64"), + ipv6Gateway: try IPv6Address("fd00::1")) + #expect(nat.ipv6Address == (try CIDRv6("fd00::2/64"))) + #expect(nat.ipv6Gateway == (try IPv6Address("fd00::1"))) + } + + @Test func natInterfaceV6FieldsDefaultToNil() throws { + let nat = NATInterface( + ipv4Address: try CIDRv4("10.0.0.2/24"), + ipv4Gateway: try IPv4Address("10.0.0.1")) + #expect(nat.ipv6Address == nil) + #expect(nat.ipv6Gateway == nil) + } +} diff --git a/Tests/ContainerizationTests/KernelTests.swift b/Tests/ContainerizationTests/KernelTests.swift index 66b03f0ed..dcc087a03 100644 --- a/Tests/ContainerizationTests/KernelTests.swift +++ b/Tests/ContainerizationTests/KernelTests.swift @@ -17,6 +17,7 @@ // import Foundation +import Logging import Testing @testable import Containerization @@ -55,4 +56,38 @@ final class KernelTests { #expect(commandLine.kernelArgs == ["console=hvc0", "debug", "panic=10"]) } + + @Test func setAgentLogLevelAppendsFlagAndValue() { + var commandLine = Kernel.CommandLine(initArgs: []) + commandLine.setAgentLogLevel(level: .debug) + #expect(commandLine.initArgs == ["--log-level", "debug"]) + } + + @Test(arguments: [ + (Logger.Level.trace, "trace"), + (.debug, "debug"), + (.info, "info"), + (.notice, "notice"), + (.warning, "warning"), + (.error, "error"), + (.critical, "critical"), + ]) + func setAgentLogLevelForEachLevel(level: Logger.Level, expected: String) { + var commandLine = Kernel.CommandLine(initArgs: []) + commandLine.setAgentLogLevel(level: level) + #expect(commandLine.initArgs == ["--log-level", expected]) + } + + @Test func setAgentLogLevelPreservesExistingInitArgs() { + var commandLine = Kernel.CommandLine(initArgs: ["--verbose"]) + commandLine.setAgentLogLevel(level: .info) + #expect(commandLine.initArgs == ["--verbose", "--log-level", "info"]) + } + + @Test func setAgentLogLevelDoesNotAffectKernelArgs() { + var commandLine = Kernel.CommandLine(debug: true, panic: 0, initArgs: []) + let kernelArgsBefore = commandLine.kernelArgs + commandLine.setAgentLogLevel(level: .warning) + #expect(commandLine.kernelArgs == kernelArgsBefore) + } } diff --git a/Tests/ContainerizationTests/LinuxContainerTests.swift b/Tests/ContainerizationTests/LinuxContainerTests.swift index ddf4a6786..f3a3f792a 100644 --- a/Tests/ContainerizationTests/LinuxContainerTests.swift +++ b/Tests/ContainerizationTests/LinuxContainerTests.swift @@ -15,9 +15,12 @@ //===----------------------------------------------------------------------===// import ContainerizationOCI +import ContainerizationOS import Foundation import Testing +import struct ContainerizationOCI.ImageConfig + @testable import Containerization struct LinuxContainerTests { @@ -66,4 +69,105 @@ struct LinuxContainerTests { #expect(process.arguments == ["/bin/sh", "-c", "echo 'hello'", "&&", "sleep 10"]) } + + @Test func defaultCapabilitiesAreRestrictedOCISet() { + // Regression guard against shipping `.allCapabilities` as the default. + // A default container must not receive CAP_SYS_ADMIN, which would let it + // write /proc/sys/kernel/core_pattern and escape to guest-root. Cover both + // construction paths: the no-argument init (property default) and the full + // memberwise init (parameter default). + let viaProperty = LinuxProcessConfiguration() + let viaInit = LinuxProcessConfiguration(arguments: ["/bin/sh"]) + + for caps in [viaProperty.capabilities, viaInit.capabilities] { + for set in [caps.bounding, caps.effective, caps.permitted, caps.inheritable, caps.ambient] { + #expect(!set.contains(.sysAdmin), "default capabilities must not include CAP_SYS_ADMIN") + } + } + + // The default must be exactly the documented OCI baseline. + let expected = LinuxCapabilities.defaultOCICapabilities + #expect(viaProperty.capabilities.bounding == expected.bounding) + #expect(viaProperty.capabilities.effective == expected.effective) + #expect(viaProperty.capabilities.permitted == expected.permitted) + #expect(viaProperty.capabilities.inheritable == expected.inheritable) + #expect(viaProperty.capabilities.ambient == expected.ambient) + #expect(viaInit.capabilities.bounding == expected.bounding) + } + + @Test func defaultMaskedAndReadonlyPathsAreOCISet() { + // Regression guard: masked/readonly paths must default to the OCI + // standard set now that capabilities default to the restricted baseline. + // Without CAP_SYS_ADMIN a workload can't unmount these, so the defaults + // are meaningful defense-in-depth — shipping empty defaults would leave + // /proc/kcore and friends exposed. Cover both construction paths and + // both configuration types. + let expectedMasked = LinuxContainer.defaultMaskedPaths() + let expectedReadonly = LinuxContainer.defaultReadonlyPaths() + + // Sensitive kernel paths must actually be in the defaults. + #expect(expectedMasked.contains("/proc/kcore")) + #expect(expectedMasked.contains("/sys/firmware")) + #expect(expectedReadonly.contains("/proc/sys")) + + let containerViaProperty = LinuxContainer.Configuration() + let containerViaInit = LinuxContainer.Configuration(process: LinuxProcessConfiguration(arguments: ["/bin/sh"])) + let pod = LinuxPod.ContainerConfiguration() + + for config in [containerViaProperty, containerViaInit] { + #expect(config.maskedPaths == expectedMasked) + #expect(config.readonlyPaths == expectedReadonly) + } + #expect(pod.maskedPaths == expectedMasked) + #expect(pod.readonlyPaths == expectedReadonly) + } + + @Test func runtimeSpecIncludesConfiguredBlockIO() throws { + let blockIO = Containerization.LinuxBlockIO( + weight: 500, + leafWeight: 300, + weightDevice: [ + Containerization.LinuxWeightDevice(major: 8, minor: 0, weight: 700, leafWeight: 400) + ], + throttleReadBpsDevice: [ + Containerization.LinuxThrottleDevice(major: 8, minor: 16, rate: 1_048_576) + ], + throttleWriteBpsDevice: [ + Containerization.LinuxThrottleDevice(major: 8, minor: 32, rate: 2_097_152) + ], + throttleReadIOPSDevice: [ + Containerization.LinuxThrottleDevice(major: 8, minor: 48, rate: 1_000) + ], + throttleWriteIOPSDevice: [ + Containerization.LinuxThrottleDevice(major: 8, minor: 64, rate: 2_000) + ] + ) + + let container = try LinuxContainer( + "blkio-test", + rootfs: .block(format: "ext4", source: "/tmp/rootfs.img", destination: "/"), + vmm: StubVirtualMachineManager(), + configuration: .init(process: .init(), blockIO: blockIO) + ) + + let resources = try #require(container.generateRuntimeSpec().linux?.resources) + let specBlockIO = try #require(resources.blockIO) + + #expect(specBlockIO.weight == 500) + #expect(specBlockIO.leafWeight == 300) + #expect(specBlockIO.weightDevice.first?.major == 8) + #expect(specBlockIO.weightDevice.first?.minor == 0) + #expect(specBlockIO.weightDevice.first?.weight == 700) + #expect(specBlockIO.weightDevice.first?.leafWeight == 400) + #expect(specBlockIO.throttleReadBpsDevice.first?.rate == 1_048_576) + #expect(specBlockIO.throttleWriteBpsDevice.first?.rate == 2_097_152) + #expect(specBlockIO.throttleReadIOPSDevice.first?.rate == 1_000) + #expect(specBlockIO.throttleWriteIOPSDevice.first?.rate == 2_000) + } +} + +private struct StubVirtualMachineManager: VirtualMachineManager { + func create(config: some VMCreationConfig) async throws -> any VirtualMachineInstance { + fatalError("StubVirtualMachineManager.create should not be called by LinuxContainerTests") + } } diff --git a/Tests/ContainerizationTests/MountCHTests.swift b/Tests/ContainerizationTests/MountCHTests.swift new file mode 100644 index 000000000..4b809ea29 --- /dev/null +++ b/Tests/ContainerizationTests/MountCHTests.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 CloudHypervisor +import Testing + +@testable import Containerization + +@Suite("Mount+CH") +struct MountCHTests { + @Test("block mount without options produces DiskConfig with readonly=false") + func blockNoOptions() { + let mount = Mount.block(format: "ext4", source: "/foo.img", destination: "/data") + let cfg = mount.chDiskConfig(id: "blk-0") + #expect(cfg?.path == "/foo.img") + #expect(cfg?.readonly == false) + #expect(cfg?.id == "blk-0") + #expect(cfg?.direct == nil) + #expect(cfg?.iommu == nil) + #expect(cfg?.pciSegment == nil) + } + + @Test("block mount with 'ro' option produces DiskConfig with readonly=true") + func blockReadOnly() { + let mount = Mount.block(format: "ext4", source: "/foo.img", destination: "/data", options: ["ro"]) + let cfg = mount.chDiskConfig(id: "blk-1") + #expect(cfg?.readonly == true) + } + + @Test("non-block mount returns nil from chDiskConfig") + func chDiskConfigNilForNonBlock() { + let share = Mount.share(source: "/host", destination: "/guest") + #expect(share.chDiskConfig(id: "x") == nil) + + let any = Mount.any(type: "tmpfs", source: "tmpfs", destination: "/tmp") + #expect(any.chDiskConfig(id: "x") == nil) + } + + @Test("share mount produces FsConfig with tag and socket") + func shareMount() { + let mount = Mount.share(source: "/host/dir", destination: "/guest/dir") + let cfg = mount.chFsConfig(tag: "share0", socketPath: "/tmp/vfs.sock", id: "fs-0") + #expect(cfg?.tag == "share0") + #expect(cfg?.socket == "/tmp/vfs.sock") + #expect(cfg?.id == "fs-0") + #expect(cfg?.numQueues == nil) + #expect(cfg?.queueSize == nil) + #expect(cfg?.pciSegment == nil) + } + + @Test("non-share mount returns nil from chFsConfig") + func chFsConfigNilForNonShare() { + let block = Mount.block(format: "ext4", source: "/foo.img", destination: "/data") + #expect(block.chFsConfig(tag: "t", socketPath: "/s", id: "x") == nil) + + let any = Mount.any(type: "tmpfs", source: "tmpfs", destination: "/tmp") + #expect(any.chFsConfig(tag: "t", socketPath: "/s", id: "x") == nil) + } +} diff --git a/Tests/ContainerizationTests/MountTests.swift b/Tests/ContainerizationTests/MountTests.swift index 6b64d81c2..33cf175df 100644 --- a/Tests/ContainerizationTests/MountTests.swift +++ b/Tests/ContainerizationTests/MountTests.swift @@ -265,6 +265,77 @@ struct PodVolumeTests { #expect(mount.type == "xfs") } + @Test func podVolumeDiskImageSourceCreation() { + let volume = LinuxPod.PodVolume( + name: "disk-data", + source: .diskImage(path: URL(fileURLWithPath: "/tmp/disk.ext4")), + format: "ext4" + ) + + #expect(volume.name == "disk-data") + #expect(volume.format == "ext4") + if case .diskImage(let path, let readOnly) = volume.source { + #expect(path.path == "/tmp/disk.ext4") + #expect(readOnly == false) + } else { + Issue.record("Expected .diskImage source") + } + } + + @Test func podVolumeDiskImageToMountConvertsCorrectly() { + let volume = LinuxPod.PodVolume( + name: "my-disk", + source: .diskImage(path: URL(fileURLWithPath: "/tmp/my-disk.ext4")), + format: "ext4" + ) + + let mount = volume.toMount() + + // The mount source must be the raw filesystem path, not a file:// URL. + #expect(mount.source == "/tmp/my-disk.ext4") + #expect(mount.destination == "/run/volumes/my-disk") + #expect(mount.type == "ext4") + #expect(mount.isBlock) + } + + @Test func podVolumeDiskImageReadOnlySetsOptions() { + let volume = LinuxPod.PodVolume( + name: "ro-disk", + source: .diskImage(path: URL(fileURLWithPath: "/tmp/ro-disk.ext4"), readOnly: true), + format: "ext4" + ) + + let mount = volume.toMount() + + #expect(mount.options.contains("ro")) + #expect(mount.isBlock) + } + + @Test func podVolumeTmpfsToMountConvertsCorrectly() { + let volume = LinuxPod.PodVolume( + name: "mem", + source: .tmpfs(sizeBytes: 64 * 1024 * 1024), + format: "tmpfs" + ) + let mount = volume.toMount() + #expect(mount.type == "tmpfs") + #expect(mount.source == "tmpfs") + #expect(mount.destination == "/run/volumes/mem") + #expect(mount.options == ["size=\(64 * 1024 * 1024)"]) + } + + @Test func podVolumeTmpfsWithoutSizeHasNoOptions() { + let volume = LinuxPod.PodVolume( + name: "mem", + source: .tmpfs(), + format: "tmpfs" + ) + let mount = volume.toMount() + #expect(mount.type == "tmpfs") + #expect(mount.options.isEmpty) + #expect(!mount.isBlock) + } + @Test func sharedMountCreation() { let mount = Mount.sharedMount( name: "shared-data", diff --git a/Tests/ContainerizationTests/TAPNameDerivationTests.swift b/Tests/ContainerizationTests/TAPNameDerivationTests.swift new file mode 100644 index 000000000..7f52500bd --- /dev/null +++ b/Tests/ContainerizationTests/TAPNameDerivationTests.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2026 Apple Inc. and the Containerization project authors. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +#if os(Linux) +import Testing + +@testable import Containerization + +@Suite("TAP name derivation") +struct TAPNameDerivationTests { + @Test("name is deterministic for a given id") + func deterministic() { + let a = LinuxBridgedNetwork.derivedTAPName(forID: "container-abc") + let b = LinuxBridgedNetwork.derivedTAPName(forID: "container-abc") + #expect(a == b) + } + + @Test("different ids produce different names") + func differentIds() { + let a = LinuxBridgedNetwork.derivedTAPName(forID: "alpha") + let b = LinuxBridgedNetwork.derivedTAPName(forID: "beta") + #expect(a != b) + } + + @Test("name fits within IFNAMSIZ - 1 (15 chars)") + func ifnamsizFit() { + for id in ["a", "short", "a-much-longer-container-id-that-exceeds-typical-bounds"] { + let n = LinuxBridgedNetwork.derivedTAPName(forID: id) + #expect(n.count <= 15, "name '\(n)' exceeds IFNAMSIZ-1") + } + } + + @Test("name uses czt- prefix") + func prefix() { + let n = LinuxBridgedNetwork.derivedTAPName(forID: "anything") + #expect(n.hasPrefix("czt-")) + } + + @Test("hex suffix is 10 chars") + func suffixLength() { + let n = LinuxBridgedNetwork.derivedTAPName(forID: "anything") + // "czt-" is 4 chars; total 14. + #expect(n.count == 14) + } +} +#endif diff --git a/docs/x86_64-build.md b/docs/x86_64-build.md new file mode 100644 index 000000000..102a1bbfb --- /dev/null +++ b/docs/x86_64-build.md @@ -0,0 +1,230 @@ +# x86_64 Deployment Build + +`make dist-x86_64` produces a self-contained x86_64 Linux deployment tarball +at `bin/containerization-x86_64-.tar.gz`. The build runs entirely inside +the aarch64 Linux dev container — there is no host tooling requirement beyond +`make`, `container`, and the prerequisites the dev image installs. + +The tarball ships everything needed to run a Containerization VM on an x86_64 +Linux host: the `cctl` host binary, the `cloud-hypervisor` VMM, the +`virtiofsd` filesystem daemon, an x86_64 Linux kernel, and an `initfs.ext4` +guest rootfs containing `vminitd` + `vmexec`. + +`cctl`, `cloud-hypervisor`, and `vminitd`/`vmexec` are statically linked +against musl, so they run on any x86_64 Linux. **`virtiofsd` is dynamically +linked against glibc 2.35+**; the deployment host must provide glibc +≥ 2.35 (Ubuntu 22.04 / Debian 12 / RHEL 9 era) plus `libseccomp.so.2` and +`libcap-ng.so.0`. Both are present by default on essentially every server +distro shipped in the last few years. + +## Prerequisites + +Before the first `make dist-x86_64`: + +1. **Source checkouts under `.local/`** — pinned by you, not fetched by the + build. There is no fetch target; clone the revision you want shipped: + + ```sh + git clone -b v52.0 https://github.com/cloud-hypervisor/cloud-hypervisor \ + .local/cloud-hypervisor + git clone https://gitlab.com/virtio-fs/virtiofsd .local/virtiofsd + ``` + +2. **An x86_64 kernel** at `kernel/vmlinuz-x86_64` (preferred) or + `kernel/vmlinux-x86_64`. Build via `make -C kernel TARGET_ARCH=x86_64`. + The build fails hard if neither exists — a tarball without a kernel is + not usable. + +3. **The Linux dev image.** `dist-x86_64` depends on the `linux-image` + make target, so the `container build` cache handles this automatically; + the first run takes a few minutes, subsequent runs are seconds. + +The dev image (`images/linux-dev/Dockerfile`) bundles Swiftly, the Static +Linux SDK, the Rust toolchain (with `cargo-zigbuild`), a prebuilt +`/opt/cross-x86_64-musl/` prefix containing zlib, xz, bzip2, libarchive, +libcap-ng, and libseccomp built static-musl for x86_64, and a sibling +`/opt/cross-x86_64-gnu/` prefix containing libcap-ng and libseccomp built +as glibc-dynamic shared libraries for virtiofsd's link step. +`scripts/build-musl-x86_64-deps.sh` and `scripts/build-glibc-x86_64-deps.sh` +produce these prefixes at image build time. + +## Running the build + +```sh +make dist-x86_64 +``` + +Drives `scripts/build-dist-x86_64.sh` inside the dev container via the +`linux_run` macro. The container bind-mounts the repo at `/workspace`, so +all build outputs land back on the host under `bin/dist-x86_64/`. + +## Pipeline + +The script runs five build stages plus a packaging stage. Each build stage +is gated by a freshness check (see [Rebuild gating](#rebuild-gating)) so +unchanged components are skipped on subsequent runs. + +1. **`cctl` cross-compile to x86_64-linux-musl.** + `swift build --swift-sdk x86_64-swift-linux-musl --product cctl`. Always + runs — this is the artifact under iteration, and Swift's incremental + build is a near-no-op when nothing changed. + +2. **`vminitd` + `vmexec` cross-compile to x86_64-linux-musl.** + `make -C vminitd LIBC=musl MUSL_ARCH=x86_64`. The guest agent and + process launcher; both run inside the VM as PID 1's children. + +3. **`cloud-hypervisor` cross-compile to x86_64-unknown-linux-musl.** + `cargo zigbuild --target x86_64-unknown-linux-musl --bin cloud-hypervisor` + from `.local/cloud-hypervisor`. + +4. **`virtiofsd` cross-compile to x86_64-unknown-linux-gnu.2.35.** + `cargo zigbuild --target x86_64-unknown-linux-gnu.2.35` from + `.local/virtiofsd`, with `scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch` + applied first. The patch is idempotent — applied if missing, skipped if + already present, fails hard if it can't be applied cleanly. Unlike the + other three host binaries, virtiofsd is **glibc-dynamic**: it expects + the deployment host to provide glibc ≥ 2.35, `libseccomp.so.2`, and + `libcap-ng.so.0`. Link-time `.so` files come from + `/opt/cross-x86_64-gnu/`. + +5. **`initfs.ext4` packaging.** + `scripts/build-initfs.sh --vminitd … --vmexec … --ext4 …` stages the guest + rootfs and writes a ready-to-mount ext4 image with the x86_64 guest binaries + inside (loop mount where available, else `mke2fs -d`). The x86_64 tarball + ships this raw ext4 and boots it via `cctl run --initfs`, so — unlike the + arm64 flow — no `vminit` OCI image is built here. + +6. **Stage and tar.** Always runs. Lays out the staging tree at + `bin/dist-x86_64//`: + + ``` + / + ├── bin/ + │ ├── cctl + │ ├── cloud-hypervisor + │ └── virtiofsd + ├── kernel/ + │ └── vmlinuz-x86_64 # or vmlinux-x86_64, whichever was found + └── initfs.ext4 + ``` + + Then `tar -czf bin/.tar.gz`. + +## Rebuild gating + +By default, every stage skips when its output is up-to-date. Each freshness +check has a corresponding `REBUILD_*=1` environment variable that forces +the stage to rerun. + +| Stage | Skip condition | Force rebuild | +| --- | --- | --- | +| `cctl` x86 cross | (never skipped — always runs) | n/a | +| `vminitd` + `vmexec` | both binaries exist under `bin/dist-x86_64/` AND nothing under `vminitd/Sources/`, `vminitd/Package.swift`, or `Sources/Containerization/SandboxContext/` is newer than them | `REBUILD_VMINITD=1` | +| `cloud-hypervisor` | `bin/dist-x86_64/cloud-hypervisor` exists | `REBUILD_CH=1` | +| `virtiofsd` | `bin/dist-x86_64/virtiofsd` exists | `REBUILD_VIRTIOFSD=1` | +| `initfs.ext4` | exists AND is newer than both staged `vminitd` and `vmexec` (also implicitly skipped when `vminitd` was skipped) | `REBUILD_INITFS=1` | +| native aarch64 `cctl` | only built when `initfs.ext4` is being rebuilt | `REBUILD_INITFS=1` | +| stage tree + tar | (always runs) | n/a | + +The freshness checks intentionally use binary presence and source mtimes +rather than content hashing — fast to evaluate, easy to bypass with `touch` +or `rm`. There is no global "rebuild everything" switch by design; force +the specific component you want, or `rm -rf bin/dist-x86_64/` for a full +clean rebuild. + +`cloud-hypervisor` and `virtiofsd` only check binary presence (not source +mtime against `.local/`). The pinned-source convention assumes you opt +into rebuilds explicitly — the `REBUILD_CH=1` / `REBUILD_VIRTIOFSD=1` +escape hatches exist for exactly that case. Walking the full Rust source +tree on every run was the alternative; not worth the cost. + +### Common rebuild scenarios + +- **Iterating on host-side `cctl` or `Containerization` Swift code:** just + `make dist-x86_64`. Only the x86 cctl rebuild runs (and tar). +- **Touched `vminitd` source or the proto:** `REBUILD_VMINITD=1` is + picked up automatically by mtime; `make dist-x86_64`. `vminitd` and + `initfs.ext4` rebuild. +- **Pulled new `.local/cloud-hypervisor`:** `REBUILD_CH=1 make dist-x86_64`. +- **Pulled new `.local/virtiofsd`:** `REBUILD_VIRTIOFSD=1 make dist-x86_64`. +- **Suspect a stale artifact:** `rm -rf bin/dist-x86_64 && make dist-x86_64` + for a full clean rebuild. + +## Cross-compilation toolchain + +Two cross toolchains live side-by-side in the dev image. `cctl`, +`vminitd`/`vmexec`, and `cloud-hypervisor` target `x86_64-linux-musl` and +ship statically linked so the artifacts are host-libc independent. +`virtiofsd` targets `x86_64-linux-gnu.2.35` and ships dynamically linked; +the deployment host provides glibc, libseccomp, and libcap-ng. + +- **Swift** uses Apple's Static Linux SDK (`x86_64-swift-linux-musl`), + installed into the dev image by `make linux-image` (Dockerfile + `SWIFT_SDK_URL`/`SWIFT_SDK_CHECKSUM` build args). The same SDK + is used for both `cctl` and `vminitd` cross-builds. +- **Rust C cross-compiler is Zig.** For musl stages, `zig cc -target + x86_64-linux-musl` is wrapped as `x86_64-linux-musl-{gcc,g++,ar,ranlib,strip}`. + For virtiofsd, parallel `x86_64-linux-gnu-*` wrappers dispatch to + `zig cc -target x86_64-linux-gnu.2.35`, plus an `x86_64-linux-gnu-ld` + wrapper backed by LLVM's `ld.lld` (apt-installed). The `ld` wrapper + is needed because libtool's shared-library detection probes the + linker with `-m elf_x86_64`; the host's aarch64 `/usr/bin/ld` + rejects that and would silently disable `.so` emission. The gnu + `gcc`/`g++` wrappers intercept `-print-prog-name=ld` so libtool + discovers the cross-ld wrapper instead of the host linker. The + pinned `.2.35` glibc baseline determines the minimum host glibc; + bumping it requires editing the wrapper scripts under + `images/linux-dev/wrappers/`. Zig was chosen over musl.cc / gcc + cross prebuilts because aarch64-hosted versions of those aren't + published. +- **Rust linker** is **not** set explicitly. `cargo-zigbuild` installs + its own linker wrapper that strips Rust's self-contained musl crt + files (which would otherwise collide with Zig's musl crt). Setting + `CARGO_TARGET_*_LINKER` ourselves overrides that and produces + duplicate-symbol link errors. +- **`pkg-config`** points at `/opt/cross-x86_64-musl/lib/pkgconfig` for + the musl stages; the virtiofsd block overrides it in a subshell to + point at `/opt/cross-x86_64-gnu/lib/pkgconfig` so `libseccomp-sys` + and `libcap-ng`'s `capng-sys` resolve against the glibc-dynamic + `.so` files, not the static-musl `.a` archives. The musl prefix uses + GNU ld linker scripts at `lib{seccomp,cap-ng}.so` to redirect + dynamic-link requests into the static archives; the gnu prefix ships + real shared libraries. + +The cross C dep prefixes are built by `scripts/build-musl-x86_64-deps.sh` +and `scripts/build-glibc-x86_64-deps.sh` during `make linux-image`. +Modifying either script invalidates that layer of the dev image and +triggers a rebuild on the next `make dist-x86_64`. + +## Troubleshooting + +- **`ERROR: missing .local/cloud-hypervisor source checkout`** — see + Prerequisites. There is no fetch target; clone the revision you want + pinned. +- **`ERROR: no x86_64 kernel found`** — run + `make -C kernel TARGET_ARCH=x86_64`. The build refuses to ship a + tarball without a kernel. +- **`ERROR: virtiofsd cap-drop patch does not apply cleanly`** — the + patch only applies to known-good upstream revisions of virtiofsd. If + you bumped `.local/virtiofsd` past that, refresh + `scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch` + against the new revision. +- **Stale binary on the deployment host** — confirm the tarball SHA in + `bin/containerization-x86_64-.tar.gz` matches `git rev-parse + --short HEAD`. The script tags the tarball with `HEAD` at build time; + uncommitted changes ship under the same SHA as their parent commit. +- **Linker errors mentioning duplicate `crt*.o` symbols** — something is + setting `CARGO_TARGET_*_LINKER`. Unset it and let `cargo-zigbuild` + manage the linker. +- **`virtiofsd: error while loading shared libraries: libseccomp.so.2`** + (or `libcap-ng.so.0`) on the deployment host — install the system + packages (`apt install libseccomp2 libcap-ng0` on Debian/Ubuntu, + `dnf install libseccomp libcap-ng` on Fedora/RHEL). `virtiofsd` is + glibc-dynamic by design; the libs are not bundled in the tarball. +- **`virtiofsd: /lib/x86_64-linux-gnu/libc.so.6: version 'GLIBC_2.35' + not found`** — the deployment host's glibc is older than the build's + baseline. Either upgrade the host or rebuild with a lower baseline + by editing the `-target x86_64-linux-gnu.` arg in + `images/linux-dev/wrappers/x86_64-linux-gnu-{gcc,g++}` and the + `cargo zigbuild --target x86_64-unknown-linux-gnu.` line in + `scripts/build-dist-x86_64.sh`. diff --git a/examples/ctr-example/Makefile b/examples/ctr-example/Makefile index 68cca066f..06bda0578 100644 --- a/examples/ctr-example/Makefile +++ b/examples/ctr-example/Makefile @@ -16,6 +16,9 @@ SWIFT = /usr/bin/swift +UNAME_M := $(shell uname -m) +KERNEL_ARCH := $(if $(filter $(UNAME_M),aarch64 arm64),arm64,$(UNAME_M)) + .PHONY: all build clean run all: run @@ -41,4 +44,4 @@ fmt: $(SWIFT) format --in-place --recursive Sources/ fetch-default-kernel: $(MAKE) -C ../.. fetch-default-kernel - cp -L ../../.local/vmlinux ./vmlinux + cp -L ../../.local/vmlinux-$(KERNEL_ARCH) ./vmlinux-$(KERNEL_ARCH) diff --git a/examples/ctr-example/Package.resolved b/examples/ctr-example/Package.resolved index d50e3654a..848cc075a 100644 --- a/examples/ctr-example/Package.resolved +++ b/examples/ctr-example/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "5de11e9b526f881c570e7b65cb339765f3aa79e8646a0c1289d36f224f9f8ca0", + "originHash" : "f9b523ded39c0fa3565fd16d3b4097384449d541048751be7efee4a43af79fd4", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "b2faff932b956df50668241d14f1b42f7bae12b4", - "version" : "1.30.0" + "revision" : "7744c2a035c68ec14726c709f031835e3e30bde1", + "version" : "1.34.0" } }, { @@ -15,17 +15,35 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/containerization.git", "state" : { - "revision" : "636eef0eff00e451de6d5d426e6a6785b90b44e2", - "version" : "0.26.5" + "revision" : "9275f365dd555c8f072e7d250d809f5eb7bdd746", + "version" : "0.33.4" } }, { - "identity" : "grpc-swift", + "identity" : "grpc-swift-2", "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift.git", + "location" : "https://github.com/grpc/grpc-swift-2.git", "state" : { - "revision" : "f857994e146f5146d702e9c31ac6f3c27d55d18a", - "version" : "1.27.0" + "revision" : "21fe69ab7ce0e87ac089534733c52f037e74a3eb", + "version" : "2.4.1" + } + }, + { + "identity" : "grpc-swift-nio-transport", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", + "state" : { + "revision" : "e7d463749f9037b047dcd6da4e633718ddc432b8", + "version" : "2.8.0" + } + }, + { + "identity" : "grpc-swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift-protobuf.git", + "state" : { + "revision" : "b05885fa9bdd88f1eab2e7162f1ee81340b0da33", + "version" : "2.4.0" } }, { @@ -42,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "cdd0ef3755280949551dc26dee5de9ddeda89f54", - "version" : "1.6.2" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -51,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "40d25bbb2fc5b557a9aa8512210bded327c0f60d", - "version" : "1.5.0" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { @@ -60,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "d0b4a06d0f173a2f3be27d3ea21b3c3aa18db440", + "version" : "1.1.4" } }, { @@ -78,8 +96,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "c399f90e7bbe8874f6cbfda1d5f9023d1f5ce122", - "version" : "1.15.1" + "revision" : "bde8ca32a096825dfce37467137c903418c1893d", + "version" : "1.19.1" } }, { @@ -87,8 +105,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", - "version" : "1.3.0" + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" } }, { @@ -105,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-distributed-tracing.git", "state" : { - "revision" : "baa932c1336f7894145cbaafcd34ce2dd0b77c97", - "version" : "1.3.1" + "revision" : "dc4030184203ffafbb2ec614352487235d747fe0", + "version" : "1.4.1" } }, { @@ -114,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", - "version" : "1.6.0" + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" } }, { @@ -123,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", - "version" : "1.5.1" + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" } }, { @@ -132,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "ce592ae52f982c847a4efc0dd881cc9eb32d29f2", - "version" : "1.6.4" + "revision" : "92448c359f00ebe36ae97d3bd9086f13c7692b5a", + "version" : "1.13.2" } }, { @@ -141,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "56724a2b6d8e2aed1b2c5f23865b9ea5c43f9977", - "version" : "2.89.0" + "revision" : "77b84ac2cd2ac9e4ac67d19f045fd5b434f56967", + "version" : "2.101.0" } }, { @@ -150,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "7ee281d816fa8e5f3967a2c294035a318ea551c7", - "version" : "1.31.0" + "revision" : "d2eeec0339074034f11a040a74aa2a341a2c4506", + "version" : "1.34.1" } }, { @@ -159,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "c2ba4cfbb83f307c66f5a6df6bb43e3c88dfbf80", - "version" : "1.39.0" + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" } }, { @@ -168,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "173cc69a058623525a58ae6710e2f5727c663793", - "version" : "2.36.0" + "revision" : "407d82d5b6cc00e1c3fb83a81b1539b70c788c5e", + "version" : "2.37.1" } }, { @@ -177,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "df6c28355051c72c884574a6c858bc54f7311ff9", - "version" : "1.25.2" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -195,8 +222,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "c169a5744230951031770e27e475ff6eefe51f9d", - "version" : "1.33.3" + "revision" : "f6506eaa86ed2e01cb0ae14a75035b7fdbf0918f", + "version" : "1.38.0" } }, { @@ -204,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-service-context.git", "state" : { - "revision" : "1983448fefc717a2bc2ebde5490fe99873c5b8a6", - "version" : "1.2.1" + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" } }, { @@ -213,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "1de37290c0ab3c5a96028e0f02911b672fd42348", - "version" : "2.9.1" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { @@ -222,8 +249,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "395a77f0aa927f0ff73941d7ac35f2b46d47c9db", - "version" : "1.6.3" + "revision" : "7502b711c92a17741fa625d722b0ccbd595d8ed1", + "version" : "1.7.2" } }, { diff --git a/examples/ctr-example/Package.swift b/examples/ctr-example/Package.swift index c90f7ebe9..15b649cba 100644 --- a/examples/ctr-example/Package.swift +++ b/examples/ctr-example/Package.swift @@ -17,7 +17,7 @@ import PackageDescription -let scVersion = "0.26.5" +let scVersion = "0.33.4" let package = Package( name: "ctr-example", diff --git a/examples/ctr-example/Sources/ctr-example/main.swift b/examples/ctr-example/Sources/ctr-example/main.swift index 0ae8f6510..101b40cbc 100644 --- a/examples/ctr-example/Sources/ctr-example/main.swift +++ b/examples/ctr-example/Sources/ctr-example/main.swift @@ -29,7 +29,14 @@ struct CtrExample { defer { current.tryReset() } let initfsReference = "ghcr.io/apple/containerization/vminit:0.26.5" - let kernelPath = "./vmlinux" + #if arch(arm64) + let kernelCandidates = ["./vmlinux-arm64"] + #elseif arch(x86_64) + let kernelCandidates = ["./vmlinuz-x86_64", "./vmlinux-x86_64"] + #else + let kernelCandidates = ["./vmlinux"] + #endif + let kernelPath = kernelCandidates.first(where: { FileManager.default.fileExists(atPath: $0) }) ?? kernelCandidates[0] print("Fetching base container filesystem...") // Create container manager with file-based initfs var manager = try await ContainerManager( diff --git a/examples/sandboxy/Makefile b/examples/sandboxy/Makefile new file mode 100644 index 000000000..2be0b9c09 --- /dev/null +++ b/examples/sandboxy/Makefile @@ -0,0 +1,38 @@ +# 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. + +BUILD_CONFIGURATION ?= debug +SWIFT = /usr/bin/swift +SWIFT_STRIP := $(if $(filter release,$(BUILD_CONFIGURATION)),-Xlinker -s) +BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path) + +.PHONY: all build clean run fmt + +all: build + +build: + $(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_STRIP) + @mkdir -p bin + @install "$(BUILD_BIN_DIR)/sandboxy" ./bin/ + codesign --force --sign - --entitlements sandboxy.entitlements ./bin/sandboxy + +clean: + $(SWIFT) package clean + rm -rf bin + +run: build + ./bin/sandboxy + +fmt: + $(SWIFT) format --in-place --recursive Sources/ diff --git a/examples/sandboxy/Package.resolved b/examples/sandboxy/Package.resolved new file mode 100644 index 000000000..dad21397d --- /dev/null +++ b/examples/sandboxy/Package.resolved @@ -0,0 +1,249 @@ +{ + "originHash" : "7b278fed488f6dec94de45fcf45179f7556a1b32ebb5b19499b932a307ddcad0", + "pins" : [ + { + "identity" : "async-http-client", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/async-http-client.git", + "state" : { + "revision" : "2fc4652fb4689eb24af10e55cabaa61d8ba774fd", + "version" : "1.32.0" + } + }, + { + "identity" : "containerization", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/containerization.git", + "state" : { + "revision" : "72432f148c9cb35d6a7e7c0ad61d6f9d226cbef7", + "version" : "0.30.0" + } + }, + { + "identity" : "grpc-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift.git", + "state" : { + "revision" : "ac715c584bb1e2e5cdfb7684ccb46fab8dafc641", + "version" : "1.27.4" + } + }, + { + "identity" : "swift-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-algorithms.git", + "state" : { + "revision" : "87e50f483c54e6efd60e885f7f5aa946cee68023", + "version" : "1.2.1" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser.git", + "state" : { + "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", + "version" : "1.7.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "810496cf121e525d660cd0ea89a758740476b85f", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "24ccdeeeed4dfaae7955fcac9dbf5489ed4f1a25", + "version" : "1.18.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "8d9834a6189db730f6264db7556a7ffb751e99ee", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" + } + }, + { + "identity" : "swift-distributed-tracing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-distributed-tracing.git", + "state" : { + "revision" : "e109d8b5308d0e05201d9a1dd1c475446a946a11", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "76d7627bd88b47bf5a0f8497dd244885960dde0b", + "version" : "1.6.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", + "version" : "1.10.1" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "e932d3c4d8f77433c8f7093b5ebcbf91463948a0", + "version" : "2.95.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "3df009d563dc9f21a5c85b33d8c2e34d2e4f8c3b", + "version" : "1.32.1" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "b6571f3db40799df5a7fc0e92c399aa71c883edd", + "version" : "1.40.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "173cc69a058623525a58ae6710e2f5727c663793", + "version" : "2.36.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "60c3e187154421171721c1a38e800b390680fb5d", + "version" : "1.26.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "a008af1a102ff3dd6cc3764bb69bf63226d0f5f6", + "version" : "1.36.1" + } + }, + { + "identity" : "swift-service-context", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-service-context.git", + "state" : { + "revision" : "d0997351b0c7779017f88e7a93bc30a1878d7f29", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-service-lifecycle", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swift-server/swift-service-lifecycle.git", + "state" : { + "revision" : "89888196dd79c61c50bca9a103d8114f32e1e598", + "version" : "2.10.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", + "version" : "1.6.4" + } + }, + { + "identity" : "zstd", + "kind" : "remoteSourceControl", + "location" : "https://github.com/facebook/zstd.git", + "state" : { + "revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99", + "version" : "1.5.7" + } + } + ], + "version" : 3 +} diff --git a/examples/sandboxy/Package.swift b/examples/sandboxy/Package.swift new file mode 100644 index 000000000..15b0c52e3 --- /dev/null +++ b/examples/sandboxy/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 6.2 +//===----------------------------------------------------------------------===// +// 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 PackageDescription + +let containerizationVersion = "0.26.5" + +let package = Package( + name: "sandboxy", + platforms: [ + .macOS("26.0") + ], + products: [ + .executable( + name: "sandboxy", + targets: ["sandboxy"] + ) + ], + dependencies: [ + .package(url: "https://github.com/apple/containerization.git", from: "0.30.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.65.0"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), + ], + targets: [ + .executableTarget( + name: "sandboxy", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "ContainerizationArchive", package: "containerization"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "NIOHTTP1", package: "swift-nio"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + ], + ) + ] +) diff --git a/examples/sandboxy/README.md b/examples/sandboxy/README.md new file mode 100644 index 000000000..3246c2e8f --- /dev/null +++ b/examples/sandboxy/README.md @@ -0,0 +1,374 @@ +# sandboxy + +``` +$ sandboxy run claude + ┌──────────────┐ + │ ░░░░░░░░░░░░ │ Sandboxy + │ ░░░░░░░░░░░░ │ Agent: Claude Code + │ ░░░░░░░░░░░░ │ Instance: claude-20260328-150531 + │ ░░░░░░░░░░░░ │ Environment: 15 hours ago + │ ░░░░░░░░░░░░ │ Workspace: /Volumes/code/vessel/containerization + │ ░░░░░░░░░░░░ │ CPUs: 4 Memory: 4 GB + └──────────────┘ + Command: claude --dangerously-skip-permissions + Allowed hosts: *.anthropic.com, npm.org, *.npmjs.org, *.github.com, *.githubusercontent.com, *.pypi.org + Mounts: + /your/code -> /your/code + /Users/you/.claude -> /root/.claude + +Welcome to Claude Code v2.1.76 +………………………………………………………………………………………………………………………………………………………… + + * █████▓▓░ + * ███▓░ ░░ + ░░░░░░ ███▓░ + ░░░ ░░░░░░░░░░ ███▓░ + ░░░░░░░░░░░░░░░░░░░ * ██▓░░ ▓ + ░▓▓███▓▓░ + * ░░░░ + ░░░░░░░░ + ░░░░░░░░░░░░░░░░ + █████████ * + ██▄█████▄██ * + █████████ * +…………………█ █ █ █……………………………………………………………………………………………………………… + + Let's get started. +``` + +`sandboxy` runs AI coding agents in sandboxed Linux environments on macOS with Apple silicon. + +One command to get an isolated agent session. Your current working directory is mounted in, your config carries over, and the environment is cached for fast subsequent runs. + +> **Note:** This is an experimental tool. Behavior/flags/commands may change across releases. Its main goal was to be a good showcase of using the `Containerization` libraries API surface to build novel tools. + +> **Note:** The tool does HTTPS/HTTP filtering today for network traffic originating from the container, but can currently reach services listening on `0.0.0.0` on the host. This should be tightened up in a future release whenever `Containerization` gains nftables support. + +## Why? + +AI coding agents work best when they can install packages, run builds, and execute code freely, but giving them unrestricted access to your host machine is risky. `sandboxy` aims to alleviate some worry by running agents inside lightweight Linux VMs on macOS. Your project directory is mounted in so the agent can read and write files, but everything else like network access, host filesystem view, and installed packages is isolated. No daemon, just a single command that gets out of your way. + +## How? + +`sandboxy` boots a Micro VM for every agent session using the [`Containerization`](https://github.com/apple/containerization) Swift package. Each agent session runs in its own VM. + +### Caching + +The first time you run an agent, `sandboxy` does a fair amount of setup: downloading a Linux kernel (unless you provide one), pulling the base OCI image, unpacking it into a root filesystem, and running the agent's install commands. All of this is cached so that subsequent runs skip straight to booting the VM. In practice, a warm start takes under a second. + +The cache has a few layers: + +- **Kernel** -- downloaded once and reused across all agents. +- **Init image** -- the minimal init process (`vminit`) that bootstraps the VM, pulled from an OCI registry and cached locally. +- **Agent rootfs** -- the fully-installed root filesystem for a given agent (e.g., `claude`). This is an ext4 disk image that includes the base image layers plus everything the agent's install commands produce. +- **Instance rootfs** -- every session saves its rootfs so it can be resumed later with `--name`. See [Instance Persistence](#instance-persistence). + +On each run, the cached rootfs is cloned (via copy-on-write when the filesystem supports it) so the original cache stays clean. You can blow away any layer independently: `sandboxy cache rm ` to rebuild a single agent, or `sandboxy cache clean --all` to start completely from scratch. + +### Agent definitions + +An agent is just a JSON file that describes how to set up and launch a particular tool. The built-in Claude Code definition specifies a base container image, a list of shell commands to install the toolchain, a launch command, and the environment variables needed at runtime. You can list available agents with `sandboxy config list --agents` and view any agent's definition with `sandboxy config list --agent `. + +You can override any built-in agent or define entirely new agents by dropping a JSON file in `~/.config/sandboxy/agents/`. Use `sandboxy config create --agent ` to scaffold a definition file (pre-filled with built-in defaults for known agents). Use `sandboxy config list --paths` to see all configuration file paths. + +If the built-in install steps don't cover what you need, `sandboxy edit ` drops you into an interactive shell inside the cached rootfs. Install extra packages, configure MCP servers, add language runtimes etc. Whatever you do is saved back to the cache and included in every future run. + +### Workspace and mounts + +Your host workspace directory is shared into the VM using virtio-fs, so reads and writes are reflected immediately on both sides. Additional host directories can be mounted with `--mount`, including read-only mounts for things like config or reference data that the agent shouldn't modify. + +Agent definitions can include default mounts (e.g., `~/.claude` for Claude Code). To skip these on a specific run, pass `--no-agent-mounts`. CLI `--mount` flags are always applied regardless. + +### Network isolation + +By default, `sandboxy` enforces network isolation by placing the workload container on a host-only network with no internet route. A HTTP CONNECT proxy runs on the host and listens on the host-only network's gateway address. The workload's `HTTP_PROXY`/`HTTPS_PROXY` environment variables point at this proxy, which checks each request's target hostname against an allowlist and either tunnels it to the internet or returns a 403. + +Additional hosts can be added at runtime with `--allow-hosts`. To disable filtering entirely, pass `--no-network-filter`. + +## Quick Start + +```bash +# Build +BUILD_CONFIGURATION=release make build + +# Run Claude Code on the current directory +.build/release/sandboxy run claude +``` + +On first run, `sandboxy` downloads a kernel, pulls a base image, and installs the agent toolchain. This is cached automatically so subsequent runs generally start in less than a second. + +## Supported Agents + +- **Claude Code** - built-in + +Additional agents can be added via JSON config files. See [Adding a New Agent](#adding-a-new-agent). + +## Commands + +### `sandboxy run ` + +Run an agent in a sandboxed container. + +```bash +# Run on the current directory +sandboxy run claude + +# Specify a workspace +sandboxy run --workspace ~/projects/myapp claude + +# Allocate more resources +sandboxy run --cpus 8 --memory 8g claude + +# Restrict network to specific hosts (in addition to agent defaults) +sandboxy run --allow-hosts api.example.com --allow-hosts internal.corp.com claude + +# Disable network filtering entirely +sandboxy run --no-network-filter claude + +# Mount additional directories (read-only or read-write) +sandboxy run --mount /tmp:/tmp:ro --mount ~/data:/data claude + +# Skip mounts defined in the agent configuration +sandboxy run --no-agent-mounts claude + +# Forward environment variables into the container +sandboxy run -e MY_TOKEN -e DEBUG=1 claude + +# Forward the host SSH agent for git-over-SSH +sandboxy run --ssh-agent claude + +# Give the instance a friendly name +sandboxy run --name my-feature claude + +# Resume a named session +sandboxy run --name my-feature claude + +# Ephemeral run (remove instance after session ends) +sandboxy run --rm claude + +# Pass flags through to the agent +sandboxy run claude -- --model foobar +``` + +**Options:** + +| Flag | Description | Default | +|------|-------------|---------| +| `-w`, `--workspace` | Host directory to mount | Current directory | +| `-m`, `--mount` | Additional mount (hostpath:containerpath[:ro\|rw], repeatable) | None | +| `-e`, `--env` | Set environment variable (KEY=VALUE or KEY to forward from host, repeatable) | None | +| `-k`, `--kernel` | Path to a Linux kernel | Auto-download | +| `--cpus` | Number of CPUs | 4 | +| `--memory` | Memory to allocate (e.g. `4g`, `512m`, `4096` for MB) | `4g` | +| `--allow-hosts` | Additional hostnames to allow (merged with agent defaults) | Agent defaults | +| `--no-network-filter` | Disable network filtering (allow unrestricted access) | Off | +| `--no-agent-mounts` | Skip mounts defined in the agent configuration | Off | +| `--name` | Persistent session name | Auto-generated | +| `--rm` | Remove instance after session ends | Off | +| `--reinstall` | Rebuild the cached environment from scratch | Off | +| `--ssh-agent` | Forward the host SSH agent socket into the container | Off | + +### `sandboxy edit ` + +Open an interactive shell in the agent's cached environment. Install packages, configure MCP tools, add language runtimes etc. +Changes are saved back to the cache when you exit. + +If no cache exists yet, the agent's install commands are run first. If any install step fails, you're dropped into the shell anyway so you can diagnose or finish the setup manually. + +```bash +sandboxy edit claude + +# Inside the container: +apt-get install -y python3-pip +pip3 install some-mcp-tool +exit # changes are saved +``` + +Every future `sandboxy run claude` will include your changes. + +### `sandboxy list` (alias: `ls`) + +Show sandbox instances and their status. + +```bash +sandboxy ls +``` + +### `sandboxy rm [...]` + +Remove one or more instances and their preserved state. + +```bash +# Remove a single instance +sandboxy rm my-feature + +# Remove multiple instances +sandboxy rm instance-1 instance-2 + +# Remove all instances +sandboxy rm --all +sandboxy rm -a +``` + +### `sandboxy cache list` + +Show cached environments and their disk usage. + +### `sandboxy cache rm ` + +Remove a specific agent's cached environment. The next run will rebuild it. + +### `sandboxy cache clean [--all] [--yes]` + +Remove all cached environments and named instance state. If named instances exist, you'll be prompted for confirmation. With `--yes`, skip the prompt. With `--all`, also removes the kernel, init image, and content store, forcing a full re-download on the next run. + +### `sandboxy config list` + +Print current configuration or agent definitions. + +```bash +# Print global defaults +sandboxy config list + +# Print a specific agent's definition +sandboxy config list --agent claude + +# List all available agents (built-in and custom) +sandboxy config list --agents + +# Print configuration file paths +sandboxy config list --paths +``` + +### `sandboxy config create` + +Create a default configuration or agent definition file. If the file already exists, you'll be prompted to confirm overwriting (use `--force` to skip). + +For built-in agents (e.g. `claude`), the file is pre-filled with the built-in definition so you have a working starting point to customize. + +```bash +# Create a global config.json with defaults +sandboxy config create + +# Create an override for the built-in claude agent +sandboxy config create --agent claude + +# Scaffold a new agent definition +sandboxy config create --agent myagent + +# Overwrite an existing definition without prompting +sandboxy config create --agent claude --force +``` + +## Instance Persistence + +Every session automatically saves its rootfs when it exits. The instance appears in `sandboxy ls` and can be resumed by passing its name to `--name`: + +```bash +# First run -- auto-named instance +sandboxy run claude +# => Instance claude-20260328-091522 saved. Resume with: sandboxy run claude --name claude-20260328-091522 + +# Resume it +sandboxy run --name claude-20260328-091522 claude + +# Or give it a memorable name upfront +sandboxy run --name my-feature claude + +# List all instances +sandboxy ls + +# Clean up +sandboxy rm my-feature + +# Ephemeral run (nothing saved) +sandboxy run --rm claude +``` + +Use `--rm` for throwaway sessions that shouldn't persist. + +## API Keys + +Agent definitions can include environment variable names without values (e.g. `"ANTHROPIC_API_KEY"`), which are automatically forwarded from the host if set. The built-in Claude Code agent forwards `ANTHROPIC_API_KEY` this way. For custom agents, add the relevant key name to the `environmentVariables` array, or pass it at runtime with `-e`. + +## Network Filtering + +Network filtering is enabled by default. Each agent definition includes an `allowedHosts` list. Additional hosts can be added at runtime with `--allow-hosts`. An empty `allowedHosts` list means all traffic is denied. To disable filtering entirely, pass `--no-network-filter`. + +The workload container runs on a **host-only network** with no internet route. A lightweight HTTP CONNECT proxy runs on the macOS host, bound to the host-only network's gateway address. The workload's proxy environment variables point at this address, so tools that respect `HTTP_PROXY`/`HTTPS_PROXY` route their traffic through the proxy automatically. + +Note that the proxy relies on applications honoring `HTTP_PROXY`/`HTTPS_PROXY` environment variables. Tools that ignore these variables won't be able to reach the internet since the container has no direct internet route. + +Agent toolchain installation (apt-get, npm install, etc.) runs with full network access on a shared network before the proxy is set up, so package repos don't need to be allowlisted. + +## Adding a New Agent + +Create a JSON file in the `agents/` directory, or use `sandboxy config create --agent ` to scaffold one: + +`~/.config/sandboxy/agents/.json` + +Example (`foo.json`): + +```json +{ + "displayName": "Foo", + "baseImage": "docker.io/library/python:3.12-slim", + "installCommands": [ + "pip install foo" + ], + "launchCommand": ["foo"], + "environmentVariables": [], + "mounts": [], + "allowedHosts": ["api.example.com", "*.cdn.example.com"] +} +``` + +Then run it with `sandboxy run foo`. + +To override a built-in agent, create a file with the same name (e.g., `claude.json`). Only the fields you include are overridden. Omitted fields keep their defaults. Use `sandboxy config list --agent claude` to see the full default definition. + +## Configuration + +Global defaults can be overridden with a config file: + +`~/.config/sandboxy/config.json` + +```json +{ + "dataDir": "/Volumes/fast/sandboxy", + "kernel": "/path/to/vmlinux", + "initfsReference": "ghcr.io/apple/containerization/vminit:0.26.5", + "defaultCPUs": 8, + "defaultMemory": "8g" +} +``` + +All fields are optional. Use `sandboxy config list` to see the defaults. + +## Kernel + +By default, `sandboxy` downloads a Linux kernel from the [Kata Containers](https://github.com/kata-containers/kata-containers) project (arm64 static release). The kernel is cached at `~/Library/Application Support/com.apple.containerization.sandboxy/kernel/vmlinux` and reused across all agent sessions. + +To use your own kernel, pass it directly: + +```bash +sandboxy run -k /path/to/vmlinux claude +``` + +Or set it permanently in `config.json`: + +```json +{ + "kernel": "/path/to/vmlinux" +} +``` + +The kernel must be an uncompressed Linux kernel binary (`vmlinux`, not `bzImage` or `zImage`) built for arm64 with virtio drivers enabled (virtio-net, virtio-blk, virtio-fs, virtio-console at minimum). + +## Building + +```bash +make build +``` + +The built binary is at `.build/release/sandboxy`. It requires macOS 26 on Apple silicon. diff --git a/examples/sandboxy/Sources/sandboxy/AgentDefinition.swift b/examples/sandboxy/Sources/sandboxy/AgentDefinition.swift new file mode 100644 index 000000000..38b8f4c87 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/AgentDefinition.swift @@ -0,0 +1,243 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Defines an AI coding agent that can be run inside a sandbox container. +/// +/// Agent definitions can be built-in or loaded from JSON files in the +/// `agents/` subdirectory of the sandboxy config directory. +/// +/// Location: `~/.config/sandboxy/agents/.json` +/// +/// Example (`foo.json`): +/// ```json +/// { +/// "displayName": "Foo", +/// "baseImage": "docker.io/library/python:3.12-slim", +/// "installCommands": [ +/// "pip install foo" +/// ], +/// "launchCommand": ["foo"], +/// "environmentVariables": [], +/// "mounts": [ +/// {"hostPath": "~/.foo", "containerPath": "/root/.foo", "readOnly": true} +/// ], +/// "allowedHosts": ["api.example.com", "*.cdn.example.com"] +/// } +/// ``` +struct AgentDefinition: Codable, Sendable { + /// Human-readable name used in output messages. + let displayName: String + + /// The base container image reference (e.g., "docker.io/library/node:22"). + let baseImage: String + + /// Shell commands run sequentially inside the container to install the agent + /// and its dependencies. Each string is passed as an argument to `sh -c`. + let installCommands: [String] + + /// The command and arguments to launch the agent interactively. + let launchCommand: [String] + + /// Environment variables required by the agent (key=value format). + let environmentVariables: [String] + + /// Host paths to mount into the container. Each entry specifies a host path + /// and a container path. Paths starting with `~` are expanded to the user's + /// home directory. Only mounted if the host path exists. + let mounts: [AgentMount] + + /// Default hostnames to allow through the network filtering proxy. + /// Supports exact matches and `*.suffix` wildcard patterns. + /// Merged with CLI `--allow-hosts` values. + /// An empty list means all traffic is denied. Use `--no-network-filter` to disable filtering. + let allowedHosts: [String] +} + +/// A host-to-container mount for an agent definition. +struct AgentMount: Codable, Sendable { + /// Path on the host. Supports `~` for the user's home directory. + let hostPath: String + + /// Path inside the container where the host path is mounted. + let containerPath: String + + /// Whether the mount is read-only. Defaults to `false`. + let readOnly: Bool + + init(hostPath: String, containerPath: String, readOnly: Bool = false) { + self.hostPath = hostPath + self.containerPath = containerPath + self.readOnly = readOnly + } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + hostPath = try container.decode(String.self, forKey: .hostPath) + containerPath = try container.decode(String.self, forKey: .containerPath) + readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + } + + /// Returns the resolved absolute host path, expanding `~`. + var resolvedHostPath: String { + if hostPath.hasPrefix("~/") { + let home = FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false) + return home + String(hostPath.dropFirst(1)) + } + if hostPath == "~" { + return FileManager.default.homeDirectoryForCurrentUser.path(percentEncoded: false) + } + return hostPath + } +} + +extension AgentDefinition { + /// Built-in agent definitions, keyed by their CLI name. + static let builtIn: [String: AgentDefinition] = [ + "claude": .claude + ] + + /// Returns all available agents: built-in definitions merged with any + /// user-defined agents from `/agents/`. If a user file matches + /// a built-in agent name, non-nil fields from the user file override the + /// built-in values, allowing partial overrides. + static func allAgents(configRoot: URL) -> [String: AgentDefinition] { + var agents = builtIn + + let agentsDir = configRoot.appendingPathComponent("agents") + let agentsDirPath = agentsDir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: agentsDirPath) else { + return agents + } + + do { + let files = try FileManager.default.contentsOfDirectory( + at: agentsDir, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" } + + let decoder = JSONDecoder() + for file in files { + let name = file.deletingPathExtension().lastPathComponent + do { + let data = try Data(contentsOf: file) + let override = try decoder.decode(AgentOverride.self, from: data) + if let base = agents[name] { + agents[name] = override.merged(onto: base) + } else { + agents[name] = try override.asFullDefinition() + } + } catch { + ProgressUI.printWarning( + "Failed to load agent definition from \(file.lastPathComponent): \(error)") + } + } + } catch { + ProgressUI.printWarning("Failed to read agents directory: \(error)") + } + + return agents + } + + /// Returns the sorted list of available agent names for display in help text. + static func knownAgentNames(configRoot: URL) -> [String] { + allAgents(configRoot: configRoot).keys.sorted() + } + + static let claude = AgentDefinition( + displayName: "Claude Code", + baseImage: "docker.io/library/node:22", + installCommands: [ + "apt-get update && apt-get install -y --no-install-recommends less git procps sudo fzf zsh man-db unzip gnupg2 gh ipset iproute2 dnsutils aggregate jq nano vim ripgrep ca-certificates && apt-get clean && rm -rf /var/lib/apt/lists/*", + "npm install -g @anthropic-ai/claude-code", + "npm install -g global-agent", + ], + launchCommand: ["claude", "--dangerously-skip-permissions"], + environmentVariables: [ + "ANTHROPIC_API_KEY", + "NODE_OPTIONS=--max-old-space-size=4096", + "IS_SANDBOX=1", + ], + mounts: [ + AgentMount(hostPath: "~/.claude", containerPath: "/root/.claude") + ], + allowedHosts: [ + "*.anthropic.com", + "*.claude.com", + "npm.org", + "*.npmjs.org", + "*.github.com", + "*.githubusercontent.com", + "*.pypi.org", + "*.pythonhosted.org", + ] + ) +} + +/// All-optional mirror of `AgentDefinition` used when loading user override files. +/// For agents that match a built-in name, only the non-nil fields override the defaults. +/// For entirely new agents, all required fields must be provided. +struct AgentOverride: Codable, Sendable { + var displayName: String? + var baseImage: String? + var installCommands: [String]? + var launchCommand: [String]? + var environmentVariables: [String]? + var mounts: [AgentMount]? + var allowedHosts: [String]? + + /// Merges this override onto a base definition, replacing only the fields + /// that are non-nil in the override. + func merged(onto base: AgentDefinition) -> AgentDefinition { + AgentDefinition( + displayName: displayName ?? base.displayName, + baseImage: baseImage ?? base.baseImage, + installCommands: installCommands ?? base.installCommands, + launchCommand: launchCommand ?? base.launchCommand, + environmentVariables: environmentVariables ?? base.environmentVariables, + mounts: mounts ?? base.mounts, + allowedHosts: allowedHosts ?? base.allowedHosts + ) + } + + /// Converts this override into a full definition, throwing if any required + /// fields are missing. Used for entirely new (non-built-in) agents. + func asFullDefinition() throws -> AgentDefinition { + guard let displayName, let baseImage, let installCommands, + let launchCommand + else { + throw SandboxyError.incompleteAgentDefinition( + missing: [ + displayName == nil ? "displayName" : nil, + baseImage == nil ? "baseImage" : nil, + installCommands == nil ? "installCommands" : nil, + launchCommand == nil ? "launchCommand" : nil, + ].compactMap { $0 } + ) + } + + return AgentDefinition( + displayName: displayName, + baseImage: baseImage, + installCommands: installCommands, + launchCommand: launchCommand, + environmentVariables: environmentVariables ?? [], + mounts: mounts ?? [], + allowedHosts: allowedHosts ?? [] + ) + } +} diff --git a/examples/sandboxy/Sources/sandboxy/CacheCommand.swift b/examples/sandboxy/Sources/sandboxy/CacheCommand.swift new file mode 100644 index 000000000..a9b94b2b4 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/CacheCommand.swift @@ -0,0 +1,263 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import Foundation + +extension Sandboxy { + struct Cache: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "cache", + abstract: "Manage cached rootfs images", + subcommands: [ + CacheList.self, + CacheRemove.self, + CacheClean.self, + ] + ) + } + + struct CacheList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List cached rootfs images and named instance state" + ) + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot) + + let agentCaches = listRootfsFiles(in: cacheDir, suffix: "-rootfs.ext4") + let namedCaches = listRootfsFiles(in: namedDir, suffix: "-rootfs.ext4") + + if agentCaches.isEmpty && namedCaches.isEmpty { + print("No cached images found.") + return + } + + if !agentCaches.isEmpty { + print("Agent caches:") + print(pad(" NAME", to: 20) + pad("SIZE", to: 12) + "MODIFIED") + for entry in agentCaches { + print( + pad(" \(entry.name)", to: 20) + + pad(formatBytes(entry.diskSize), to: 12) + + entry.modified + ) + } + } + + if !namedCaches.isEmpty { + if !agentCaches.isEmpty { print() } + print("Named instances:") + print(pad(" NAME", to: 20) + pad("SIZE", to: 12) + "MODIFIED") + for entry in namedCaches { + print( + pad(" \(entry.name)", to: 20) + + pad(formatBytes(entry.diskSize), to: 12) + + entry.modified + ) + } + } + } + + private struct CacheEntry { + let name: String + let diskSize: UInt64 + let modified: String + } + + private func listRootfsFiles(in dir: URL, suffix: String) -> [CacheEntry] { + let dirPath = dir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: dirPath) else { + return [] + } + + do { + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: [ + .totalFileAllocatedSizeKey, + .contentModificationDateKey, + ] + ).filter { $0.lastPathComponent.hasSuffix(suffix) } + + return files.compactMap { file -> CacheEntry? in + let cleanName = file.lastPathComponent + .replacingOccurrences(of: suffix, with: "") + + do { + let values = try file.resourceValues(forKeys: [ + .totalFileAllocatedSizeKey, + .contentModificationDateKey, + ]) + let diskSize = UInt64(values.totalFileAllocatedSize ?? 0) + let date = values.contentModificationDate ?? Date() + let formatter = DateFormatter() + formatter.dateStyle = .short + formatter.timeStyle = .short + return CacheEntry( + name: cleanName, + diskSize: diskSize, + modified: formatter.string(from: date) + ) + } catch { + return nil + } + }.sorted { $0.name < $1.name } + } catch { + return [] + } + } + + private func pad(_ s: String, to width: Int) -> String { + if s.count >= width { return s } + return s + String(repeating: " ", count: width - s.count) + } + + private func formatBytes(_ bytes: UInt64) -> String { + if bytes < 1024 { return "\(bytes) B" } + let kb = Double(bytes) / 1024 + if kb < 1024 { return String(format: "%.1f KB", kb) } + let mb = kb / 1024 + if mb < 1024 { return String(format: "%.1f MB", mb) } + let gb = mb / 1024 + return String(format: "%.1f GB", gb) + } + } + + struct CacheRemove: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "rm", + abstract: "Remove a specific agent cache" + ) + + @Argument(help: "Name of the agent cache to remove") + var name: String + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + let cachePath = cacheDir.appendingPathComponent("\(name)-rootfs.ext4") + + guard FileManager.default.fileExists(atPath: cachePath.path(percentEncoded: false)) else { + print("No cache found for agent '\(name)'.") + throw ExitCode.failure + } + + try FileManager.default.removeItem(at: cachePath) + print("Removed cache for '\(name)'.") + } + } + + struct CacheClean: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "clean", + abstract: "Remove all cached rootfs images (use --all to also remove kernel, init image, and content store)" + ) + + @Flag(name: .long, help: "Also remove kernel, init image, and content store, forcing a full re-download on next run") + var all = false + + @Flag(name: .long, help: "Skip confirmation prompts") + var yes = false + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let fm = FileManager.default + + // Check for named instances and warn the user before deleting them. + let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot) + let namedInstances = listNamedInstances(in: namedDir) + if !namedInstances.isEmpty && !yes { + print("This will also delete the following named instances:") + for name in namedInstances { + print(" - \(name)") + } + print() + print("Are you sure? [y/N] ", terminator: "") + guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else { + print("Aborted.") + return + } + } + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + if fm.fileExists(atPath: cacheDir.path(percentEncoded: false)) { + try fm.removeItem(at: cacheDir) + try fm.createDirectory(at: cacheDir, withIntermediateDirectories: true) + } + + if fm.fileExists(atPath: namedDir.path(percentEncoded: false)) { + try fm.removeItem(at: namedDir) + try fm.createDirectory(at: namedDir, withIntermediateDirectories: true) + } + + if all { + let kernelDir = Sandboxy.appRoot.appendingPathComponent("kernel") + if fm.fileExists(atPath: kernelDir.path(percentEncoded: false)) { + try fm.removeItem(at: kernelDir) + print("Removed kernel.") + } + + let initfs = Sandboxy.appRoot.appendingPathComponent("initfs.ext4") + if fm.fileExists(atPath: initfs.path(percentEncoded: false)) { + try fm.removeItem(at: initfs) + print("Removed init image.") + } + + let contentDir = Sandboxy.appRoot.appendingPathComponent("content") + if fm.fileExists(atPath: contentDir.path(percentEncoded: false)) { + try fm.removeItem(at: contentDir) + print("Removed content store.") + } + + // Remove the image store reference database so stale references + // don't point to missing content. + let stateFile = Sandboxy.appRoot.appendingPathComponent("state.json") + if fm.fileExists(atPath: stateFile.path(percentEncoded: false)) { + try fm.removeItem(at: stateFile) + } + + print("All caches and downloaded artifacts removed.") + } else { + print("All caches removed. Use --all to also remove kernel, init image, and content store.") + } + } + + private func listNamedInstances(in dir: URL) -> [String] { + let path = dir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: path) else { + return [] + } + do { + return try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ) + .filter { $0.lastPathComponent.hasSuffix("-rootfs.ext4") } + .map { $0.lastPathComponent.replacingOccurrences(of: "-rootfs.ext4", with: "") } + .sorted() + } catch { + return [] + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/ConfigCommand.swift b/examples/sandboxy/Sources/sandboxy/ConfigCommand.swift new file mode 100644 index 000000000..68e81c5c4 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/ConfigCommand.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import Foundation + +extension Sandboxy { + struct Config: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "config", + abstract: "View and create configuration files", + subcommands: [ + ConfigList.self, + ConfigCreate.self, + ] + ) + } + + struct ConfigList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "Print current configuration and agent definitions" + ) + + @Option(name: .long, help: "Print the definition for a specific agent") + var agent: String? + + @Flag(name: .long, help: "Print built-in defaults instead of the resolved configuration") + var defaults = false + + @Flag(name: .long, help: "Print configuration file paths") + var paths = false + + @Flag(name: .long, help: "List available agents") + var agents = false + + func run() async throws { + if paths { + let configPath = Sandboxy.configRoot.appendingPathComponent("config.json") + let agentsDir = Sandboxy.configRoot.appendingPathComponent("agents") + print("Config: \(configPath.path(percentEncoded: false))") + print("Agents: \(agentsDir.path(percentEncoded: false))") + print("Data: \(Sandboxy.appRoot.path(percentEncoded: false))") + return + } + + if agents { + let allAgents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + let builtInNames = Set(AgentDefinition.builtIn.keys) + for name in allAgents.keys.sorted() { + let definition = allAgents[name]! + let source = builtInNames.contains(name) ? "built-in" : "custom" + print(" \(name) - \(definition.displayName) (\(source))") + } + return + } + + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + if let agentName = agent { + let allAgents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + guard let definition = allAgents[agentName] else { + let available = allAgents.keys.sorted().joined(separator: ", ") + throw ValidationError( + "Unknown agent '\(agentName)'. Available agents: \(available)" + ) + } + let data = try encoder.encode(definition) + print(String(data: data, encoding: .utf8)!) + } else { + let config = defaults ? SandboxyConfig.defaults : try Sandboxy.loadConfig() + let data = try encoder.encode(config) + print(String(data: data, encoding: .utf8)!) + } + } + } + + struct ConfigCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a default configuration or agent definition file" + ) + + @Option(name: .long, help: "Create a definition file for a new agent with this name") + var agent: String? + + @Flag(name: .long, help: "Overwrite existing files without prompting") + var force = false + + func run() async throws { + let fm = FileManager.default + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + + if let agentName = agent { + let agentsDir = Sandboxy.configRoot.appendingPathComponent("agents") + try fm.createDirectory(at: agentsDir, withIntermediateDirectories: true) + + let filePath = agentsDir.appendingPathComponent("\(agentName).json") + if fm.fileExists(atPath: filePath.path(percentEncoded: false)) && !force { + guard isatty(STDIN_FILENO) != 0 else { + print("Agent definition already exists at \(filePath.path(percentEncoded: false)). Use --force to overwrite.") + throw ExitCode.failure + } + print("Agent definition already exists at \(filePath.path(percentEncoded: false))") + print("Overwrite? [y/N] ", terminator: "") + guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else { + print("Aborted.") + return + } + } + + let definition: AgentDefinition + if let builtIn = AgentDefinition.builtIn[agentName] { + definition = builtIn + } else { + definition = AgentDefinition( + displayName: agentName.capitalized, + baseImage: "docker.io/library/node:22", + installCommands: [], + launchCommand: [agentName], + environmentVariables: [], + mounts: [], + allowedHosts: [] + ) + } + + let data = try encoder.encode(definition) + try data.write(to: filePath, options: .atomic) + print("Created agent definition at \(filePath.path(percentEncoded: false))") + print() + print(String(data: data, encoding: .utf8)!) + } else { + let configPath = Sandboxy.configRoot.appendingPathComponent("config.json") + if fm.fileExists(atPath: configPath.path(percentEncoded: false)) && !force { + guard isatty(STDIN_FILENO) != 0 else { + print("Configuration file already exists at \(configPath.path(percentEncoded: false)). Use --force to overwrite.") + throw ExitCode.failure + } + print("Configuration file already exists at \(configPath.path(percentEncoded: false))") + print("Overwrite? [y/N] ", terminator: "") + guard let response = readLine()?.lowercased(), response == "y" || response == "yes" else { + print("Aborted.") + return + } + } + + try fm.createDirectory(at: Sandboxy.configRoot, withIntermediateDirectories: true) + + let data = try encoder.encode(SandboxyConfig.defaults) + try data.write(to: configPath, options: .atomic) + print("Created configuration file at \(configPath.path(percentEncoded: false))") + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/EditCommand.swift b/examples/sandboxy/Sources/sandboxy/EditCommand.swift new file mode 100644 index 000000000..d55a81df3 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/EditCommand.swift @@ -0,0 +1,245 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import Containerization +import ContainerizationExtras +import ContainerizationOS +import Foundation + +extension Sandboxy { + struct Edit: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "edit", + abstract: "Open an interactive shell in an agent's cached environment", + discussion: """ + Boots the cached rootfs for the given agent and drops you into a shell. + Any changes you make (installing packages, editing configs, etc.) are + saved back to the cache when you exit. If no cache exists, the agent + is installed from scratch first. + """ + ) + + @Argument(help: "Agent whose environment to edit (e.g. claude)") + var agent: String + + @Option( + name: [.customLong("kernel"), .customShort("k")], + help: "Path to Linux kernel binary (auto-downloads if omitted)", + completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + }) + var kernel: String? + + func run() async throws { + let config = try Sandboxy.loadConfig() + + let agents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + guard let definition = agents[agent] else { + let available = agents.keys.sorted().joined(separator: ", ") + throw ValidationError( + "Unknown agent '\(agent)'. Available agents: \(available)" + ) + } + + ProgressUI.printStatus("Opening \(definition.displayName) environment for editing...") + + let kernelPath = try await KernelManager.ensureKernel( + explicitPath: kernel, + appRoot: Sandboxy.appRoot, + config: config + ) + let vmKernel = Kernel(path: kernelPath, platform: .linuxArm) + + let enableNetworking: Bool + var sharedNetwork: VmnetNetwork? + if #available(macOS 26, *) { + sharedNetwork = try VmnetNetwork() + enableNetworking = true + } else { + sharedNetwork = nil + enableNetworking = false + } + + let vmnetMTU: UInt32 = 1400 + + let initfsReference = config.initfsReference ?? SandboxyConfig.defaults.initfsReference! + var manager = try await ContainerManager( + kernel: vmKernel, + initfsReference: initfsReference, + root: Sandboxy.appRoot + ) + + let containerId = "\(agent)-edit-\(ProcessInfo.processInfo.processIdentifier)" + + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + let agentCachePath = cacheDir.appendingPathComponent("\(agent)-rootfs.ext4") + let containerRootfsPath = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + .appendingPathComponent("rootfs.ext4") + + let hasCachedRootfs = FileManager.default.fileExists( + atPath: agentCachePath.path(percentEncoded: false)) + + let container: LinuxContainer + + if hasCachedRootfs { + ProgressUI.printDetail("Using cached environment...") + let containerDir = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + try FileManager.default.createDirectory( + at: containerDir, withIntermediateDirectories: true) + + let result = Darwin.clonefile( + agentCachePath.path(percentEncoded: false), + containerRootfsPath.path(percentEncoded: false), + 0 + ) + if result != 0 { + try FileManager.default.copyItem(at: agentCachePath, to: containerRootfsPath) + } + + let rootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/" + ) + + let image = try await Sandboxy.imageStore.get( + reference: definition.baseImage, pull: true) + + container = try await manager.create( + containerId, + image: image, + rootfs: rootfsMount, + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + config.cpus = 4 + config.memoryInBytes = 4096 * 1024 * 1024 + config.process.arguments = ["/bin/sleep", "infinity"] + config.process.workingDirectory = "/" + config.process.capabilities = .allCapabilities + config.useInit = true + } + } else { + ProgressUI.printDetail("No cached environment, setting up from scratch...") + container = try await manager.create( + containerId, + reference: definition.baseImage, + rootfsSizeInBytes: 512.gib(), + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + config.cpus = 4 + config.memoryInBytes = 4096 * 1024 * 1024 + config.process.arguments = ["/bin/sleep", "infinity"] + config.process.workingDirectory = "/" + config.process.capabilities = .allCapabilities + config.useInit = true + } + } + + do { + try await container.create() + try await container.start() + + // If no cache existed, run the agent's install commands first. + if !hasCachedRootfs { + ProgressUI.printStatus("Installing \(definition.displayName) toolchain...") + do { + try await installAgent(in: container, definition: definition) + ProgressUI.printStatus("Installation complete.") + } catch { + ProgressUI.printError("Installation failed: \(error)") + ProgressUI.printStatus("Dropping into shell...") + } + } + + // Drop into an interactive shell. + ProgressUI.printStatus("Launching shell (exit to save changes)...\n") + + let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH]) + let current = try Terminal.current + try current.setraw() + defer { current.tryReset() } + + let shellProcess = try await container.exec("edit-shell") { config in + config.arguments = ["/bin/bash"] + config.environmentVariables = [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "TERM=xterm", + "HOME=/root", + ] + config.workingDirectory = "/root" + config.setTerminalIO(terminal: current) + config.capabilities = .allCapabilities + } + + try await shellProcess.start() + try? await shellProcess.resize(to: try current.size) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await _ in sigwinchStream.signals { + try await shellProcess.resize(to: try current.size) + } + } + + _ = try await shellProcess.wait() + group.cancelAll() + try await shellProcess.delete() + } + + // Stop container so rootfs is cleanly unmounted before caching. + try await container.stop() + + // Save the modified rootfs back to the cache. + ProgressUI.printStatus("Saving changes to cache...") + removeIfExists(at: agentCachePath) + try FileManager.default.copyItem(at: containerRootfsPath, to: agentCachePath) + ProgressUI.printStatus("Done.") + + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + } catch { + do { + try await container.stop() + } catch { + log.warning("Failed to stop container \(containerId): \(error)") + } + do { + try manager.delete(containerId) + } catch { + log.warning("Failed to delete container \(containerId): \(error)") + } + try? sharedNetwork?.releaseInterface(containerId) + throw error + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/HostProxy.swift b/examples/sandboxy/Sources/sandboxy/HostProxy.swift new file mode 100644 index 000000000..af08eda1b --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/HostProxy.swift @@ -0,0 +1,460 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// Adapted from https://github.com/apple/swift-nio-examples/tree/main/connect-proxy + +import Foundation +import NIOCore +import NIOHTTP1 +import NIOPosix + +/// A lightweight HTTP proxy that runs on the host and filters by hostname. +/// +/// Binds to the host's gateway IP on a vmnet host-only network. Workload containers +/// that have no direct internet route use this proxy (via HTTP_PROXY/HTTPS_PROXY env vars) +/// to reach the outside world. Only hostnames matching the allowlist are permitted. +/// +/// Handles both HTTPS (via CONNECT tunneling) and plain HTTP (via request forwarding). +/// For HTTPS, the client sends a CONNECT request with the target hostname in plaintext +/// before TLS begins, so we can filter without any certificate interception. +final class HostProxy: @unchecked Sendable { + private let group: MultiThreadedEventLoopGroup + private let channel: any Channel + + /// The port the proxy is listening on. + let port: Int + + /// The host address the proxy is bound to. + let host: String + + /// Start a proxy bound to the given address. + /// - Parameters: + /// - host: IP address to bind to (e.g. the vmnet gateway IP). + /// - port: Port to bind to. Use 0 for an OS-assigned port. + /// - allowedHosts: Hostname patterns to allow. Supports `*.example.com` wildcards. + init(host: String, port: Int = 0, allowedHosts: [String]) async throws { + self.host = host + let group = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + self.group = group + + let bootstrap = ServerBootstrap(group: group) + .serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + ByteToMessageHandler(HTTPRequestDecoder(leftOverBytesStrategy: .forwardBytes)) + ) + try channel.pipeline.syncOperations.addHandler(HTTPResponseEncoder()) + try channel.pipeline.syncOperations.addHandler( + ConnectHandler(allowedHosts: allowedHosts) + ) + } + } + + let channel = try await bootstrap.bind( + to: SocketAddress(ipAddress: host, port: port) + ).get() + + guard let localAddress = channel.localAddress, let assignedPort = localAddress.port else { + throw SandboxyError.proxyFailed(reason: "could not determine proxy listen port") + } + + self.port = assignedPort + self.channel = channel + } + + /// Stop the proxy and release resources. + func stop() async throws { + try await channel.close() + try await group.shutdownGracefully() + } + + /// Check if a hostname matches any pattern in the allowlist. + static func isAllowed(host: String, allowedHosts: [String]) -> Bool { + let host = host.lowercased() + for pattern in allowedHosts { + let pattern = pattern.lowercased() + if pattern.hasPrefix("*.") { + let suffix = String(pattern.dropFirst(1)) // e.g. ".example.com" + if host == String(pattern.dropFirst(2)) || host.hasSuffix(suffix) { + return true + } + } else if host == pattern { + return true + } + } + return false + } +} + +/// Channel handler that processes HTTP CONNECT and plain HTTP proxy requests. +/// Checks the target hostname against an allowlist before connecting. +private final class ConnectHandler { + private var upgradeState: State + private let allowedHosts: [String] + + /// Buffered request for plain HTTP forwarding (nil for CONNECT). + private var pendingHTTPHead: HTTPRequestHead? + private var pendingHTTPBody: [ByteBuffer] = [] + + init(allowedHosts: [String]) { + self.upgradeState = .idle + self.allowedHosts = allowedHosts + } +} + +extension ConnectHandler { + fileprivate enum State { + case idle + case beganConnecting + case awaitingEnd(connectResult: Channel) + case awaitingConnection(pendingBytes: [NIOAny]) + case upgradeComplete(pendingBytes: [NIOAny]) + case upgradeFailed + } +} + +extension ConnectHandler: ChannelInboundHandler { + typealias InboundIn = HTTPServerRequestPart + typealias OutboundOut = HTTPServerResponsePart + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + switch self.upgradeState { + case .idle: + self.handleInitialMessage(context: context, data: self.unwrapInboundIn(data)) + + case .beganConnecting: + switch self.unwrapInboundIn(data) { + case .body(let body): + self.pendingHTTPBody.append(body) + case .end: + self.upgradeState = .awaitingConnection(pendingBytes: []) + self.removeDecoder(context: context) + default: + break + } + + case .awaitingEnd(let peerChannel): + switch self.unwrapInboundIn(data) { + case .body(let body): + self.pendingHTTPBody.append(body) + case .end: + self.upgradeState = .upgradeComplete(pendingBytes: []) + self.removeDecoder(context: context) + self.glue(peerChannel, context: context) + default: + break + } + + case .awaitingConnection(var pendingBytes): + self.upgradeState = .awaitingConnection(pendingBytes: []) + pendingBytes.append(data) + self.upgradeState = .awaitingConnection(pendingBytes: pendingBytes) + + case .upgradeComplete(var pendingBytes): + self.upgradeState = .upgradeComplete(pendingBytes: []) + pendingBytes.append(data) + self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes) + + case .upgradeFailed: + break + } + } +} + +extension ConnectHandler: RemovableChannelHandler { + func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { + var didRead = false + + while case .upgradeComplete(var pendingBytes) = self.upgradeState, pendingBytes.count > 0 { + self.upgradeState = .upgradeComplete(pendingBytes: []) + let nextRead = pendingBytes.removeFirst() + self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes) + + context.fireChannelRead(nextRead) + didRead = true + } + + if didRead { + context.fireChannelReadComplete() + } + + context.leavePipeline(removalToken: removalToken) + } +} + +extension ConnectHandler { + private func handleInitialMessage(context: ChannelHandlerContext, data: InboundIn) { + guard case .head(let head) = data else { + self.httpErrorAndClose(context: context, status: .badRequest) + return + } + + if head.method == .CONNECT { + // HTTPS: CONNECT host:port + let components = head.uri.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + let host = String(components.first!) + let port = components.last.flatMap { Int($0, radix: 10) } ?? 443 + + guard HostProxy.isAllowed(host: host, allowedHosts: self.allowedHosts) else { + self.httpErrorAndClose(context: context, status: .forbidden) + return + } + + self.upgradeState = .beganConnecting + self.connectTo(host: host, port: port, context: context) + } else { + // Plain HTTP: GET http://host/path, POST http://host/path, etc. + guard let url = URLComponents(string: head.uri), + let hostname = url.host, !hostname.isEmpty + else { + self.httpErrorAndClose(context: context, status: .badRequest) + return + } + + guard HostProxy.isAllowed(host: hostname, allowedHosts: self.allowedHosts) else { + self.httpErrorAndClose(context: context, status: .forbidden) + return + } + + let port = url.port ?? 80 + + // Rewrite URI from absolute (http://host/path) to relative (/path). + var relativePath = url.path + if relativePath.isEmpty { relativePath = "/" } + if let query = url.query { + relativePath += "?\(query)" + } + + var rewritten = head + rewritten.uri = relativePath + self.pendingHTTPHead = rewritten + + self.upgradeState = .beganConnecting + self.connectTo(host: hostname, port: port, context: context) + } + } + + private func connectTo(host: String, port: Int, context: ChannelHandlerContext) { + ClientBootstrap(group: context.eventLoop) + .connect(host: host, port: port).assumeIsolatedUnsafeUnchecked().whenComplete { result in + switch result { + case .success(let channel): + self.connectSucceeded(channel: channel, context: context) + case .failure(let error): + self.connectFailed(error: error, context: context) + } + } + } + + private func connectSucceeded(channel: Channel, context: ChannelHandlerContext) { + switch self.upgradeState { + case .beganConnecting: + self.upgradeState = .awaitingEnd(connectResult: channel) + + case .awaitingConnection(let pendingBytes): + self.upgradeState = .upgradeComplete(pendingBytes: pendingBytes) + self.glue(channel, context: context) + + case .awaitingEnd(let peerChannel): + peerChannel.close(mode: .all, promise: nil) + context.close(promise: nil) + + case .idle, .upgradeFailed, .upgradeComplete: + context.close(promise: nil) + } + } + + private func connectFailed(error: Error, context: ChannelHandlerContext) { + switch self.upgradeState { + case .beganConnecting, .awaitingConnection: + self.httpErrorAndClose(context: context, status: .badGateway) + + case .awaitingEnd(let peerChannel): + peerChannel.close(mode: .all, promise: nil) + context.close(promise: nil) + + case .idle, .upgradeFailed, .upgradeComplete: + context.close(promise: nil) + } + + context.fireErrorCaught(error) + } + + private func glue(_ peerChannel: Channel, context: ChannelHandlerContext) { + if let httpHead = self.pendingHTTPHead { + // Plain HTTP: forward the buffered request to the peer, then glue. + var buffer = context.channel.allocator.buffer(capacity: 256) + buffer.writeString("\(httpHead.method) \(httpHead.uri) HTTP/\(httpHead.version.major).\(httpHead.version.minor)\r\n") + for (name, value) in httpHead.headers { + buffer.writeString("\(name): \(value)\r\n") + } + buffer.writeString("\r\n") + for var body in self.pendingHTTPBody { + buffer.writeBuffer(&body) + } + peerChannel.writeAndFlush(buffer, promise: nil) + self.pendingHTTPHead = nil + self.pendingHTTPBody = [] + } else { + // CONNECT: send 200 OK to the client. + // Content-Length: 0 prevents the encoder from adding chunked transfer encoding, + // which would inject a chunked terminator into the raw tunnel and break TLS. + let headers = HTTPHeaders([("Content-Length", "0")]) + let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: .ok, headers: headers) + context.write(self.wrapOutboundOut(.head(head)), promise: nil) + context.writeAndFlush(self.wrapOutboundOut(.end(nil)), promise: nil) + } + + self.removeEncoder(context: context) + + let (localGlue, peerGlue) = GlueHandler.matchedPair() + do { + try context.channel.pipeline.syncOperations.addHandler(localGlue) + try peerChannel.pipeline.syncOperations.addHandler(peerGlue) + context.pipeline.syncOperations.removeHandler(self, promise: nil) + } catch { + peerChannel.close(mode: .all, promise: nil) + context.close(promise: nil) + } + } + + private func httpErrorAndClose(context: ChannelHandlerContext, status: HTTPResponseStatus) { + self.upgradeState = .upgradeFailed + + let headers = HTTPHeaders([("Content-Length", "0"), ("Connection", "close")]) + let head = HTTPResponseHead(version: .init(major: 1, minor: 1), status: status, headers: headers) + context.write(self.wrapOutboundOut(.head(head)), promise: nil) + context.writeAndFlush(self.wrapOutboundOut(.end(nil))).assumeIsolatedUnsafeUnchecked().whenComplete { + (_: Result) in + context.close(mode: .output, promise: nil) + } + } + + private func removeDecoder(context: ChannelHandlerContext) { + if let ctx = try? context.pipeline.syncOperations.context( + handlerType: ByteToMessageHandler.self + ) { + context.pipeline.syncOperations.removeHandler(context: ctx, promise: nil) + } + } + + private func removeEncoder(context: ChannelHandlerContext) { + if let ctx = try? context.pipeline.syncOperations.context( + handlerType: HTTPResponseEncoder.self + ) { + context.pipeline.syncOperations.removeHandler(context: ctx, promise: nil) + } + } +} + +/// Bidirectional relay handler that glues two channels together. +private final class GlueHandler { + private var partner: GlueHandler? + private var context: ChannelHandlerContext? + private var pendingRead: Bool = false + + private init() {} + + static func matchedPair() -> (GlueHandler, GlueHandler) { + let first = GlueHandler() + let second = GlueHandler() + first.partner = second + second.partner = first + return (first, second) + } +} + +extension GlueHandler { + fileprivate func partnerWrite(_ data: NIOAny) { + self.context?.write(data, promise: nil) + } + + fileprivate func partnerFlush() { + self.context?.flush() + } + + fileprivate func partnerWriteEOF() { + self.context?.close(mode: .output, promise: nil) + } + + fileprivate func partnerCloseFull() { + self.context?.close(promise: nil) + } + + fileprivate func partnerBecameWritable() { + if self.pendingRead { + self.pendingRead = false + self.context?.read() + } + } + + fileprivate var partnerWritable: Bool { + self.context?.channel.isWritable ?? false + } +} + +extension GlueHandler: ChannelDuplexHandler { + typealias InboundIn = NIOAny + typealias OutboundIn = NIOAny + typealias OutboundOut = NIOAny + + func handlerAdded(context: ChannelHandlerContext) { + self.context = context + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.context = nil + self.partner = nil + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + self.partner?.partnerWrite(data) + } + + func channelReadComplete(context: ChannelHandlerContext) { + self.partner?.partnerFlush() + } + + func channelInactive(context: ChannelHandlerContext) { + self.partner?.partnerCloseFull() + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let event = event as? ChannelEvent, case .inputClosed = event { + self.partner?.partnerWriteEOF() + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + self.partner?.partnerCloseFull() + } + + func channelWritabilityChanged(context: ChannelHandlerContext) { + if context.channel.isWritable { + self.partner?.partnerBecameWritable() + } + } + + func read(context: ChannelHandlerContext) { + if let partner = self.partner, partner.partnerWritable { + context.read() + } else { + self.pendingRead = true + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/InstanceState.swift b/examples/sandboxy/Sources/sandboxy/InstanceState.swift new file mode 100644 index 000000000..333be1e01 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/InstanceState.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Persistent metadata about a sandbox instance. +struct InstanceState: Codable, Sendable { + let id: String + /// User-provided name for persistent instances. Nil for ephemeral runs. + let name: String? + let agent: String + let workspace: String + let status: Status + let createdAt: Date + var stoppedAt: Date? + let cpus: Int + let memoryMB: UInt64 + + enum Status: String, Codable, Sendable { + case running + case stopped + } + + /// Whether this is a named (persistent) instance. + var isNamed: Bool { name != nil } + + /// Directory where instance state files are stored. + static func instancesDir(appRoot: URL) -> URL { + appRoot.appendingPathComponent("instances") + } + + /// Directory where named instance rootfs files are preserved. + static func namedRootfsDir(appRoot: URL) -> URL { + appRoot.appendingPathComponent("named") + } + + /// Path to the preserved rootfs for a named instance. + static func namedRootfsPath(appRoot: URL, name: String) -> URL { + namedRootfsDir(appRoot: appRoot).appendingPathComponent("\(name)-rootfs.ext4") + } + + /// Path to this instance's state file. + func statePath(appRoot: URL) -> URL { + Self.instancesDir(appRoot: appRoot).appendingPathComponent("\(id).json") + } + + /// Saves this instance state to disk. + func save(appRoot: URL) throws { + let dir = Self.instancesDir(appRoot: appRoot) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + encoder.outputFormatting = .prettyPrinted + let data = try encoder.encode(self) + try data.write(to: statePath(appRoot: appRoot)) + } + + /// Loads all instance states from disk. + static func loadAll(appRoot: URL) throws -> [InstanceState] { + let dir = instancesDir(appRoot: appRoot) + let path = dir.path(percentEncoded: false) + guard FileManager.default.fileExists(atPath: path) else { + return [] + } + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + + let files = try FileManager.default.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: nil + ).filter { $0.pathExtension == "json" } + + return files.compactMap { file -> InstanceState? in + do { + let data = try Data(contentsOf: file) + return try decoder.decode(InstanceState.self, from: data) + } catch { + log.warning("Failed to load instance state from \(file.lastPathComponent): \(error)") + return nil + } + } + } + + /// Finds a named instance by name. + static func find(name: String, appRoot: URL) throws -> InstanceState? { + try loadAll(appRoot: appRoot).first { $0.name == name } + } + + /// Removes this instance's state file from disk. + func remove(appRoot: URL) throws { + try FileManager.default.removeItem(at: statePath(appRoot: appRoot)) + } + + /// Removes this instance's state file and preserved rootfs (for named instances). + func removeAll(appRoot: URL) throws { + try remove(appRoot: appRoot) + if let name { + let rootfs = Self.namedRootfsPath(appRoot: appRoot, name: name) + let path = rootfs.path(percentEncoded: false) + if FileManager.default.fileExists(atPath: path) { + try FileManager.default.removeItem(at: rootfs) + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/KernelManager.swift b/examples/sandboxy/Sources/sandboxy/KernelManager.swift new file mode 100644 index 000000000..fd4bcafee --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/KernelManager.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// 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 AsyncHTTPClient +import ContainerizationArchive +import ContainerizationExtras +import Foundation + +enum KernelManager { + // Hardcoded default kernel source (Kata Containers arm64 static release). + private static let defaultKernelURL = + "https://github.com/kata-containers/kata-containers/releases/download/3.26.0/kata-static-3.26.0-arm64.tar.zst" + private static let defaultKernelPathInTarball = "opt/kata/share/kata-containers/vmlinux.container" + + /// Ensures a kernel binary is available, returning its path. + /// + /// Resolution order: + /// 1. CLI flag (`-k` / `--kernel`) + /// 2. Config file (`"kernel"` field) + /// 3. Cached kernel at `appRoot/kernel/vmlinux` + /// 4. Auto-download from Kata Containers + static func ensureKernel(explicitPath: String?, appRoot: URL, config: SandboxyConfig) async throws -> URL { + // 1. CLI flag takes priority. + if let explicitPath { + let url = URL(fileURLWithPath: explicitPath) + guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { + throw SandboxyError.kernelNotFound(path: explicitPath) + } + return url + } + + // 2. Config file path. + if let configKernel = config.kernel { + let url = URL(fileURLWithPath: configKernel) + guard FileManager.default.fileExists(atPath: url.path(percentEncoded: false)) else { + throw SandboxyError.kernelNotFound(path: configKernel) + } + return url + } + + // 3. Cached kernel. + let kernelDir = appRoot.appendingPathComponent("kernel") + let kernelPath = kernelDir.appendingPathComponent("vmlinux") + + if FileManager.default.fileExists(atPath: kernelPath.path(percentEncoded: false)) { + return kernelPath + } + + // 4. Auto-download. + try FileManager.default.createDirectory(at: kernelDir, withIntermediateDirectories: true) + + let progressConfig = try ProgressConfig( + description: "Downloading kernel", + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { progress.finish() } + progress.start() + + let tarballPath = kernelDir.appendingPathComponent("kata.tar.zst") + try await downloadFile(from: defaultKernelURL, to: tarballPath, progress: progress) + + progress.set(description: "Extracting kernel") + try extractKernel(from: tarballPath, kernelPathInTarball: defaultKernelPathInTarball, to: kernelPath) + + try FileManager.default.removeItem(at: tarballPath) + + return kernelPath + } + + private static func downloadFile(from urlString: String, to destination: URL, progress: ProgressBar) async throws { + guard let url = URL(string: urlString) else { + throw SandboxyError.kernelDownloadFailed(reason: "invalid URL: \(urlString)") + } + + let delegate = try FileDownloadDelegate( + path: destination.path(percentEncoded: false), + reportHead: { head in + if let contentLength = head.headers["Content-Length"].first, let totalBytes = Int64(contentLength) { + progress.add(totalSize: totalBytes) + } + }, + reportProgress: { progressUpdate in + progress.set(size: Int64(progressUpdate.receivedBytes)) + } + ) + + let request = try HTTPClient.Request(url: url) + let client = createClient(url: url) + do { + _ = try await client.execute(request: request, delegate: delegate).get() + } catch { + try? await client.shutdown() + throw error + } + try await client.shutdown() + } + + private static func createClient(url: URL) -> HTTPClient { + var httpConfiguration = HTTPClient.Configuration() + httpConfiguration.timeout = HTTPClient.Configuration.Timeout( + connect: .seconds(30), + read: .none + ) + if let host = url.host { + let proxyURL = ProxyUtils.proxyFromEnvironment(scheme: url.scheme, host: host) + if let proxyURL, let proxyHost = proxyURL.host { + httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyURL.port ?? 8080) + } + } + + return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) + } + + private static func extractKernel(from tarball: URL, kernelPathInTarball: String, to destination: URL) throws { + var target = kernelPathInTarball + var reader = try ArchiveReader(file: tarball) + var (entry, data) = try reader.extractFile(path: target) + + // If the target file is a symlink, get the data for the actual file. + if entry.fileType == .symbolicLink, let symlinkRelative = entry.symlinkTarget { + reader = try ArchiveReader(file: tarball) + let symlinkTarget = URL(filePath: target).deletingLastPathComponent().appending(path: symlinkRelative) + + // Standardize so that we remove any and all ../ and ./ in the path since symlink targets + // are relative paths to the target file from the symlink's parent dir itself. + target = symlinkTarget.standardized.relativePath + let (_, targetData) = try reader.extractFile(path: target) + data = targetData + } + + try data.write(to: destination, options: .atomic) + + try FileManager.default.setAttributes( + [.posixPermissions: 0o755], + ofItemAtPath: destination.path(percentEncoded: false) + ) + } +} diff --git a/examples/sandboxy/Sources/sandboxy/ListCommand.swift b/examples/sandboxy/Sources/sandboxy/ListCommand.swift new file mode 100644 index 000000000..1a15ff490 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/ListCommand.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import Foundation + +extension Sandboxy { + struct List: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List sandbox instances", + aliases: ["ls"] + ) + + func run() async throws { + _ = try Sandboxy.loadConfig() + + let instances = try InstanceState.loadAll(appRoot: Sandboxy.appRoot) + + if instances.isEmpty { + print("No sandbox instances found.") + return + } + + // Deduplicate named instances, keeping only the most recent entry per name. + var seen = Set() + var deduplicated: [InstanceState] = [] + let sorted = instances.sorted { $0.createdAt > $1.createdAt } + for instance in sorted { + if let name = instance.name { + if seen.contains(name) { continue } + seen.insert(name) + } + deduplicated.append(instance) + } + + print( + pad("NAME", to: 30) + + pad("AGENT", to: 12) + + pad("STATUS", to: 12) + + pad("CREATED", to: 12) + + "WORKSPACE" + ) + + for instance in deduplicated { + let age = relativeTime(from: instance.createdAt) + let displayName = instance.name ?? "-" + print( + pad(truncate(displayName, to: 28), to: 30) + + pad(instance.agent, to: 12) + + pad(instance.status.rawValue, to: 12) + + pad(age, to: 12) + + instance.workspace + ) + } + } + + private func pad(_ s: String, to width: Int) -> String { + if s.count >= width { + return s + } + return s + String(repeating: " ", count: width - s.count) + } + + private func truncate(_ s: String, to maxLen: Int) -> String { + if s.count <= maxLen { return s } + return "..." + String(s.suffix(maxLen - 3)) + } + + private func relativeTime(from date: Date) -> String { + let seconds = Int(Date().timeIntervalSince(date)) + if seconds < 60 { return "\(seconds)s ago" } + let minutes = seconds / 60 + if minutes < 60 { return "\(minutes)m ago" } + let hours = minutes / 60 + if hours < 24 { return "\(hours)h ago" } + let days = hours / 24 + return "\(days)d ago" + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/OutputCapture.swift b/examples/sandboxy/Sources/sandboxy/OutputCapture.swift new file mode 100644 index 000000000..4f9991732 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/OutputCapture.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import Foundation +import Synchronization + +/// A Writer that captures output into a Data buffer and optionally streams it. +final class OutputCapture: Writer, Sendable { + private let storage = Mutex(Data()) + private let streamTo: FileHandle? + + var data: Data { + storage.withLock { $0 } + } + + init(streamToStdout: Bool = false, streamToStderr: Bool = false) { + if streamToStdout { + self.streamTo = .standardOutput + } else if streamToStderr { + self.streamTo = .standardError + } else { + self.streamTo = nil + } + } + + func write(_ data: Data) throws { + guard data.count > 0 else { return } + storage.withLock { $0.append(data) } + streamTo?.write(data) + } + + func close() throws {} +} diff --git a/examples/sandboxy/Sources/sandboxy/ProgressUI.swift b/examples/sandboxy/Sources/sandboxy/ProgressUI.swift new file mode 100644 index 000000000..10d5f404b --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/ProgressUI.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Status output helpers that write to stderr to keep stdout clean for the agent's terminal IO. +enum ProgressUI { + private static let boxLines = [ + "┌──────────────┐", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "│ ░░░░░░░░░░░░ │", + "└──────────────┘", + ] + + /// Prints the logo with info lines displayed to the right of the box. + static func printLogo(info: [String] = []) { + let yellow = "\u{1b}[33m" + let reset = "\u{1b}[0m" + let gap = " " + + for (i, boxLine) in boxLines.enumerated() { + let coloredBox = "\(yellow)\(boxLine)\(reset)" + if i < info.count { + FileHandle.standardError.write(Data(" \(coloredBox)\(gap)\(info[i])\n".utf8)) + } else { + FileHandle.standardError.write(Data(" \(coloredBox)\n".utf8)) + } + } + // Print any remaining info lines that don't fit beside the box. + if info.count > boxLines.count { + for j in boxLines.count.. \(message)\u{1b}[0m\n".utf8)) + } + + static func printDetail(_ message: String) { + FileHandle.standardError.write(Data("\u{1b}[32m \(message)\u{1b}[0m\n".utf8)) + } + + static func printWarning(_ message: String) { + FileHandle.standardError.write(Data("warning: \(message)\n".utf8)) + } + + static func printError(_ message: String) { + FileHandle.standardError.write(Data("\u{1b}[31merror: \(message)\u{1b}[0m\n".utf8)) + } +} diff --git a/examples/sandboxy/Sources/sandboxy/RemoveCommand.swift b/examples/sandboxy/Sources/sandboxy/RemoveCommand.swift new file mode 100644 index 000000000..3768f9968 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/RemoveCommand.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import Foundation + +extension Sandboxy { + struct Remove: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "rm", + abstract: "Remove sandbox instances and their preserved state" + ) + + @Argument(help: "Name of the instance to remove") + var names: [String] = [] + + @Flag(name: [.customShort("a"), .long], help: "Remove all instances") + var all: Bool = false + + func run() async throws { + _ = try Sandboxy.loadConfig() + + if all { + let instances = try InstanceState.loadAll(appRoot: Sandboxy.appRoot) + if instances.isEmpty { + print("No instances to remove.") + return + } + for instance in instances { + let displayName = instance.name ?? instance.id + do { + try instance.removeAll(appRoot: Sandboxy.appRoot) + print("Removed instance '\(displayName)'.") + } catch { + print("Failed to remove instance '\(displayName)': \(error)") + } + } + return + } + + guard !names.isEmpty else { + print("Specify instance name(s) to remove, or use --all (-a).") + throw ExitCode.failure + } + + for name in names { + guard let instance = try InstanceState.find(name: name, appRoot: Sandboxy.appRoot) else { + print("No instance named '\(name)' found.") + continue + } + try instance.removeAll(appRoot: Sandboxy.appRoot) + print("Removed instance '\(name)'.") + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/RunAgentCommand.swift b/examples/sandboxy/Sources/sandboxy/RunAgentCommand.swift new file mode 100644 index 000000000..cdad0d318 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/RunAgentCommand.swift @@ -0,0 +1,1000 @@ +//===----------------------------------------------------------------------===// +// 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 ArgumentParser +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import vmnet + +extension Sandboxy { + struct Run: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run an AI coding agent in a sandboxed Linux container", + discussion: """ + Available agents are determined by built-in definitions and any custom + agent JSON files in the agents/ subdirectory of the sandboxy application + support directory. + """ + ) + + @OptionGroup var options: AgentOptions + + @Argument(help: "Agent to run (e.g. claude)") + var agent: String + + @Argument(parsing: .captureForPassthrough) + var passthroughArgs: [String] = [] + + func run() async throws { + let config = try Sandboxy.loadConfig() + + let agents = AgentDefinition.allAgents(configRoot: Sandboxy.configRoot) + guard let definition = agents[agent] else { + let available = agents.keys.sorted().joined(separator: ", ") + throw ValidationError( + "Unknown agent '\(agent)'. Available agents: \(available)" + ) + } + + try await runAgent( + config: config, + agentName: agent, + definition: definition, + options: options, + passthroughArgs: passthroughArgs + ) + } + } +} + +struct AgentOptions: ParsableArguments { + @Option( + name: [.customLong("workspace"), .customShort("w")], + help: "Workspace directory on the host (defaults to current directory)", + completion: .directory, + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + }) + var workspace: String? + + @Option(name: .long, help: "Number of CPUs to allocate") + var cpus: Int = 4 + + @Option(name: .long, help: "Memory to allocate (e.g. 4g, 512m, 4096 for MB)") + var memory: String = "4g" + + /// Parses the memory string into bytes. Supports suffixes: b, k/kb, m/mb, g/gb, t/tb. + /// A bare number is treated as megabytes for backward compatibility. + var memoryBytes: UInt64 { + get throws { + let str = memory.lowercased().trimmingCharacters(in: .whitespaces) + guard !str.isEmpty else { + throw ValidationError("Memory value cannot be empty") + } + + let suffixes: [(String, UInt64)] = [ + ("tb", 1024 * 1024 * 1024 * 1024), + ("gb", 1024 * 1024 * 1024), + ("mb", 1024 * 1024), + ("kb", 1024), + ("t", 1024 * 1024 * 1024 * 1024), + ("g", 1024 * 1024 * 1024), + ("m", 1024 * 1024), + ("k", 1024), + ("b", 1), + ] + + for (suffix, multiplier) in suffixes { + if str.hasSuffix(suffix) { + let numStr = String(str.dropLast(suffix.count)) + guard let value = Double(numStr), value > 0 else { + throw ValidationError("Invalid memory value: \(memory)") + } + return UInt64(value * Double(multiplier)) + } + } + + // Bare number: treat as megabytes. + guard let value = Double(str), value > 0 else { + throw ValidationError("Invalid memory value: \(memory)") + } + return UInt64(value * 1024 * 1024) + } + } + + /// Returns a human-readable memory string (e.g. "4 GB", "512 MB"). + var memoryDisplay: String { + get throws { + let bytes = try memoryBytes + if bytes >= 1024 * 1024 * 1024 && bytes % (1024 * 1024 * 1024) == 0 { + return "\(bytes / (1024 * 1024 * 1024)) GB" + } else if bytes >= 1024 * 1024 { + return "\(bytes / (1024 * 1024)) MB" + } else if bytes >= 1024 { + return "\(bytes / 1024) KB" + } + return "\(bytes) B" + } + } + + @Option( + name: .long, parsing: .upToNextOption, + help: "Hostnames to allow through the HTTP proxy") + var allowHosts: [String] = [] + + @Flag(name: .long, help: "Disable network filtering (allow unrestricted network access)") + var noNetworkFilter: Bool = false + + @Flag(name: .long, help: "Force reinstall of agent (ignore cached rootfs)") + var reinstall: Bool = false + + @Flag(name: .long, help: "Forward the host SSH agent socket into the container") + var sshAgent: Bool = false + + @Flag(name: .long, help: "Skip mounts defined in the agent configuration") + var noAgentMounts: Bool = false + + @Flag(name: .customLong("rm"), help: "Automatically remove the instance after the session ends") + var removeAfterRun: Bool = false + + @Option( + name: [.customLong("mount"), .customShort("m")], + parsing: .singleValue, + help: "Additional mount in hostpath:containerpath[:ro|rw] format (repeatable)") + var mount: [String] = [] + + @Option( + name: [.customLong("env"), .customShort("e")], + parsing: .singleValue, + help: "Set environment variable (KEY=VALUE or KEY to forward from host, repeatable)") + var env: [String] = [] + + @Option( + name: .long, + help: "Name for a persistent instance (preserves rootfs and resumes conversation)") + var name: String? + + @Option( + name: [.customLong("kernel"), .customShort("k")], + help: "Path to Linux kernel binary (auto-downloads if omitted)", + completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + }) + var kernel: String? +} + +func runAgent( + config: SandboxyConfig, + agentName: String, + definition: AgentDefinition, + options: AgentOptions, + passthroughArgs: [String] +) async throws { + signal(SIGINT) { _ in + var termios = termios() + tcgetattr(STDIN_FILENO, &termios) + termios.c_lflag |= UInt(ECHO | ICANON) + tcsetattr(STDIN_FILENO, TCSANOW, &termios) + write(STDERR_FILENO, "\u{001B}[?25h", 6) + _exit(130) + } + + let hostWorkspacePath = options.workspace ?? FileManager.default.currentDirectoryPath + let guestWorkspacePath = hostWorkspacePath + let extraMounts = try options.mount.map { try MountSpec.parse($0) } + let extraEnvVars = try options.env.map { try EnvSpec.resolve($0) } + + // Determine instance name: use --name if provided, otherwise auto-generate. + let instanceName: String + if let name = options.name { + instanceName = name + } else { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd-HHmmss" + instanceName = "\(agentName)-\(formatter.string(from: Date()))" + } + + if let old = try InstanceState.find(name: instanceName, appRoot: Sandboxy.appRoot) { + try? old.remove(appRoot: Sandboxy.appRoot) + } + + // Check cache age for display. + let cacheDir = Sandboxy.appRoot.appendingPathComponent("cache") + let agentCachePath = cacheDir.appendingPathComponent("\(agentName)-rootfs.ext4") + let cacheAgeLine: String + if let attrs = try? FileManager.default.attributesOfItem(atPath: agentCachePath.path(percentEncoded: false)), + let created = attrs[.creationDate] as? Date + { + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .full + let age = formatter.localizedString(for: created, relativeTo: Date()) + let capitalizedAge = age.prefix(1).uppercased() + age.dropFirst() + cacheAgeLine = "\u{1b}[1mEnvironment:\u{1b}[0m \(capitalizedAge)" + } else { + cacheAgeLine = "\u{1b}[1mEnvironment:\u{1b}[0m Not yet installed" + } + + ProgressUI.printLogo(info: [ + "", + "\u{1b}[1mSandboxy\u{1b}[0m", + "\u{1b}[1mAgent:\u{1b}[0m \(definition.displayName)", + "\u{1b}[1mInstance:\u{1b}[0m \(instanceName)", + cacheAgeLine, + "\u{1b}[1mWorkspace:\u{1b}[0m \(hostWorkspacePath)", + "\u{1b}[1mCPUs:\u{1b}[0m \(options.cpus) \u{1b}[1mMemory:\u{1b}[0m \(try options.memoryDisplay)", + ]) + + let kernelPath = try await KernelManager.ensureKernel( + explicitPath: options.kernel, + appRoot: Sandboxy.appRoot, + config: config + ) + guard FileManager.default.fileExists(atPath: kernelPath.path(percentEncoded: false)) else { + throw SandboxyError.kernelNotFound(path: kernelPath.path(percentEncoded: false)) + } + let kernel = Kernel(path: kernelPath, platform: .linuxArm) + + // Merge allowed hosts from agent definition and CLI flags. + var allowedHosts = definition.allowedHosts + allowedHosts.append(contentsOf: options.allowHosts) + let filteringEnabled = !options.noNetworkFilter + + let filteredPassthroughArgs = passthroughArgs.filter { $0 != "--" } + + var fullCommand = definition.launchCommand + fullCommand.append(contentsOf: filteredPassthroughArgs) + ProgressUI.printDetail("\u{1b}[1mCommand:\u{1b}[0m \(fullCommand.joined(separator: " "))") + + if filteringEnabled { + if allowedHosts.isEmpty { + ProgressUI.printDetail("\u{1b}[1mAllowed hosts:\u{1b}[0m\u{1b}[33m none (all traffic denied)") + } else { + let hostList = allowedHosts.joined(separator: ", ") + ProgressUI.printDetail("\u{1b}[1mAllowed hosts:\u{1b}[0m\u{1b}[32m \(hostList)") + } + } else { + ProgressUI.printDetail("\u{1b}[1mAllowed hosts:\u{1b}[0m\u{1b}[32m unrestricted") + } + + // Log mounts. + ProgressUI.printDetail("\u{1b}[1mMounts:\u{1b}[0m") + ProgressUI.printDetail(" \(hostWorkspacePath) -> \(guestWorkspacePath)") + if options.noAgentMounts { + for agentMount in definition.mounts { + let ro = agentMount.readOnly ? " (ro)" : "" + ProgressUI.printDetail(" \(agentMount.resolvedHostPath) -> \(agentMount.containerPath)\(ro) \u{1b}[33m(skipped, --no-agent-mounts)\u{1b}[0m") + } + } else { + for agentMount in definition.mounts { + let hostPath = agentMount.resolvedHostPath + let ro = agentMount.readOnly ? " (ro)" : "" + if FileManager.default.fileExists(atPath: hostPath) { + ProgressUI.printDetail(" \(hostPath) -> \(agentMount.containerPath)\(ro)") + } else { + ProgressUI.printDetail(" \(hostPath) -> \(agentMount.containerPath)\(ro) \u{1b}[33m(skipped, host path not found)\u{1b}[0m") + } + } + } + for mountSpec in extraMounts { + let ro = mountSpec.readOnly ? " (ro)" : "" + ProgressUI.printDetail(" \(mountSpec.hostPath) -> \(mountSpec.containerPath)\(ro)") + } + + // Setup networking. + let enableNetworking: Bool + var sharedNetwork: VmnetNetwork? + if #available(macOS 26, *) { + sharedNetwork = try VmnetNetwork() + enableNetworking = true + } else { + sharedNetwork = nil + enableNetworking = false + } + + // Pull the init image with progress if it hasn't been cached yet. + let initfsReference = config.initfsReference ?? SandboxyConfig.defaults.initfsReference! + _ = try await pullImageWithProgress(reference: initfsReference) + + var manager = try await ContainerManager( + kernel: kernel, + initfsReference: initfsReference, + root: Sandboxy.appRoot + ) + + /// MTU for vmnet interfaces. Lowered from the default 1500 to avoid + /// PMTU black-hole issues on networks that block ICMP fragmentation-needed. + let vmnetMTU: UInt32 = 1400 + + let containerId = "\(agentName)-\(ProcessInfo.processInfo.processIdentifier)" + + // + // Workload container setup + // + + // Determine rootfs source: named instance > agent cache > fresh install. + try FileManager.default.createDirectory(at: cacheDir, withIntermediateDirectories: true) + let containerRootfsPath = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + .appendingPathComponent("rootfs.ext4") + + let sourceRootfs: URL? + var needsInstall = false + + if options.reinstall { + removeIfExists(at: agentCachePath) + removeIfExists( + at: InstanceState.namedRootfsPath(appRoot: Sandboxy.appRoot, name: instanceName)) + sourceRootfs = nil + needsInstall = true + } else { + let namedPath = InstanceState.namedRootfsPath(appRoot: Sandboxy.appRoot, name: instanceName) + if FileManager.default.fileExists(atPath: namedPath.path(percentEncoded: false)) { + sourceRootfs = namedPath + } else if FileManager.default.fileExists(atPath: agentCachePath.path(percentEncoded: false)) { + sourceRootfs = agentCachePath + } else { + sourceRootfs = nil + needsInstall = true + } + } + + // Create workload container with full network for installation. + // After install, we recreate with the filtered network if needed. + var container: LinuxContainer + + if let sourceRootfs { + let containerDir = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + try FileManager.default.createDirectory(at: containerDir, withIntermediateDirectories: true) + + let result = Darwin.clonefile( + sourceRootfs.path(percentEncoded: false), + containerRootfsPath.path(percentEncoded: false), + 0 + ) + if result != 0 { + try FileManager.default.copyItem(at: sourceRootfs, to: containerRootfsPath) + } + + let rootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/", + runtimeOptions: ["vzDiskImageSynchronizationMode=fsync"] + ) + + let image = try await pullImageWithProgress(reference: definition.baseImage) + + container = try await manager.create( + containerId, + image: image, + rootfs: rootfsMount, + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } else { + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + ignoreSmallSize: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { progress.finish() } + progress.start() + + progress.set(description: "Pulling image (\(definition.baseImage))") + progress.set(itemsName: "blobs") + let image = try await Sandboxy.imageStore.pull( + reference: definition.baseImage, + progress: progressEventAdapter(for: progress) + ) + + progress.set(description: "Unpacking image") + container = try await manager.create( + containerId, + image: image, + rootfsSizeInBytes: 512.gib(), + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } + + // Boot and install toolchain if needed (with full network). + if needsInstall { + try await container.create() + try await container.start() + + ProgressUI.printStatus("Installing \(definition.displayName) toolchain...") + try await installAgent(in: container, definition: definition) + ProgressUI.printStatus("Installation complete.") + + try await container.stop() + ProgressUI.printDetail("Caching environment for future runs...") + try FileManager.default.copyItem(at: containerRootfsPath, to: agentCachePath) + + // Delete so we can recreate (possibly on a different network). + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + + // Recreate from the freshly-cached rootfs (unless filtering will recreate again). + if !filteringEnabled { + let cachedRootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/", + runtimeOptions: ["vzDiskImageSynchronizationMode=fsync"] + ) + let cachedImage = try await pullImageWithProgress(reference: definition.baseImage) + container = try await manager.create( + containerId, + image: cachedImage, + rootfs: cachedRootfsMount, + networking: false + ) { config in + if enableNetworking, let iface = try sharedNetwork?.createInterface(containerId, mtu: vmnetMTU) { + config.interfaces = [iface] + config.dns = .init(nameservers: [sharedNetwork!.ipv4Gateway.description]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } + } + + // Host-only network setup (only when filtering is active) + var proxyIP: String? + var hostOnlyNetwork: VmnetNetwork? + + if filteringEnabled, enableNetworking, #available(macOS 26, *) { + hostOnlyNetwork = try VmnetNetwork(mode: .VMNET_HOST_MODE) + + let gatewayIP = hostOnlyNetwork!.ipv4Gateway.description + let workloadHostOnlyInterface = try hostOnlyNetwork!.createInterface(containerId, mtu: vmnetMTU) + proxyIP = gatewayIP + + // Recreate the workload container on the host-only network. + if !needsInstall { + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + } + + let filteredContainerDir = Sandboxy.appRoot + .appendingPathComponent("containers") + .appendingPathComponent(containerId) + try FileManager.default.createDirectory(at: filteredContainerDir, withIntermediateDirectories: true) + + let filteredRootfsSource = needsInstall ? agentCachePath : (sourceRootfs ?? agentCachePath) + let cloneResult2 = Darwin.clonefile( + filteredRootfsSource.path(percentEncoded: false), + containerRootfsPath.path(percentEncoded: false), + 0 + ) + if cloneResult2 != 0 { + try FileManager.default.copyItem(at: filteredRootfsSource, to: containerRootfsPath) + } + + let filteredRootfsMount = Mount.block( + format: "ext4", + source: containerRootfsPath.path(percentEncoded: false), + destination: "/", + runtimeOptions: ["vzDiskImageSynchronizationMode=fsync"] + ) + + let image = try await pullImageWithProgress(reference: definition.baseImage) + + container = try await manager.create( + containerId, + image: image, + rootfs: filteredRootfsMount, + networking: false + ) { config in + if let iface = workloadHostOnlyInterface { + config.interfaces = [iface] + config.dns = .init(nameservers: [gatewayIP]) + } + try configureContainer( + config: &config, + definition: definition, + options: options, + containerId: containerId, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraMounts: extraMounts + ) + } + } + + // Run the container session, cleaning up on both success and failure. + do { + try await runContainerSession( + container: container, + containerId: containerId, + instanceName: instanceName, + agentName: agentName, + definition: definition, + options: options, + containerRootfsPath: containerRootfsPath, + agentCachePath: agentCachePath, + hostWorkspacePath: hostWorkspacePath, + guestWorkspacePath: guestWorkspacePath, + extraEnvVars: extraEnvVars, + proxyIP: proxyIP, + allowedHosts: allowedHosts, + passthroughArgs: filteredPassthroughArgs + ) + + // Cleanup + try manager.delete(containerId) + try? sharedNetwork?.releaseInterface(containerId) + if hostOnlyNetwork != nil { + try? hostOnlyNetwork?.releaseInterface(containerId) + } + } catch { + do { + try await container.stop() + } catch { + log.warning("Failed to stop container \(containerId): \(error)") + } + do { + try manager.delete(containerId) + } catch { + log.warning("Failed to delete container \(containerId): \(error)") + } + try? sharedNetwork?.releaseInterface(containerId) + if hostOnlyNetwork != nil { + try? hostOnlyNetwork?.releaseInterface(containerId) + } + throw error + } +} + +private func runContainerSession( + container: LinuxContainer, + containerId: String, + instanceName: String, + agentName: String, + definition: AgentDefinition, + options: AgentOptions, + containerRootfsPath: URL, + agentCachePath: URL, + hostWorkspacePath: String, + guestWorkspacePath: String, + extraEnvVars: [String], + proxyIP: String?, + allowedHosts: [String], + passthroughArgs: [String] +) async throws { + // create() boots the VM and brings up the vmnet bridge on the host. + try await container.create() + + // Start the proxy now that the bridge interface is up. + var hostProxy: HostProxy? + if let proxyIP { + let proxy = try await HostProxy( + host: proxyIP, + port: 0, + allowedHosts: allowedHosts + ) + hostProxy = proxy + } + + // start() launches the container process. + try await container.start() + + // Write instance state. + let instanceState = InstanceState( + id: containerId, + name: instanceName, + agent: agentName, + workspace: hostWorkspacePath, + status: .running, + createdAt: Date(), + cpus: options.cpus, + memoryMB: try options.memoryBytes / (1024 * 1024) + ) + try instanceState.save(appRoot: Sandboxy.appRoot) + + let sigwinchStream = AsyncSignalHandler.create(notify: [SIGWINCH]) + let current = try Terminal.current + try current.setraw() + defer { current.tryReset() } + + // Build environment for the agent process. + var envVarsBuilder = [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "TERM=xterm-256color", + "HOME=/root", + ] + for envVar in definition.environmentVariables { + if envVar.contains("=") { + envVarsBuilder.append(envVar) + } else if let value = ProcessInfo.processInfo.environment[envVar] { + envVarsBuilder.append("\(envVar)=\(value)") + } + } + + envVarsBuilder.append(contentsOf: extraEnvVars) + + if options.sshAgent, ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] != nil { + envVarsBuilder.append("SSH_AUTH_SOCK=/tmp/ssh-agent.sock") + } + + if let proxyIP, let proxyPort = hostProxy?.port { + let proxyURL = "http://\(proxyIP):\(proxyPort)" + envVarsBuilder.append("HTTP_PROXY=\(proxyURL)") + envVarsBuilder.append("HTTPS_PROXY=\(proxyURL)") + envVarsBuilder.append("http_proxy=\(proxyURL)") + envVarsBuilder.append("https_proxy=\(proxyURL)") + envVarsBuilder.append("NO_PROXY=localhost,127.0.0.1") + envVarsBuilder.append("no_proxy=localhost,127.0.0.1") + envVarsBuilder.append("GLOBAL_AGENT_HTTP_PROXY=\(proxyURL)") + envVarsBuilder.append("GLOBAL_AGENT_HTTPS_PROXY=\(proxyURL)") + envVarsBuilder.append("GLOBAL_AGENT_NO_PROXY=localhost,127.0.0.1") + + // Prepend global-agent bootstrap to NODE_OPTIONS so Node.js http/https + // modules respect the proxy environment variables. + if let idx = envVarsBuilder.firstIndex(where: { $0.hasPrefix("NODE_OPTIONS=") }) { + let existing = String(envVarsBuilder[idx].dropFirst("NODE_OPTIONS=".count)) + envVarsBuilder[idx] = "NODE_OPTIONS=-r /usr/local/lib/node_modules/global-agent/dist/routines/bootstrap.js \(existing)" + } else { + envVarsBuilder.append("NODE_OPTIONS=-r /usr/local/lib/node_modules/global-agent/dist/routines/bootstrap.js") + } + } + + var launchArgsBuilder = definition.launchCommand + launchArgsBuilder.append(contentsOf: passthroughArgs) + + let envVars = envVarsBuilder + let launchArgs = launchArgsBuilder + + let agentProcess = try await container.exec("agent-session") { config in + config.arguments = launchArgs + config.environmentVariables = envVars + config.workingDirectory = guestWorkspacePath + config.terminal = true + config.stdin = current + config.stdout = current + } + + try await agentProcess.start() + try? await agentProcess.resize(to: try current.size) + + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await _ in sigwinchStream.signals { + try await agentProcess.resize(to: try current.size) + } + } + + let status = try await agentProcess.wait() + group.cancelAll() + + try await agentProcess.delete() + + // Stop container so rootfs is cleanly unmounted before caching. + try await container.stop() + + if options.removeAfterRun { + ProgressUI.printStatus("Instance \u{1b}[1m\(instanceName)\u{1b}[0m removed (--rm).") + } else { + // Preserve the rootfs for all instances. + let namedDir = InstanceState.namedRootfsDir(appRoot: Sandboxy.appRoot) + try FileManager.default.createDirectory(at: namedDir, withIntermediateDirectories: true) + let namedPath = InstanceState.namedRootfsPath(appRoot: Sandboxy.appRoot, name: instanceName) + removeIfExists(at: namedPath) + try FileManager.default.copyItem(at: containerRootfsPath, to: namedPath) + + let stopped = InstanceState( + id: instanceState.id, + name: instanceState.name, + agent: instanceState.agent, + workspace: instanceState.workspace, + status: .stopped, + createdAt: instanceState.createdAt, + stoppedAt: Date(), + cpus: instanceState.cpus, + memoryMB: instanceState.memoryMB + ) + try stopped.save(appRoot: Sandboxy.appRoot) + + ProgressUI.printStatus("Instance \u{1b}[1m\(instanceName)\u{1b}[0m saved. Resume with: sandboxy run \(agentName) --name \(instanceName)") + } + + if status.exitCode != 0 { + throw ExitCode(status.exitCode) + } + } + + if let proxy = hostProxy { + try await proxy.stop() + } +} + +private func configureContainer( + config: inout LinuxContainer.Configuration, + definition: AgentDefinition, + options: AgentOptions, + containerId: String, + hostWorkspacePath: String, + guestWorkspacePath: String, + extraMounts: [MountSpec] +) throws { + config.cpus = options.cpus + config.memoryInBytes = try options.memoryBytes + + config.process.arguments = ["/bin/sleep", "infinity"] + config.process.workingDirectory = "/" + config.process.capabilities = .allCapabilities + config.useInit = true + + // SSH agent forwarding. + if options.sshAgent, + let authSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] + { + let guestSocketPath = "/tmp/ssh-agent.sock" + config.sockets.append( + UnixSocketConfiguration( + source: URL(fileURLWithPath: authSock), + destination: URL(fileURLWithPath: guestSocketPath), + direction: .into + ) + ) + } + + config.mounts.append( + Mount.share( + source: hostWorkspacePath, + destination: guestWorkspacePath + ) + ) + + if !options.noAgentMounts { + for agentMount in definition.mounts { + let hostPath = agentMount.resolvedHostPath + if FileManager.default.fileExists(atPath: hostPath) { + config.mounts.append( + Mount.share( + source: hostPath, + destination: agentMount.containerPath, + options: agentMount.readOnly ? ["ro"] : [] + ) + ) + } + } + } + + for mountSpec in extraMounts { + config.mounts.append(mountSpec.toMount()) + } + + var hosts = Hosts.default + if #available(macOS 26, *), !config.interfaces.isEmpty { + let interface = config.interfaces[0] + hosts.entries.append( + Hosts.Entry( + ipAddress: interface.ipv4Address.address.description, + hostnames: [containerId] + ) + ) + } + config.hosts = hosts +} + +func installAgent( + in container: LinuxContainer, + definition: AgentDefinition +) async throws { + for (index, command) in definition.installCommands.enumerated() { + let truncated = command.count > 80 ? String(command.prefix(77)) + "..." : command + ProgressUI.printDetail("[\(index + 1)/\(definition.installCommands.count)] \(truncated)") + + let buffer = OutputCapture(streamToStdout: true) + let execId = "install-\(index)" + let process = try await container.exec(execId) { config in + config.arguments = ["/bin/sh", "-c", command] + config.workingDirectory = "/" + config.stdout = buffer + config.stderr = buffer + config.capabilities = .allCapabilities + config.environmentVariables = [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + "DEBIAN_FRONTEND=noninteractive", + "HOME=/root", + ] + } + + try await process.start() + let status = try await process.wait() + try await process.delete() + + guard status.exitCode == 0 else { + throw SandboxyError.installFailed( + step: index + 1, + command: command, + exitCode: status.exitCode + ) + } + } +} + +func removeIfExists(at url: URL) { + let path = url.path(percentEncoded: false) + if FileManager.default.fileExists(atPath: path) { + do { + try FileManager.default.removeItem(at: url) + } catch { + log.warning("Failed to remove \(path): \(error)") + } + } +} + +/// A parsed `hostpath:containerpath[:ro|rw]` mount specification from the CLI. +struct MountSpec { + let hostPath: String + let containerPath: String + let readOnly: Bool + + static func parse(_ spec: String) throws -> MountSpec { + let parts = spec.split(separator: ":", maxSplits: 2).map(String.init) + guard parts.count >= 2 else { + throw SandboxyError.invalidMountSpec(spec: spec) + } + + let readOnly: Bool + if parts.count == 3 { + switch parts[2] { + case "ro": + readOnly = true + case "rw": + readOnly = false + default: + throw SandboxyError.invalidMountSpec(spec: spec) + } + } else { + readOnly = false + } + + // Resolve host path to absolute. + let hostPath = URL(fileURLWithPath: parts[0], relativeTo: .currentDirectory()) + .absoluteURL.path(percentEncoded: false) + + return MountSpec(hostPath: hostPath, containerPath: parts[1], readOnly: readOnly) + } + + func toMount() -> Containerization.Mount { + Containerization.Mount.share( + source: hostPath, + destination: containerPath, + options: readOnly ? ["ro"] : [] + ) + } +} + +func progressEventAdapter(for progress: ProgressBar) -> ProgressHandler { + { events in + for event in events { + switch event.event { + case "add-size": + if let value = event.value as? Int64 { + progress.add(size: value) + } + case "add-total-size": + if let value = event.value as? Int64 { + progress.add(totalSize: value) + } + case "add-items": + if let value = event.value as? Int { + progress.add(items: value) + } + case "add-total-items": + if let value = event.value as? Int { + progress.add(totalItems: value) + } + default: + break + } + } + } +} + +/// Pulls an image, showing a progress bar only if the image isn't already cached locally. +func pullImageWithProgress(reference: String) async throws -> Containerization.Image { + do { + return try await Sandboxy.imageStore.get(reference: reference) + } catch { + let progressConfig = try ProgressConfig( + description: "Pulling image (\(reference))", + showItems: true, + ignoreSmallSize: true + ) + let progress = ProgressBar(config: progressConfig) + defer { progress.finish() } + progress.start() + progress.set(itemsName: "blobs") + return try await Sandboxy.imageStore.pull( + reference: reference, + progress: progressEventAdapter(for: progress) + ) + } +} + +/// Resolves a `KEY=VALUE` or `KEY` environment variable specification. +/// +/// - `KEY=VALUE`: passed through as-is. +/// - `KEY`: looks up the variable in the host environment and produces `KEY=`. +/// Throws if the variable is not set. +enum EnvSpec { + static func resolve(_ spec: String) throws -> String { + if let eqIndex = spec.firstIndex(of: "=") { + // KEY=VALUE: Use as-is, but validate key is non-empty. + let key = spec[spec.startIndex.. SandboxyConfig { + let fm = FileManager.default + let agentsDir = configRoot.appendingPathComponent("agents") + try fm.createDirectory(at: agentsDir, withIntermediateDirectories: true) + + let config = try SandboxyConfig.load(configRoot: configRoot) + if let dataDir = config.dataDir { + appRoot = URL(fileURLWithPath: dataDir) + } + + try fm.createDirectory(at: appRoot, withIntermediateDirectories: true) + return config + } + + /// User configuration directory (`~/.config/sandboxy/`). + /// Holds `config.json` and `agents/` definition files. + static let configRoot: URL = { + FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config") + .appendingPathComponent("sandboxy") + }() + + private static let _contentStore: ContentStore = { + try! LocalContentStore(path: appRoot.appendingPathComponent("content")) + }() + + private static let _imageStore: ImageStore = { + try! ImageStore( + path: appRoot, + contentStore: contentStore + ) + }() + + static var imageStore: ImageStore { + _imageStore + } + + static var contentStore: ContentStore { + _contentStore + } +} diff --git a/examples/sandboxy/Sources/sandboxy/SandboxyConfig.swift b/examples/sandboxy/Sources/sandboxy/SandboxyConfig.swift new file mode 100644 index 000000000..d946970d9 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/SandboxyConfig.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Optional configuration file for overriding sandboxy defaults. +/// +/// If `config.json` exists in the sandboxy config directory, it is +/// loaded at startup and its values override the built-in defaults. Any field +/// can be omitted to keep the default. +/// +/// Location: `~/.config/sandboxy/config.json` +/// +/// Example: +/// ```json +/// { +/// "dataDir": "/Volumes/fast/sandboxy", +/// "kernel": "/path/to/vmlinux", +/// "initfsReference": "ghcr.io/apple/containerization/vminit:latest", +/// "defaultCPUs": 4, +/// "defaultMemory": "4g" +/// } +/// ``` +struct SandboxyConfig: Codable, Sendable { + /// Directory for runtime data (caches, content store, containers). + /// Defaults to `~/Library/Application Support/com.apple.containerization.sandboxy`. + var dataDir: String? + /// Path to a Linux kernel binary on disk. When set, the auto-download is skipped. + var kernel: String? + /// OCI reference for the VM init image. + var initfsReference: String? + /// Default number of CPUs for new containers. + var defaultCPUs: Int? + /// Default memory for new containers (e.g. "4g", "512m", "4096" for MB). + var defaultMemory: String? + + /// Built-in defaults used when no config file is present. + static let defaults = SandboxyConfig( + initfsReference: "ghcr.io/apple/containerization/vminit:0.30.0", + defaultCPUs: 4, + defaultMemory: "4g" + ) + + /// Loads the config from `/config.json`, falling back to defaults + /// for any missing fields. + static func load(configRoot: URL) throws -> SandboxyConfig { + let configPath = configRoot.appendingPathComponent("config.json") + + guard FileManager.default.fileExists(atPath: configPath.path(percentEncoded: false)) else { + return .defaults + } + + do { + let data = try Data(contentsOf: configPath) + let userConfig = try JSONDecoder().decode(SandboxyConfig.self, from: data) + + return SandboxyConfig( + dataDir: userConfig.dataDir, + kernel: userConfig.kernel, + initfsReference: userConfig.initfsReference ?? defaults.initfsReference, + defaultCPUs: userConfig.defaultCPUs ?? defaults.defaultCPUs, + defaultMemory: userConfig.defaultMemory ?? defaults.defaultMemory + ) + } catch { + throw SandboxyError.configFailedToLoad(error: error) + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/SandboxyError.swift b/examples/sandboxy/Sources/sandboxy/SandboxyError.swift new file mode 100644 index 000000000..d112c9759 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/SandboxyError.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 + +enum SandboxyError: Error, CustomStringConvertible { + case configFailedToLoad(error: Swift.Error) + case installFailed(step: Int, command: String, exitCode: Int32) + case kernelDownloadFailed(reason: String) + case proxyFailed(reason: String) + case kernelNotFound(path: String) + case incompleteAgentDefinition(missing: [String]) + case invalidMountSpec(spec: String) + case envVarNotSet(name: String) + + var description: String { + switch self { + case .configFailedToLoad(let error): + return "Failed to load sandbox config: \(error)" + case .installFailed(let step, let command, let exitCode): + return """ + Installation step \(step) failed (exit code \(exitCode)). + Command: \(command) + """ + case .kernelDownloadFailed(let reason): + return "Failed to download kernel: \(reason)" + case .proxyFailed(let reason): + return "Proxy failed: \(reason)" + case .kernelNotFound(let path): + return "Kernel not found at \(path). Provide a valid path with -k or omit to auto-download." + case .incompleteAgentDefinition(let missing): + return "Agent definition is missing required fields: \(missing.joined(separator: ", ")). Use 'sandboxy config --agent claude' to see a complete example." + case .invalidMountSpec(let spec): + return "Invalid mount specification: '\(spec)'. Expected format: hostpath:containerpath[:ro|rw]" + case .envVarNotSet(let name): + return "Environment variable '\(name)' is not set on the host." + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int+Formatted.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int+Formatted.swift new file mode 100644 index 000000000..28f7ee217 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int+Formatted.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Int { + func formattedTime() -> String { + let secondsInMinute = 60 + let secondsInHour = secondsInMinute * 60 + let secondsInDay = secondsInHour * 24 + + let days = self / secondsInDay + let hours = (self % secondsInDay) / secondsInHour + let minutes = (self % secondsInHour) / secondsInMinute + let seconds = self % secondsInMinute + + var components = [String]() + if days > 0 { + components.append("\(days)d") + } + if hours > 0 || days > 0 { + components.append("\(hours)h") + } + if minutes > 0 || hours > 0 || days > 0 { + components.append("\(minutes)m") + } + components.append("\(seconds)s") + return components.joined(separator: " ") + } + + func formattedNumber() -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + guard let formattedNumber = formatter.string(from: NSNumber(value: self)) else { + return "" + } + return formattedNumber + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int64+Formatted.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int64+Formatted.swift new file mode 100644 index 000000000..34b73ff01 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/Int64+Formatted.swift @@ -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. +//===----------------------------------------------------------------------===// + +import Foundation + +extension Int64 { + func formattedSize() -> String { + let formattedSize = ByteCountFormatter.string(fromByteCount: self, countStyle: .binary) + return formattedSize + } + + func formattedSizeSpeed(from startTime: DispatchTime) -> String { + let elapsedTimeNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds + let elapsedTimeSeconds = Double(elapsedTimeNanoseconds) / 1_000_000_000 + guard elapsedTimeSeconds > 0 else { + return "0 B/s" + } + + let speed = Double(self) / elapsedTimeSeconds + let formattedSpeed = ByteCountFormatter.string(fromByteCount: Int64(speed), countStyle: .binary) + return "\(formattedSpeed)/s" + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Add.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Add.swift new file mode 100644 index 000000000..3710895dd --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Add.swift @@ -0,0 +1,236 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension ProgressBar { + /// A handler function to update the progress bar. + /// - Parameter events: The events to handle. + public func handler(_ events: [ProgressUpdateEvent]) { + for event in events { + switch event { + case .setDescription(let description): + set(description: description) + case .setSubDescription(let subDescription): + set(subDescription: subDescription) + case .setItemsName(let itemsName): + set(itemsName: itemsName) + case .addTasks(let tasks): + add(tasks: tasks) + case .setTasks(let tasks): + set(tasks: tasks) + case .addTotalTasks(let totalTasks): + add(totalTasks: totalTasks) + case .setTotalTasks(let totalTasks): + set(totalTasks: totalTasks) + case .addSize(let size): + add(size: size) + case .setSize(let size): + set(size: size) + case .addTotalSize(let totalSize): + add(totalSize: totalSize) + case .setTotalSize(let totalSize): + set(totalSize: totalSize) + case .addItems(let items): + add(items: items) + case .setItems(let items): + set(items: items) + case .addTotalItems(let totalItems): + add(totalItems: totalItems) + case .setTotalItems(let totalItems): + set(totalItems: totalItems) + case .custom: + // Custom events are handled by the client. + break + } + } + } + + /// Performs a check to see if the progress bar should be finished. + public func checkIfFinished() { + let state = self.state.withLock { $0 } + + var finished = true + var defined = false + if let totalTasks = state.totalTasks, totalTasks > 0 { + // For tasks, we're showing the current task rather than the number of completed tasks. + finished = finished && state.tasks == totalTasks + defined = true + } + if let totalItems = state.totalItems, totalItems > 0 { + finished = finished && state.items == totalItems + defined = true + } + if let totalSize = state.totalSize, totalSize > 0 { + finished = finished && state.size == totalSize + defined = true + } + if defined && finished { + finish() + } + } + + /// Sets the current tasks. + /// - Parameter newTasks: The current tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(tasks newTasks: Int, render: Bool = true) { + state.withLock { $0.tasks = newTasks } + if render { + self.render() + } + checkIfFinished() + } + + /// Performs an addition to the current tasks. + /// - Parameter delta: The tasks to add to the current tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(tasks delta: Int, render: Bool = true) { + state.withLock { + let newTasks = $0.tasks + delta + $0.tasks = newTasks + } + if render { + self.render() + } + } + + /// Sets the total tasks. + /// - Parameter newTotalTasks: The total tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalTasks newTotalTasks: Int, render: Bool = true) { + state.withLock { $0.totalTasks = newTotalTasks } + if render { + self.render() + } + } + + /// Performs an addition to the total tasks. + /// - Parameter delta: The tasks to add to the total tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalTasks delta: Int, render: Bool = true) { + state.withLock { + let totalTasks = $0.totalTasks ?? 0 + let newTotalTasks = totalTasks + delta + $0.totalTasks = newTotalTasks + } + if render { + self.render() + } + } + + /// Sets the items name. + /// - Parameter newItemsName: The current items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(itemsName newItemsName: String, render: Bool = true) { + state.withLock { $0.itemsName = newItemsName } + if render { + self.render() + } + } + + /// Sets the current items. + /// - Parameter newItems: The current items to set. + public func set(items newItems: Int, render: Bool = true) { + state.withLock { $0.items = newItems } + if render { + self.render() + } + } + + /// Performs an addition to the current items. + /// - Parameter delta: The items to add to the current items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(items delta: Int, render: Bool = true) { + state.withLock { + let newItems = $0.items + delta + $0.items = newItems + } + if render { + self.render() + } + } + + /// Sets the total items. + /// - Parameter newTotalItems: The total items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalItems newTotalItems: Int, render: Bool = true) { + state.withLock { $0.totalItems = newTotalItems } + if render { + self.render() + } + } + + /// Performs an addition to the total items. + /// - Parameter delta: The items to add to the total items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalItems delta: Int, render: Bool = true) { + state.withLock { + let totalItems = $0.totalItems ?? 0 + let newTotalItems = totalItems + delta + $0.totalItems = newTotalItems + } + if render { + self.render() + } + } + + /// Sets the current size. + /// - Parameter newSize: The current size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(size newSize: Int64, render: Bool = true) { + state.withLock { $0.size = newSize } + if render { + self.render() + } + } + + /// Performs an addition to the current size. + /// - Parameter delta: The size to add to the current size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(size delta: Int64, render: Bool = true) { + state.withLock { + let newSize = $0.size + delta + $0.size = newSize + } + if render { + self.render() + } + } + + /// Sets the total size. + /// - Parameter newTotalSize: The total size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalSize newTotalSize: Int64, render: Bool = true) { + state.withLock { $0.totalSize = newTotalSize } + if render { + self.render() + } + } + + /// Performs an addition to the total size. + /// - Parameter delta: The size to add to the total size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalSize delta: Int64, render: Bool = true) { + state.withLock { + let totalSize = $0.totalSize ?? 0 + let newTotalSize = totalSize + delta + $0.totalSize = newTotalSize + } + if render { + self.render() + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+State.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+State.swift new file mode 100644 index 000000000..0771e793a --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+State.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension ProgressBar { + /// State for the progress bar. + struct State { + /// A flag indicating whether the progress bar is finished. + var finished = false + var iteration = 0 + private let speedInterval: DispatchTimeInterval = .seconds(1) + + var description: String + var subDescription: String + var itemsName: String + + var tasks: Int + var totalTasks: Int? + + var items: Int + var totalItems: Int? + + private var sizeUpdateTime: DispatchTime? + private var sizeUpdateValue: Int64 = 0 + var size: Int64 { + didSet { + calculateSizeSpeed() + } + } + + var totalSize: Int64? + private var sizeUpdateSpeed: String? + var sizeSpeed: String? { + guard sizeUpdateTime == nil || sizeUpdateTime! > .now() - speedInterval - speedInterval else { + return Int64(0).formattedSizeSpeed(from: startTime) + } + return sizeUpdateSpeed + } + var averageSizeSpeed: String { + size.formattedSizeSpeed(from: startTime) + } + + var percent: String { + var value = 0 + if let totalSize, totalSize > 0 { + value = Int(size * 100 / totalSize) + } else if let totalItems, totalItems > 0 { + value = Int(items * 100 / totalItems) + } + value = min(value, 100) + return "\(value)%" + } + + var startTime: DispatchTime + var output = "" + var renderTask: Task? + + init( + description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil, + size: Int64 = 0, totalSize: Int64? = nil, startTime: DispatchTime = .now() + ) { + self.description = description + self.subDescription = subDescription + self.itemsName = itemsName + self.tasks = tasks + self.totalTasks = totalTasks + self.items = items + self.totalItems = totalItems + self.size = size + self.totalSize = totalSize + self.startTime = startTime + } + + private mutating func calculateSizeSpeed() { + if sizeUpdateTime == nil || sizeUpdateTime! < .now() - speedInterval { + let partSize = size - sizeUpdateValue + let partStartTime = sizeUpdateTime ?? startTime + let partSizeSpeed = partSize.formattedSizeSpeed(from: partStartTime) + self.sizeUpdateSpeed = partSizeSpeed + + sizeUpdateTime = .now() + sizeUpdateValue = size + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Terminal.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Terminal.swift new file mode 100644 index 000000000..ce84cc5ca --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressBar+Terminal.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS +import Foundation + +enum EscapeSequence { + static let hideCursor = "\u{001B}[?25l" + static let showCursor = "\u{001B}[?25h" + static let moveUp = "\u{001B}[1A" + static let clearToEndOfLine = "\u{001B}[K" +} + +extension ProgressBar { + var termWidth: Int { + guard + let terminalHandle = term, + let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor) + else { + return 0 + } + + return (try? Int(terminal.size.width)) ?? 0 + } + + /// Clears the progress bar and resets the cursor. + public func clearAndResetCursor() { + state.withLock { s in + clear(state: &s) + resetCursor() + } + } + + /// Clears the progress bar. + public func clear() { + state.withLock { s in + clear(state: &s) + } + } + + /// Clears the progress bar (caller must hold state lock). + func clear(state: inout State) { + displayText("", state: &state) + } + + /// Resets the cursor. + public func resetCursor() { + display(EscapeSequence.showCursor) + } + + func display(_ text: String) { + guard let term else { + return + } + termQueue.sync { + try? term.write(contentsOf: Data(text.utf8)) + try? term.synchronize() + } + } + + func displayText(_ text: String, terminating: String = "\r") { + state.withLock { s in + displayText(text, state: &s, terminating: terminating) + } + } + + func displayText(_ text: String, state: inout State, terminating: String = "\r") { + state.output = text + + // Clears previously printed lines. + var lines = "" + if terminating.hasSuffix("\r") && termWidth > 0 { + let lineCount = (text.count - 1) / termWidth + for _ in 0.. + let term: FileHandle? + let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar") + + /// Returns `true` if the progress bar has finished. + public var isFinished: Bool { + state.withLock { $0.finished } + } + + /// Creates a new progress bar. + /// - Parameter config: The configuration for the progress bar. + public init(config: ProgressConfig) { + self.config = config + term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil + let state = State( + description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks, + totalItems: config.initialTotalItems, + totalSize: config.initialTotalSize) + self.state = Mutex(state) + display(EscapeSequence.hideCursor) + } + + /// Allows resetting the progress state. + public func reset() { + state.withLock { + $0 = State(description: config.initialDescription) + } + } + + /// Allows resetting the progress state of the current task. + public func resetCurrentTask() { + state.withLock { + $0 = State(description: $0.description, itemsName: $0.itemsName, tasks: $0.tasks, totalTasks: $0.totalTasks, startTime: $0.startTime) + } + } + + /// Updates the description of the progress bar and increments the tasks by one. + /// - Parameter description: The description of the action being performed. + public func set(description: String) { + resetCurrentTask() + + state.withLock { + $0.description = description + $0.subDescription = "" + $0.tasks += 1 + } + } + + /// Updates the additional description of the progress bar. + /// - Parameter subDescription: The additional description of the action being performed. + public func set(subDescription: String) { + resetCurrentTask() + + state.withLock { $0.subDescription = subDescription } + } + + private func start(intervalSeconds: TimeInterval) async { + while true { + let done = state.withLock { s -> Bool in + guard !s.finished else { + return true + } + render(state: &s) + s.iteration += 1 + return false + } + + if done { + return + } + + let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) + guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else { + return + } + } + } + + /// Starts an animation of the progress bar. + /// - Parameter intervalSeconds: The time interval between updates in seconds. + public func start(intervalSeconds: TimeInterval = 0.04) { + state.withLock { + if $0.renderTask != nil { + return + } + $0.renderTask = Task(priority: .utility) { + await start(intervalSeconds: intervalSeconds) + } + } + } + + /// Finishes the progress bar. + /// - Parameter clearScreen: If true, clears the progress bar from the screen. + public func finish(clearScreen: Bool = false) { + state.withLock { s in + guard !s.finished else { return } + + s.finished = true + s.renderTask?.cancel() + + let shouldClear = clearScreen || config.clearOnFinish + if !config.disableProgressUpdates && !shouldClear { + let output = draw(state: s) + displayText(output, state: &s, terminating: "\n") + } + + if shouldClear { + clear(state: &s) + } + resetCursor() + } + } +} + +extension ProgressBar { + private func secondsSinceStart(from startTime: DispatchTime) -> Int { + let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds + let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000)) + return timeDifferenceSeconds + } + + func render(force: Bool = false) { + guard term != nil && !config.disableProgressUpdates else { + return + } + state.withLock { s in + render(state: &s, force: force) + } + } + + func render(state: inout State, force: Bool = false) { + guard term != nil && !config.disableProgressUpdates else { + return + } + guard force || !state.finished else { + return + } + let output = draw(state: state) + displayText(output, state: &state) + } + + /// Detail levels for progressive truncation. + enum DetailLevel: Int, CaseIterable { + case full = 0 // Everything shown + case noSpeed // Drop speed from parens + case noSize // Drop size from parens + case noParens // Drop parens entirely (items, size, speed) + case noTime // Drop time + case noDescription // Drop description/subdescription + case minimal // Just spinner, tasks, percent + } + + func draw(state: State) -> String { + let width = termWidth + // If no terminal or width unknown, use full detail + guard width > 0 else { + return draw(state: state, detail: .full) + } + + // Add a small buffer to prevent wrapping issues during resize + let bufferChars = 4 + let targetWidth = max(1, width - bufferChars) + + for detail in DetailLevel.allCases { + let output = draw(state: state, detail: detail) + if output.count <= targetWidth { + return output + } + } + + return draw(state: state, detail: .minimal) + } + + func draw(state: State, detail: DetailLevel) -> String { + var components = [String]() + + // Spinner - always shown if configured (unless using progress bar) + if config.showSpinner && !config.showProgressBar { + if !state.finished { + let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) + components.append("\(spinnerIcon)") + } else { + components.append("\(config.theme.done)") + } + } + + // Tasks [x/y] - always shown if configured + if config.showTasks, let totalTasks = state.totalTasks { + let tasks = min(state.tasks, totalTasks) + components.append("[\(tasks)/\(totalTasks)]") + } + + // Description - dropped at noDescription level + if detail.rawValue < DetailLevel.noDescription.rawValue { + if config.showDescription && !state.description.isEmpty { + components.append("\(state.description)") + if !state.subDescription.isEmpty { + components.append("\(state.subDescription)") + } + } + } + + let allowProgress = !config.ignoreSmallSize || state.totalSize == nil || state.totalSize! > Int64(1024 * 1024) + let value = state.totalSize != nil ? state.size : Int64(state.items) + let total = state.totalSize ?? Int64(state.totalItems ?? 0) + + // Percent - always shown if configured + if config.showPercent && total > 0 && allowProgress { + components.append("\(state.finished ? "100%" : state.percent)") + } + + // Progress bar - always shown if configured + if config.showProgressBar, total > 0, allowProgress { + let usedWidth = components.joined(separator: " ").count + 45 + let remainingWidth = max(config.width - usedWidth, 1) + let barLength = state.finished ? remainingWidth : Int(Int64(remainingWidth) * value / total) + let barPaddingLength = remainingWidth - barLength + let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))" + components.append("|\(bar)|") + } + + // Additional components in parens - progressively dropped + if detail.rawValue < DetailLevel.noParens.rawValue { + var additionalComponents = [String]() + + // Items - dropped at noParens level + if config.showItems, state.items > 0 { + var itemsName = "" + if !state.itemsName.isEmpty { + itemsName = " \(state.itemsName)" + } + if state.finished { + if let totalItems = state.totalItems { + additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") + } + } else { + if let totalItems = state.totalItems { + additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + } else { + additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + } + } + } + + // Size and speed - progressively dropped + if state.size > 0 && allowProgress { + if state.finished { + // Size - dropped at noSize level + if detail.rawValue < DetailLevel.noSize.rawValue { + if config.showSize { + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + additionalComponents.append(formattedTotalSize) + } + } + } + } else { + // Size - dropped at noSize level + var formattedCombinedSize = "" + if detail.rawValue < DetailLevel.noSize.rawValue && config.showSize { + var formattedSize = state.size.formattedSize() + formattedSize = adjustFormattedSize(formattedSize) + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) + } else { + formattedCombinedSize = formattedSize + } + } + + // Speed - dropped at noSpeed level + var formattedSpeed = "" + if detail.rawValue < DetailLevel.noSpeed.rawValue && config.showSpeed { + formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" + formattedSpeed = adjustFormattedSize(formattedSpeed) + } + + if !formattedCombinedSize.isEmpty && !formattedSpeed.isEmpty { + additionalComponents.append(formattedCombinedSize) + additionalComponents.append(formattedSpeed) + } else if !formattedCombinedSize.isEmpty { + additionalComponents.append(formattedCombinedSize) + } else if !formattedSpeed.isEmpty { + additionalComponents.append(formattedSpeed) + } + } + } + + if additionalComponents.count > 0 { + let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") + components.append("(\(joinedAdditionalComponents))") + } + } + + // Time - dropped at noTime level + if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime { + let timeDifferenceSeconds = secondsSinceStart(from: state.startTime) + let formattedTime = timeDifferenceSeconds.formattedTime() + components.append("[\(formattedTime)]") + } + + return components.joined(separator: " ") + } + + private func adjustFormattedSize(_ size: String) -> String { + // Ensure we always have one digit after the decimal point to prevent flickering. + let zero = Int64(0).formattedSize() + let decimalSep = Locale.current.decimalSeparator ?? "." + guard !size.contains(decimalSep), let first = size.first, first.isNumber || !size.contains(zero) else { + return size + } + var size = size + for unit in ["MB", "GB", "TB"] { + size = size.replacingOccurrences(of: " \(unit)", with: "\(decimalSep)0 \(unit)") + } + return size + } + + private func combineSize(size: String, totalSize: String) -> String { + let sizeComponents = size.split(separator: " ", maxSplits: 1) + let totalSizeComponents = totalSize.split(separator: " ", maxSplits: 1) + guard sizeComponents.count == 2, totalSizeComponents.count == 2 else { + return "\(size)/\(totalSize)" + } + let sizeNumber = sizeComponents[0] + let sizeUnit = sizeComponents[1] + let totalSizeNumber = totalSizeComponents[0] + let totalSizeUnit = totalSizeComponents[1] + guard sizeUnit == totalSizeUnit else { + return "\(size)/\(totalSize)" + } + return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)" + } + + func draw() -> String { + state.withLock { draw(state: $0) } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressConfig.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressConfig.swift new file mode 100644 index 000000000..5579ef106 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressConfig.swift @@ -0,0 +1,170 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// A configuration for displaying a progress bar. +public struct ProgressConfig: Sendable { + /// The file handle for progress updates. + let terminal: FileHandle + /// The initial description of the progress bar. + let initialDescription: String + /// The initial additional description of the progress bar. + let initialSubDescription: String + /// The initial items name (e.g., "files"). + let initialItemsName: String + /// A flag indicating whether to show a spinner (e.g., "⠋"). + /// The spinner is hidden when a progress bar is shown. + public let showSpinner: Bool + /// A flag indicating whether to show tasks and total tasks (e.g., "[1]" or "[1/3]"). + public let showTasks: Bool + /// A flag indicating whether to show the description (e.g., "Downloading..."). + public let showDescription: Bool + /// A flag indicating whether to show a percentage (e.g., "100%"). + /// The percentage is hidden when no total size and total items are set. + public let showPercent: Bool + /// A flag indicating whether to show a progress bar (e.g., "|███ |"). + /// The progress bar is hidden when no total size and total items are set. + public let showProgressBar: Bool + /// A flag indicating whether to show items and total items (e.g., "(22 it)" or "(22/22 it)"). + public let showItems: Bool + /// A flag indicating whether to show a size and a total size (e.g., "(22 MB)" or "(22/22 MB)"). + public let showSize: Bool + /// A flag indicating whether to show a speed (e.g., "(4.834 MB/s)"). + /// The speed is combined with the size and total size (e.g., "(22/22 MB, 4.834 MB/s)"). + /// The speed is hidden when no total size is set. + public let showSpeed: Bool + /// A flag indicating whether to show the elapsed time (e.g., "[4s]"). + public let showTime: Bool + /// The flag indicating whether to ignore small size values (less than 1 MB). For example, this may help to avoid reaching 100% after downloading metadata before downloading content. + public let ignoreSmallSize: Bool + /// The initial total tasks of the progress bar. + let initialTotalTasks: Int? + /// The initial total size of the progress bar. + let initialTotalSize: Int64? + /// The initial total items of the progress bar. + let initialTotalItems: Int? + /// The width of the progress bar in characters. + public let width: Int + /// The theme of the progress bar. + public let theme: ProgressTheme + /// The flag indicating whether to clear the progress bar before resetting the cursor. + public let clearOnFinish: Bool + /// The flag indicating whether to update the progress bar. + public let disableProgressUpdates: Bool + /// Creates a new instance of `ProgressConfig`. + /// - Parameters: + /// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`. + /// - description: The initial description of the progress bar. The default value is `""`. + /// - subDescription: The initial additional description of the progress bar. The default value is `""`. + /// - itemsName: The initial items name. The default value is `"it"`. + /// - showSpinner: A flag indicating whether to show a spinner. The default value is `true`. + /// - showTasks: A flag indicating whether to show tasks and total tasks. The default value is `false`. + /// - showDescription: A flag indicating whether to show the description. The default value is `true`. + /// - showPercent: A flag indicating whether to show a percentage. The default value is `true`. + /// - showProgressBar: A flag indicating whether to show a progress bar. The default value is `false`. + /// - showItems: A flag indicating whether to show items and a total items. The default value is `false`. + /// - showSize: A flag indicating whether to show a size and a total size. The default value is `true`. + /// - showSpeed: A flag indicating whether to show a speed. The default value is `true`. + /// - showTime: A flag indicating whether to show the elapsed time. The default value is `true`. + /// - ignoreSmallSize: A flag indicating whether to ignore small size values. The default value is `false`. + /// - totalTasks: The initial total tasks of the progress bar. The default value is `nil`. + /// - totalItems: The initial total items of the progress bar. The default value is `nil`. + /// - totalSize: The initial total size of the progress bar. The default value is `nil`. + /// - width: The width of the progress bar in characters. The default value is `120`. + /// - theme: The theme of the progress bar. The default value is `nil`. + /// - clearOnFinish: The flag indicating whether to clear the progress bar before resetting the cursor. The default is `true`. + /// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`. + public init( + terminal: FileHandle = .standardError, + description: String = "", + subDescription: String = "", + itemsName: String = "it", + showSpinner: Bool = true, + showTasks: Bool = false, + showDescription: Bool = true, + showPercent: Bool = true, + showProgressBar: Bool = false, + showItems: Bool = false, + showSize: Bool = true, + showSpeed: Bool = true, + showTime: Bool = true, + ignoreSmallSize: Bool = false, + totalTasks: Int? = nil, + totalItems: Int? = nil, + totalSize: Int64? = nil, + width: Int = 120, + theme: ProgressTheme? = nil, + clearOnFinish: Bool = true, + disableProgressUpdates: Bool = false + ) throws { + if let totalTasks { + guard totalTasks > 0 else { + throw Error.invalid("totalTasks must be greater than zero") + } + } + if let totalItems { + guard totalItems > 0 else { + throw Error.invalid("totalItems must be greater than zero") + } + } + if let totalSize { + guard totalSize > 0 else { + throw Error.invalid("totalSize must be greater than zero") + } + } + + self.terminal = terminal + self.initialDescription = description + self.initialSubDescription = subDescription + self.initialItemsName = itemsName + + self.showSpinner = showSpinner + self.showTasks = showTasks + self.showDescription = showDescription + self.showPercent = showPercent + self.showProgressBar = showProgressBar + self.showItems = showItems + self.showSize = showSize + self.showSpeed = showSpeed + self.showTime = showTime + + self.ignoreSmallSize = ignoreSmallSize + self.initialTotalTasks = totalTasks + self.initialTotalItems = totalItems + self.initialTotalSize = totalSize + + self.width = width + self.theme = theme ?? DefaultProgressTheme() + self.clearOnFinish = clearOnFinish + self.disableProgressUpdates = disableProgressUpdates + } +} + +extension ProgressConfig { + /// An enumeration of errors that can occur when creating a `ProgressConfig`. + public enum Error: Swift.Error, CustomStringConvertible { + case invalid(String) + + /// The description of the error. + public var description: String { + switch self { + case .invalid(let reason): + return "failed to validate config (\(reason))" + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTaskCoordinator.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTaskCoordinator.swift new file mode 100644 index 000000000..ae09ca773 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTaskCoordinator.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// A type that represents a task whose progress is being monitored. +public struct ProgressTask: Sendable, Equatable { + private var id = UUID() + private var coordinator: ProgressTaskCoordinator + + init(manager: ProgressTaskCoordinator) { + self.coordinator = manager + } + + static public func == (lhs: ProgressTask, rhs: ProgressTask) -> Bool { + lhs.id == rhs.id + } + + /// Returns `true` if this task is the currently active task, `false` otherwise. + public func isCurrent() async -> Bool { + guard let currentTask = await coordinator.currentTask else { + return false + } + return currentTask == self + } +} + +/// A type that coordinates progress tasks to ignore updates from completed tasks. +public actor ProgressTaskCoordinator { + var currentTask: ProgressTask? + + /// Creates an instance of `ProgressTaskCoordinator`. + public init() {} + + /// Returns a new task that should be monitored for progress updates. + public func startTask() -> ProgressTask { + let newTask = ProgressTask(manager: self) + currentTask = newTask + return newTask + } + + /// Performs cleanup when the monitored tasks complete. + public func finish() { + currentTask = nil + } + + /// Returns a handler that updates the progress of a given task. + /// - Parameters: + /// - task: The task whose progress is being updated. + /// - progressUpdate: The handler to invoke when progress updates are received. + public static func handler(for task: ProgressTask, from progressUpdate: @escaping ProgressUpdateHandler) -> ProgressUpdateHandler { + { events in + // Ignore updates from completed tasks. + if await task.isCurrent() { + await progressUpdate(events) + } + } + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTheme.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTheme.swift new file mode 100644 index 000000000..44b5bc374 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressTheme.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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 theme for progress bar. +public protocol ProgressTheme: Sendable { + /// The icons used to represent a spinner. + var spinner: [String] { get } + /// The icon used to represent a progress bar. + var bar: String { get } + /// The icon used to indicate that a progress bar finished. + var done: String { get } +} + +public struct DefaultProgressTheme: ProgressTheme { + public let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + public let bar = "█" + public let done = "✔" +} + +extension ProgressTheme { + func getSpinnerIcon(_ iteration: Int) -> String { + spinner[iteration % spinner.count] + } +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressUpdate.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressUpdate.swift new file mode 100644 index 000000000..cf92db4e2 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/ProgressUpdate.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum ProgressUpdateEvent: Sendable { + case setDescription(String) + case setSubDescription(String) + case setItemsName(String) + case addTasks(Int) + case setTasks(Int) + case addTotalTasks(Int) + case setTotalTasks(Int) + case addItems(Int) + case setItems(Int) + case addTotalItems(Int) + case setTotalItems(Int) + case addSize(Int64) + case setSize(Int64) + case addTotalSize(Int64) + case setTotalSize(Int64) + case custom(String) +} + +public typealias ProgressUpdateHandler = @Sendable (_ events: [ProgressUpdateEvent]) async -> Void + +public protocol ProgressAdapter { + associatedtype T + static func handler(from progressUpdate: ProgressUpdateHandler?) -> (@Sendable ([T]) async -> Void)? +} diff --git a/examples/sandboxy/Sources/sandboxy/TerminalProgress/StandardError.swift b/examples/sandboxy/Sources/sandboxy/TerminalProgress/StandardError.swift new file mode 100644 index 000000000..9295f8624 --- /dev/null +++ b/examples/sandboxy/Sources/sandboxy/TerminalProgress/StandardError.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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 + +struct StandardError { + func write(_ string: String) { + if let data = string.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } +} diff --git a/examples/sandboxy/sandboxy.entitlements b/examples/sandboxy/sandboxy.entitlements new file mode 100644 index 000000000..d7d0d6e8b --- /dev/null +++ b/examples/sandboxy/sandboxy.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.virtualization + + + diff --git a/images/linux-dev/Dockerfile b/images/linux-dev/Dockerfile index afde9e959..0b499e316 100644 --- a/images/linux-dev/Dockerfile +++ b/images/linux-dev/Dockerfile @@ -16,7 +16,13 @@ ARG SWIFT_VERSION=6.3 FROM swift:${SWIFT_VERSION}-noble RUN apt-get update \ - && apt-get install -y make libarchive-dev libbz2-dev liblzma-dev libssl-dev \ + && apt-get install -y --no-install-recommends \ + make \ + e2fsprogs \ + libarchive-dev \ + libbz2-dev \ + liblzma-dev \ + libssl-dev \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* @@ -25,3 +31,103 @@ ARG SWIFT_SDK_CHECKSUM RUN if [ -n "$SWIFT_SDK_URL" ]; then \ swift sdk install "$SWIFT_SDK_URL" --checksum "$SWIFT_SDK_CHECKSUM"; \ fi + +# x86_64 cross-build tooling +# --- +# Used by `make dist-x86_64` to produce a Linux x86_64 deployment +# tarball from this aarch64 dev container. Adds: Zig (used as a +# clang-based cross C/C++ compiler — its bundled musl + LLVM lets us +# target x86_64-linux-musl from any host arch), wrapper scripts that +# look like a standard `x86_64-linux-musl-*` toolchain so autotools' +# `--host=x86_64-linux-musl` works, parallel `x86_64-linux-gnu-*` +# wrappers pinned at glibc 2.35 for virtiofsd's dynamic build, +# Rust stable + both x86_64-unknown-linux-{musl,gnu} targets, +# autotools/gperf, static-musl builds of zlib, xz, bzip2, libarchive, +# libcap-ng, and libseccomp at /opt/cross-x86_64-musl, and +# glibc-dynamic builds of libcap-ng and libseccomp at +# /opt/cross-x86_64-gnu. + +# Build deps for static-musl C libraries. +RUN apt-get update \ + && apt-get install -y --no-install-recommends \ + autoconf \ + automake \ + libtool \ + gperf \ + curl \ + ca-certificates \ + xz-utils \ + build-essential \ + pkg-config \ + lld \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +# Zig (cross compiler). Pin to a specific release; bump as needed. +# Zig provides clang-based `zig cc -target ...` plus `zig ar`, `zig +# ranlib`, etc., bundled with musl libc — no need for a separate +# arch-specific gcc cross toolchain. +# +# The SHA256 is captured from the upstream release index +# (https://ziglang.org/download/) and verified before extraction so a +# tampered tarball can't poison the dev image. +ARG ZIG_VERSION=0.13.0 +ARG ZIG_AARCH64_SHA256=041ac42323837eb5624068acd8b00cd5777dac4cf91179e8dad7a7e90dd0c556 +RUN set -eux; \ + curl -fsSL -o /tmp/zig.tar.xz \ + "https://ziglang.org/download/${ZIG_VERSION}/zig-linux-aarch64-${ZIG_VERSION}.tar.xz"; \ + echo "${ZIG_AARCH64_SHA256} /tmp/zig.tar.xz" | sha256sum -c -; \ + tar -xJf /tmp/zig.tar.xz -C /opt; \ + rm /tmp/zig.tar.xz; \ + ln -s "/opt/zig-linux-aarch64-${ZIG_VERSION}" /opt/zig +ENV PATH="/opt/zig:${PATH}" + +# Wrapper scripts that look like conventional cross toolchains — +# `x86_64-linux-musl-{gcc,g++,cc,c++,ar,ranlib,strip}` for the musl +# side (used by cctl, vminitd, cloud-hypervisor) and parallel +# `x86_64-linux-gnu-*` wrappers for virtiofsd's glibc-dynamic build. +# All dispatch to zig under the hood. Lets autotools' +# `--host=x86_64-linux-{musl,gnu}` and cargo's +# CARGO_TARGET_X86_64_UNKNOWN_LINUX_{MUSL,GNU}_LINKER point at these +# names without knowing about Zig. +# +# The C/C++ wrappers filter out `--target=` args that cc-rs +# (used by Rust build scripts like zstd-sys, libseccomp-sys) adds — +# cc-rs emits the Rust-form triple (e.g. x86_64-unknown-linux-musl) +# which Zig refuses to parse, and we always set our own -target. +COPY images/linux-dev/wrappers/ /usr/local/bin/ +RUN chmod +x /usr/local/bin/x86_64-linux-musl-* \ + && ln -s x86_64-linux-musl-gcc /usr/local/bin/x86_64-linux-musl-cc \ + && ln -s x86_64-linux-musl-g++ /usr/local/bin/x86_64-linux-musl-c++ \ + && chmod +x /usr/local/bin/x86_64-linux-gnu-* \ + && ln -s x86_64-linux-gnu-gcc /usr/local/bin/x86_64-linux-gnu-cc \ + && ln -s x86_64-linux-gnu-g++ /usr/local/bin/x86_64-linux-gnu-c++ + +# Rust toolchain at the same path the existing build-cloud-hypervisor / +# build-virtiofsd targets expect, with the x86_64-musl cross target +# pre-installed so dist-x86_64 doesn't redo it on every run. Also +# installs cargo-zigbuild — a cargo subcommand that uses Zig as the +# linker and handles the Rust+musl+Zig integration (specifically: it +# strips Rust's self-contained musl crt files so they don't collide +# with Zig's, and wires up libunwind correctly). +RUN curl --proto '=https' --tlsv1.2 -fsSL https://sh.rustup.rs \ + | sh -s -- -y --default-toolchain stable --profile minimal \ + && /root/.cargo/bin/rustup target add x86_64-unknown-linux-musl \ + && /root/.cargo/bin/rustup target add x86_64-unknown-linux-gnu \ + && /root/.cargo/bin/cargo install --locked cargo-zigbuild +ENV PATH="/root/.cargo/bin:${PATH}" + +# Static-musl C libraries (libarchive + deps for cctl, libcap-ng + +# libseccomp for virtiofsd). Installs to /opt/cross-x86_64-musl; +# build-dist-x86_64.sh adds -L/-I flags pointing at that prefix when +# linking the host-side binaries. +COPY scripts/build-musl-x86_64-deps.sh /tmp/build-musl-x86_64-deps.sh +RUN /tmp/build-musl-x86_64-deps.sh && rm /tmp/build-musl-x86_64-deps.sh + +# Glibc-dynamic C libraries for virtiofsd. virtiofsd ships +# glibc-dynamic in the x86_64 tarball so deployment hosts use their +# system libseccomp.so.2 + libcap-ng.so.0; this prefix only provides +# the link-time .so + headers + pkg-config files. cloud-hypervisor +# and cctl stay musl-static and link against /opt/cross-x86_64-musl/. +COPY scripts/build-glibc-x86_64-deps.sh /tmp/build-glibc-x86_64-deps.sh +RUN /tmp/build-glibc-x86_64-deps.sh && rm /tmp/build-glibc-x86_64-deps.sh diff --git a/images/linux-dev/wrappers/x86_64-linux-gnu-ar b/images/linux-dev/wrappers/x86_64-linux-gnu-ar new file mode 100755 index 000000000..6c3646ab3 --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-gnu-ar @@ -0,0 +1,2 @@ +#!/bin/sh +exec /opt/zig/zig ar "$@" diff --git a/images/linux-dev/wrappers/x86_64-linux-gnu-g++ b/images/linux-dev/wrappers/x86_64-linux-gnu-g++ new file mode 100755 index 000000000..ea64733ef --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-gnu-g++ @@ -0,0 +1,24 @@ +#!/bin/bash +# Wrapper that dispatches to `zig c++ -target x86_64-linux-gnu.2.35`. +# Mirrors x86_64-linux-musl-g++ but pins a glibc 2.35 baseline. +# +# See x86_64-linux-gnu-gcc for the rationale behind intercepting +# `-print-prog-name=ld`. +case " $* " in + *" -print-prog-name=ld "*) + echo /usr/local/bin/x86_64-linux-gnu-ld + exit 0 + ;; +esac + +# Filters out `--target=` args that cc-rs adds — cc-rs +# emits the Rust-form triple (x86_64-unknown-linux-gnu) which Zig +# can't parse. We always pass our own -target below. +args=() +for arg in "$@"; do + case "$arg" in + --target=*) ;; + *) args+=("$arg") ;; + esac +done +exec /opt/zig/zig c++ -target x86_64-linux-gnu.2.35 "${args[@]}" diff --git a/images/linux-dev/wrappers/x86_64-linux-gnu-gcc b/images/linux-dev/wrappers/x86_64-linux-gnu-gcc new file mode 100755 index 000000000..59f921e6c --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-gnu-gcc @@ -0,0 +1,31 @@ +#!/bin/bash +# Wrapper that dispatches to `zig cc -target x86_64-linux-gnu.2.35`. +# Mirrors x86_64-linux-musl-gcc but pins a glibc 2.35 baseline; the +# resulting binaries run on any host with glibc >= 2.35. +# +# Intercepts `-print-prog-name=ld` and returns our cross-ld wrapper — +# libtool uses that query to discover the linker before probing it +# with `-m elf_x86_64` to decide whether shared library builds are +# supported. zig cc passes the query through to the host `/usr/bin/ld`, +# which is aarch64-only and rejects the x86_64 emulation mode, causing +# libtool to silently disable shared-lib emission. Pointing libtool at +# our ld.lld-backed wrapper makes the probe succeed. +case " $* " in + *" -print-prog-name=ld "*) + echo /usr/local/bin/x86_64-linux-gnu-ld + exit 0 + ;; +esac + +# Filters out `--target=` args that cc-rs (Rust build +# scripts like libseccomp-sys, capng-sys) adds — cc-rs emits the +# Rust-form triple (x86_64-unknown-linux-gnu) which Zig can't parse. +# We always pass our own -target below, so cc-rs's is redundant. +args=() +for arg in "$@"; do + case "$arg" in + --target=*) ;; + *) args+=("$arg") ;; + esac +done +exec /opt/zig/zig cc -target x86_64-linux-gnu.2.35 "${args[@]}" diff --git a/images/linux-dev/wrappers/x86_64-linux-gnu-ld b/images/linux-dev/wrappers/x86_64-linux-gnu-ld new file mode 100755 index 000000000..15daeca77 --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-gnu-ld @@ -0,0 +1,9 @@ +#!/bin/sh +# Wrapper that points the autotools / libtool linker probe at +# LLVM's lld, which can produce x86_64 ELF output on an aarch64 +# host. The system `/usr/bin/ld` on the dev image is aarch64-only +# and rejects `-m elf_x86_64`, causing libtool to silently disable +# shared-library builds. libtool searches PATH for `-ld` +# before falling back to plain `ld`; this wrapper satisfies the +# search and unblocks shared-lib emission. +exec ld.lld "$@" diff --git a/images/linux-dev/wrappers/x86_64-linux-gnu-ranlib b/images/linux-dev/wrappers/x86_64-linux-gnu-ranlib new file mode 100755 index 000000000..5118caa7b --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-gnu-ranlib @@ -0,0 +1,2 @@ +#!/bin/sh +exec /opt/zig/zig ranlib "$@" diff --git a/images/linux-dev/wrappers/x86_64-linux-gnu-strip b/images/linux-dev/wrappers/x86_64-linux-gnu-strip new file mode 100755 index 000000000..052a9b9ae --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-gnu-strip @@ -0,0 +1,2 @@ +#!/bin/sh +exec /opt/zig/zig strip "$@" diff --git a/images/linux-dev/wrappers/x86_64-linux-musl-ar b/images/linux-dev/wrappers/x86_64-linux-musl-ar new file mode 100755 index 000000000..6c3646ab3 --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-musl-ar @@ -0,0 +1,2 @@ +#!/bin/sh +exec /opt/zig/zig ar "$@" diff --git a/images/linux-dev/wrappers/x86_64-linux-musl-g++ b/images/linux-dev/wrappers/x86_64-linux-musl-g++ new file mode 100755 index 000000000..57b7ee3e5 --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-musl-g++ @@ -0,0 +1,14 @@ +#!/bin/bash +# Wrapper that dispatches to `zig c++ -target x86_64-linux-musl`. +# Filters out `--target=` args that cc-rs (Rust build +# scripts) adds — cc-rs emits the Rust-form triple +# (x86_64-unknown-linux-musl) which Zig can't parse. We always pass +# our own -target below, so cc-rs's is redundant. +args=() +for arg in "$@"; do + case "$arg" in + --target=*) ;; + *) args+=("$arg") ;; + esac +done +exec /opt/zig/zig c++ -target x86_64-linux-musl "${args[@]}" diff --git a/images/linux-dev/wrappers/x86_64-linux-musl-gcc b/images/linux-dev/wrappers/x86_64-linux-musl-gcc new file mode 100755 index 000000000..fd43c9807 --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-musl-gcc @@ -0,0 +1,14 @@ +#!/bin/bash +# Wrapper that dispatches to `zig cc -target x86_64-linux-musl`. +# Filters out `--target=` args that cc-rs (Rust build +# scripts like zstd-sys) adds — cc-rs emits the Rust-form triple +# (x86_64-unknown-linux-musl) which Zig can't parse. We always pass +# our own -target below, so cc-rs's is redundant. +args=() +for arg in "$@"; do + case "$arg" in + --target=*) ;; + *) args+=("$arg") ;; + esac +done +exec /opt/zig/zig cc -target x86_64-linux-musl "${args[@]}" diff --git a/images/linux-dev/wrappers/x86_64-linux-musl-ranlib b/images/linux-dev/wrappers/x86_64-linux-musl-ranlib new file mode 100755 index 000000000..5118caa7b --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-musl-ranlib @@ -0,0 +1,2 @@ +#!/bin/sh +exec /opt/zig/zig ranlib "$@" diff --git a/images/linux-dev/wrappers/x86_64-linux-musl-strip b/images/linux-dev/wrappers/x86_64-linux-musl-strip new file mode 100755 index 000000000..052a9b9ae --- /dev/null +++ b/images/linux-dev/wrappers/x86_64-linux-musl-strip @@ -0,0 +1,2 @@ +#!/bin/sh +exec /opt/zig/zig strip "$@" diff --git a/kernel/Makefile b/kernel/Makefile index 914b50d70..bc955b11a 100644 --- a/kernel/Makefile +++ b/kernel/Makefile @@ -14,30 +14,61 @@ KSOURCE ?= https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.5.tar.xz KIMAGE ?= kernel-build:0.1 -CURDIR := $(shell pwd) -GIT_VERSION := $(shell git -C $(CURDIR) rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +MAKEFILE_DIR := $(patsubst %/,%,$(dir $(abspath $(lastword $(MAKEFILE_LIST))))) +GIT_VERSION := $(shell git -C $(MAKEFILE_DIR) rev-parse --short=12 HEAD 2>/dev/null || echo unknown) +TARGET_ARCH ?= + +# Resolve the effective target arch (TARGET_ARCH override, else host). +EFFECTIVE_ARCH := $(if $(TARGET_ARCH),$(TARGET_ARCH),$(shell uname -m)) +ifneq (,$(filter $(EFFECTIVE_ARCH),aarch64)) +EFFECTIVE_ARCH := arm64 +endif +ifneq (,$(filter $(EFFECTIVE_ARCH),amd64)) +EFFECTIVE_ARCH := x86_64 +endif + +# Compressed (vmlinuz) for x86_64 bzImage; uncompressed (vmlinux) for arm64 Image. +ifeq ($(EFFECTIVE_ARCH),x86_64) +KERNEL_OUTPUT := vmlinuz-x86_64 +else +KERNEL_OUTPUT := vmlinux-arm64 +endif + +BIN_DIR := $(abspath $(MAKEFILE_DIR)/../bin) .DEFAULT_GOAL := all .PHONY: all all: kernel-build-image all: kernel-build +all: kernel-install .PHONY: kernel-build-image kernel-build-image: - container build image/ -f image/Dockerfile -t ${KIMAGE} + container build $(MAKEFILE_DIR)/image -f $(MAKEFILE_DIR)/image/Dockerfile -t ${KIMAGE} .PHONY: kernel-build kernel-build: -ifeq (,$(wildcard source.tar.xz)) - curl -SsL -o source.tar.xz ${KSOURCE} +ifeq (,$(wildcard $(MAKEFILE_DIR)/source.tar.xz)) + curl -SsL -o $(MAKEFILE_DIR)/source.tar.xz ${KSOURCE} endif container run \ --cpus 8 \ --rm \ --memory 16g \ - -v ${CURDIR}:/kernel \ + -v $(MAKEFILE_DIR):/kernel \ --env LOCALVERSION=-cz-${GIT_VERSION} \ + $(if $(TARGET_ARCH),--env TARGET_ARCH=$(TARGET_ARCH),) \ --cwd /kernel \ ${KIMAGE} \ /bin/bash -c "./build.sh" + +.PHONY: kernel-install +kernel-install: + @mkdir -p $(BIN_DIR) + @cp -L $(MAKEFILE_DIR)/$(KERNEL_OUTPUT) $(BIN_DIR)/$(KERNEL_OUTPUT) + @echo "Installed $(KERNEL_OUTPUT) -> $(BIN_DIR)/$(KERNEL_OUTPUT)" + +.PHONY: x86_64 +x86_64: + $(MAKE) all TARGET_ARCH=x86_64 diff --git a/kernel/README.md b/kernel/README.md index bba12c088..797aa4fbc 100644 --- a/kernel/README.md +++ b/kernel/README.md @@ -2,7 +2,7 @@ This directory includes an optimized kernel configuration to produce a fast and lightweight kernel for container use. -- `config-arm64` includes the kernel `CONFIG_` options. +- `config-arm64` and `config-x86_64` include the per-arch kernel `CONFIG_` options. - `Makefile` includes the kernel version and source package URL. - `build.sh` scripts the kernel build process. - `image/` includes the configuration for an image with build tooling. @@ -12,4 +12,13 @@ This directory includes an optimized kernel configuration to produce a fast and 1. The build process relies on having the `container` tool installed (https://github.com/apple/container/releases). 2. Run `make`. This should create the image used for building the resulting Linux kernel, and then run a container with that image to perform the kernel build. -A `kernel/vmlinux` file will be the result of the build. +### Target architecture + +The build target is selected by the `TARGET_ARCH` make variable, which accepts either `arm64` or `x86_64`. When unset, it falls back to the build host's architecture (as reported by `uname -m`, with `aarch64`/`amd64` normalized to `arm64`/`x86_64`). + +- `make` (default) → builds for the host arch +- `make TARGET_ARCH=arm64` → `vmlinux-arm64` (uncompressed `Image`) +- `make TARGET_ARCH=x86_64` → `vmlinuz-x86_64` (compressed `bzImage`, cross-compiled inside the arm64 container) +- `make x86_64` → convenience alias for `make TARGET_ARCH=x86_64` + +The `z` suffix on the x86 name follows Linux convention for a compressed kernel image. The resulting kernel is copied into the repo's `bin/` directory. diff --git a/kernel/build.sh b/kernel/build.sh index 2f3b5c459..a338de35b 100755 --- a/kernel/build.sh +++ b/kernel/build.sh @@ -15,13 +15,36 @@ set -e +TARGET_ARCH="${TARGET_ARCH:-$(uname -m)}" + +case "${TARGET_ARCH}" in + aarch64|arm64) + CONFIG=config-arm64 + KARCH=arm64 + CROSS_COMPILE=aarch64-linux-gnu- + IMAGE_PATH=arch/arm64/boot/Image + OUTPUT_NAME=vmlinux-arm64 + ;; + x86_64|amd64) + CONFIG=config-x86_64 + KARCH=x86_64 + CROSS_COMPILE=x86_64-linux-gnu- + IMAGE_PATH=arch/x86/boot/bzImage + OUTPUT_NAME=vmlinuz-x86_64 + ;; + *) + echo "Unsupported target architecture: ${TARGET_ARCH}" >&2 + exit 1 + ;; +esac + mkdir -p /kbuild tar -xf /kernel/source.tar.xz -C /kbuild --strip-components=1 -cp /kernel/config-arm64 /kbuild/.config +cp "/kernel/${CONFIG}" /kbuild/.config ( cd /kbuild - make olddefconfig && \ - make -j$((`nproc`-1)) LOCALVERSION="${LOCALVERSION}" && \ - cp arch/arm64/boot/Image /kernel/vmlinux + make ARCH="${KARCH}" CROSS_COMPILE="${CROSS_COMPILE}" olddefconfig && \ + make ARCH="${KARCH}" CROSS_COMPILE="${CROSS_COMPILE}" -j$((`nproc`-1)) LOCALVERSION="${LOCALVERSION}" && \ + cp "${IMAGE_PATH}" "/kernel/${OUTPUT_NAME}" ) diff --git a/kernel/config-arm64 b/kernel/config-arm64 index c9c0c8642..429535b9f 100644 --- a/kernel/config-arm64 +++ b/kernel/config-arm64 @@ -1527,7 +1527,7 @@ CONFIG_DNS_RESOLVER=y # CONFIG_OPENVSWITCH is not set CONFIG_VSOCKETS=y CONFIG_VSOCKETS_DIAG=y -CONFIG_VSOCKETS_LOOPBACK=y +# CONFIG_VSOCKETS_LOOPBACK is not set CONFIG_VIRTIO_VSOCKETS=y CONFIG_VIRTIO_VSOCKETS_COMMON=y CONFIG_NETLINK_DIAG=y diff --git a/kernel/config-x86_64 b/kernel/config-x86_64 new file mode 100644 index 000000000..7ca2317a2 --- /dev/null +++ b/kernel/config-x86_64 @@ -0,0 +1,3650 @@ +# +# Automatically generated file; DO NOT EDIT. +# Linux/x86_64 6.6.9 Kernel Configuration +# +CONFIG_CC_VERSION_TEXT="x86_64-linux-gnu-gcc (Ubuntu 9.4.0-1ubuntu1~20.04.1) 9.4.0" +CONFIG_CC_IS_GCC=y +CONFIG_GCC_VERSION=90400 +CONFIG_CLANG_VERSION=0 +CONFIG_AS_IS_GNU=y +CONFIG_AS_VERSION=23400 +CONFIG_LD_IS_BFD=y +CONFIG_LD_VERSION=23400 +CONFIG_LLD_VERSION=0 +CONFIG_CC_CAN_LINK=y +CONFIG_CC_CAN_LINK_STATIC=y +CONFIG_CC_HAS_ASM_INLINE=y +CONFIG_CC_HAS_NO_PROFILE_FN_ATTR=y +CONFIG_PAHOLE_VERSION=0 +CONFIG_IRQ_WORK=y +CONFIG_BUILDTIME_TABLE_SORT=y +CONFIG_THREAD_INFO_IN_TASK=y + +# +# General setup +# +CONFIG_INIT_ENV_ARG_LIMIT=32 +# CONFIG_COMPILE_TEST is not set +# CONFIG_WERROR is not set +CONFIG_LOCALVERSION="" +# CONFIG_LOCALVERSION_AUTO is not set +CONFIG_BUILD_SALT="" +CONFIG_HAVE_KERNEL_GZIP=y +CONFIG_HAVE_KERNEL_BZIP2=y +CONFIG_HAVE_KERNEL_LZMA=y +CONFIG_HAVE_KERNEL_XZ=y +CONFIG_HAVE_KERNEL_LZO=y +CONFIG_HAVE_KERNEL_LZ4=y +CONFIG_HAVE_KERNEL_ZSTD=y +CONFIG_KERNEL_GZIP=y +# CONFIG_KERNEL_BZIP2 is not set +# CONFIG_KERNEL_LZMA is not set +# CONFIG_KERNEL_XZ is not set +# CONFIG_KERNEL_LZO is not set +# CONFIG_KERNEL_LZ4 is not set +# CONFIG_KERNEL_ZSTD is not set +CONFIG_DEFAULT_INIT="" +CONFIG_DEFAULT_HOSTNAME="sandbox-vm" +CONFIG_SYSVIPC=y +CONFIG_SYSVIPC_SYSCTL=y +CONFIG_POSIX_MQUEUE=y +CONFIG_POSIX_MQUEUE_SYSCTL=y +# CONFIG_WATCH_QUEUE is not set +CONFIG_CROSS_MEMORY_ATTACH=y +# CONFIG_USELIB is not set +CONFIG_AUDIT=y +CONFIG_HAVE_ARCH_AUDITSYSCALL=y +CONFIG_AUDITSYSCALL=y + +# +# IRQ subsystem +# +CONFIG_GENERIC_IRQ_PROBE=y +CONFIG_GENERIC_IRQ_SHOW=y +CONFIG_GENERIC_IRQ_EFFECTIVE_AFF_MASK=y +CONFIG_GENERIC_PENDING_IRQ=y +CONFIG_GENERIC_IRQ_MIGRATION=y +CONFIG_HARDIRQS_SW_RESEND=y +CONFIG_IRQ_DOMAIN=y +CONFIG_IRQ_DOMAIN_HIERARCHY=y +CONFIG_GENERIC_MSI_IRQ=y +CONFIG_GENERIC_MSI_IRQ_DOMAIN=y +CONFIG_IRQ_MSI_IOMMU=y +CONFIG_GENERIC_IRQ_MATRIX_ALLOCATOR=y +CONFIG_GENERIC_IRQ_RESERVATION_MODE=y +CONFIG_IRQ_FORCED_THREADING=y +CONFIG_SPARSE_IRQ=y +# CONFIG_GENERIC_IRQ_DEBUGFS is not set +# end of IRQ subsystem + +CONFIG_CLOCKSOURCE_WATCHDOG=y +CONFIG_ARCH_CLOCKSOURCE_INIT=y +CONFIG_CLOCKSOURCE_VALIDATE_LAST_CYCLE=y +CONFIG_GENERIC_TIME_VSYSCALL=y +CONFIG_GENERIC_CLOCKEVENTS=y +CONFIG_GENERIC_CLOCKEVENTS_BROADCAST=y +CONFIG_GENERIC_CLOCKEVENTS_MIN_ADJUST=y +CONFIG_GENERIC_CMOS_UPDATE=y +CONFIG_HAVE_POSIX_CPU_TIMERS_TASK_WORK=y +CONFIG_POSIX_CPU_TIMERS_TASK_WORK=y +CONFIG_CONTEXT_TRACKING=y +CONFIG_CONTEXT_TRACKING_IDLE=y + +# +# Timers subsystem +# +CONFIG_TICK_ONESHOT=y +CONFIG_NO_HZ_COMMON=y +# CONFIG_HZ_PERIODIC is not set +CONFIG_NO_HZ_IDLE=y +# CONFIG_NO_HZ_FULL is not set +CONFIG_NO_HZ=y +CONFIG_HIGH_RES_TIMERS=y +CONFIG_CLOCKSOURCE_WATCHDOG_MAX_SKEW_US=100 +# end of Timers subsystem + +CONFIG_BPF=y +CONFIG_HAVE_EBPF_JIT=y +CONFIG_ARCH_WANT_DEFAULT_BPF_JIT=y + +# +# BPF subsystem +# +CONFIG_BPF_SYSCALL=y +# CONFIG_BPF_UNPRIV_DEFAULT_OFF is not set +CONFIG_USERMODE_DRIVER=y +CONFIG_BPF_PRELOAD=y +CONFIG_BPF_PRELOAD_UMD=y +# end of BPF subsystem + +CONFIG_PREEMPT_BUILD=y +CONFIG_PREEMPT_NONE=y +# CONFIG_PREEMPT_VOLUNTARY is not set +# CONFIG_PREEMPT is not set +CONFIG_PREEMPT_COUNT=y +CONFIG_PREEMPTION=y +CONFIG_PREEMPT_DYNAMIC=y +# CONFIG_SCHED_CORE is not set + +# +# CPU/Task time and stats accounting +# +CONFIG_TICK_CPU_ACCOUNTING=y +# CONFIG_VIRT_CPU_ACCOUNTING_GEN is not set +# CONFIG_IRQ_TIME_ACCOUNTING is not set +CONFIG_BSD_PROCESS_ACCT=y +CONFIG_BSD_PROCESS_ACCT_V3=y +CONFIG_TASKSTATS=y +CONFIG_TASK_DELAY_ACCT=y +CONFIG_TASK_XACCT=y +CONFIG_TASK_IO_ACCOUNTING=y +# CONFIG_PSI is not set +# end of CPU/Task time and stats accounting + +CONFIG_CPU_ISOLATION=y + +# +# RCU Subsystem +# +CONFIG_TREE_RCU=y +CONFIG_PREEMPT_RCU=y +# CONFIG_RCU_EXPERT is not set +CONFIG_SRCU=y +CONFIG_TREE_SRCU=y +CONFIG_TASKS_RCU_GENERIC=y +CONFIG_TASKS_RCU=y +CONFIG_TASKS_TRACE_RCU=y +CONFIG_RCU_STALL_COMMON=y +CONFIG_RCU_NEED_SEGCBLIST=y +# end of RCU Subsystem + +CONFIG_IKCONFIG=y +CONFIG_IKCONFIG_PROC=y +# CONFIG_IKHEADERS is not set +CONFIG_LOG_BUF_SHIFT=21 +CONFIG_LOG_CPU_MAX_BUF_SHIFT=12 +CONFIG_PRINTK_SAFE_LOG_BUF_SHIFT=13 +# CONFIG_PRINTK_INDEX is not set +CONFIG_HAVE_UNSTABLE_SCHED_CLOCK=y + +# +# Scheduler features +# +# CONFIG_UCLAMP_TASK is not set +# end of Scheduler features + +CONFIG_ARCH_SUPPORTS_NUMA_BALANCING=y +CONFIG_ARCH_WANT_BATCHED_UNMAP_TLB_FLUSH=y +CONFIG_CC_HAS_INT128=y +CONFIG_CC_IMPLICIT_FALLTHROUGH="-Wimplicit-fallthrough=5" +CONFIG_GCC11_NO_ARRAY_BOUNDS=y +CONFIG_ARCH_SUPPORTS_INT128=y +CONFIG_NUMA_BALANCING=y +# CONFIG_NUMA_BALANCING_DEFAULT_ENABLED is not set +CONFIG_CGROUPS=y +CONFIG_PAGE_COUNTER=y +# CONFIG_CGROUP_FAVOR_DYNMODS is not set +CONFIG_MEMCG=y +CONFIG_MEMCG_V1=y +CONFIG_MEMCG_KMEM=y +CONFIG_BLK_CGROUP=y +CONFIG_CGROUP_WRITEBACK=y +CONFIG_CGROUP_SCHED=y +CONFIG_FAIR_GROUP_SCHED=y +CONFIG_CFS_BANDWIDTH=y +CONFIG_RT_GROUP_SCHED=y +CONFIG_CGROUP_PIDS=y +# CONFIG_CGROUP_RDMA is not set +CONFIG_CGROUP_FREEZER=y +CONFIG_CGROUP_HUGETLB=y +CONFIG_CPUSETS=y +CONFIG_CPUSETS_V1=y +CONFIG_PROC_PID_CPUSET=y +CONFIG_CGROUP_DEVICE=y +CONFIG_CGROUP_CPUACCT=y +CONFIG_CGROUP_PERF=y +CONFIG_CGROUP_BPF=y +# CONFIG_CGROUP_MISC is not set +# CONFIG_CGROUP_DEBUG is not set +CONFIG_SOCK_CGROUP_DATA=y +CONFIG_NAMESPACES=y +CONFIG_UTS_NS=y +CONFIG_TIME_NS=y +CONFIG_IPC_NS=y +CONFIG_USER_NS=y +CONFIG_PID_NS=y +CONFIG_NET_NS=y +# CONFIG_CHECKPOINT_RESTORE is not set +CONFIG_SCHED_AUTOGROUP=y +# CONFIG_SYSFS_DEPRECATED is not set +CONFIG_RELAY=y +CONFIG_BLK_DEV_INITRD=y +CONFIG_INITRAMFS_SOURCE="" +CONFIG_RD_GZIP=y +CONFIG_RD_BZIP2=y +CONFIG_RD_LZMA=y +CONFIG_RD_XZ=y +CONFIG_RD_LZO=y +CONFIG_RD_LZ4=y +CONFIG_RD_ZSTD=y +# CONFIG_BOOT_CONFIG is not set +CONFIG_INITRAMFS_PRESERVE_MTIME=y +CONFIG_CC_OPTIMIZE_FOR_PERFORMANCE=y +# CONFIG_CC_OPTIMIZE_FOR_SIZE is not set +CONFIG_LD_ORPHAN_WARN=y +CONFIG_SYSCTL=y +CONFIG_SYSCTL_EXCEPTION_TRACE=y +CONFIG_HAVE_PCSPKR_PLATFORM=y +CONFIG_EXPERT=y +CONFIG_MULTIUSER=y +CONFIG_SGETMASK_SYSCALL=y +CONFIG_SYSFS_SYSCALL=y +CONFIG_FHANDLE=y +CONFIG_POSIX_TIMERS=y +CONFIG_PRINTK=y +CONFIG_BUG=y +CONFIG_ELF_CORE=y +CONFIG_PCSPKR_PLATFORM=y +CONFIG_BASE_FULL=y +CONFIG_FUTEX=y +CONFIG_FUTEX_PI=y +CONFIG_EPOLL=y +CONFIG_SIGNALFD=y +CONFIG_TIMERFD=y +CONFIG_EVENTFD=y +CONFIG_SHMEM=y +CONFIG_AIO=y +CONFIG_IO_URING=y +CONFIG_ADVISE_SYSCALLS=y +CONFIG_MEMBARRIER=y +CONFIG_KALLSYMS=y +# CONFIG_KALLSYMS_ALL is not set +CONFIG_KALLSYMS_ABSOLUTE_PERCPU=y +CONFIG_KALLSYMS_BASE_RELATIVE=y +CONFIG_ARCH_HAS_MEMBARRIER_SYNC_CORE=y +CONFIG_KCMP=y +CONFIG_RSEQ=y +# CONFIG_DEBUG_RSEQ is not set +# CONFIG_EMBEDDED is not set +CONFIG_HAVE_PERF_EVENTS=y +CONFIG_GUEST_PERF_EVENTS=y +# CONFIG_PC104 is not set + +# +# Kernel Performance Events And Counters +# +CONFIG_PERF_EVENTS=y +# CONFIG_DEBUG_PERF_USE_VMALLOC is not set +# end of Kernel Performance Events And Counters + +# CONFIG_PROFILING is not set +# end of General setup + +CONFIG_64BIT=y +CONFIG_X86_64=y +CONFIG_X86=y +CONFIG_INSTRUCTION_DECODER=y +CONFIG_OUTPUT_FORMAT="elf64-x86-64" +CONFIG_LOCKDEP_SUPPORT=y +CONFIG_STACKTRACE_SUPPORT=y +CONFIG_MMU=y +CONFIG_ARCH_MMAP_RND_BITS_MIN=28 +CONFIG_ARCH_MMAP_RND_BITS_MAX=32 +CONFIG_ARCH_MMAP_RND_COMPAT_BITS_MIN=8 +CONFIG_ARCH_MMAP_RND_COMPAT_BITS_MAX=16 +CONFIG_GENERIC_BUG=y +CONFIG_GENERIC_BUG_RELATIVE_POINTERS=y +CONFIG_GENERIC_CALIBRATE_DELAY=y +CONFIG_ARCH_HAS_CPU_RELAX=y +CONFIG_ARCH_HIBERNATION_POSSIBLE=y +CONFIG_ARCH_NR_GPIO=1024 +CONFIG_ARCH_SUSPEND_POSSIBLE=y +CONFIG_AUDIT_ARCH=y +CONFIG_X86_64_SMP=y +CONFIG_ARCH_SUPPORTS_UPROBES=y +CONFIG_FIX_EARLYCON_MEM=y +CONFIG_PGTABLE_LEVELS=5 +CONFIG_CC_HAS_SANE_STACKPROTECTOR=y + +# +# Processor type and features +# +CONFIG_SMP=y +CONFIG_X86_FEATURE_NAMES=y +CONFIG_X86_MPPARSE=y +# CONFIG_GOLDFISH is not set +# CONFIG_X86_CPU_RESCTRL is not set +# CONFIG_X86_EXTENDED_PLATFORM is not set +CONFIG_X86_INTEL_LPSS=y +# CONFIG_X86_AMD_PLATFORM_DEVICE is not set +CONFIG_IOSF_MBI=y +# CONFIG_IOSF_MBI_DEBUG is not set +CONFIG_SCHED_OMIT_FRAME_POINTER=y +# CONFIG_HYPERVISOR_GUEST is not set +# CONFIG_MK8 is not set +# CONFIG_MPSC is not set +# CONFIG_MCORE2 is not set +# CONFIG_MATOM is not set +CONFIG_GENERIC_CPU=y +CONFIG_X86_INTERNODE_CACHE_SHIFT=6 +CONFIG_X86_L1_CACHE_SHIFT=6 +CONFIG_X86_TSC=y +CONFIG_X86_CMPXCHG64=y +CONFIG_X86_CMOV=y +CONFIG_X86_MINIMUM_CPU_FAMILY=64 +CONFIG_X86_DEBUGCTLMSR=y +CONFIG_IA32_FEAT_CTL=y +CONFIG_X86_VMX_FEATURE_NAMES=y +CONFIG_PROCESSOR_SELECT=y +CONFIG_CPU_SUP_INTEL=y +# CONFIG_CPU_SUP_AMD is not set +# CONFIG_CPU_SUP_HYGON is not set +# CONFIG_CPU_SUP_CENTAUR is not set +# CONFIG_CPU_SUP_ZHAOXIN is not set +CONFIG_HPET_TIMER=y +CONFIG_DMI=y +# CONFIG_MAXSMP is not set +CONFIG_NR_CPUS_RANGE_BEGIN=2 +CONFIG_NR_CPUS_RANGE_END=512 +CONFIG_NR_CPUS_DEFAULT=64 +CONFIG_NR_CPUS=128 +CONFIG_SCHED_CLUSTER=y +CONFIG_SCHED_SMT=y +CONFIG_SCHED_MC=y +CONFIG_SCHED_MC_PRIO=y +CONFIG_X86_LOCAL_APIC=y +CONFIG_X86_IO_APIC=y +CONFIG_X86_REROUTE_FOR_BROKEN_BOOT_IRQS=y +# CONFIG_X86_MCE is not set + +# +# Performance monitoring +# +CONFIG_PERF_EVENTS_INTEL_UNCORE=y +CONFIG_PERF_EVENTS_INTEL_RAPL=y +CONFIG_PERF_EVENTS_INTEL_CSTATE=y +# end of Performance monitoring + +CONFIG_X86_16BIT=y +CONFIG_X86_ESPFIX64=y +CONFIG_X86_VSYSCALL_EMULATION=y +# CONFIG_X86_IOPL_IOPERM is not set +# CONFIG_MICROCODE is not set +CONFIG_X86_MSR=y +CONFIG_X86_CPUID=y +CONFIG_X86_5LEVEL=y +CONFIG_X86_DIRECT_GBPAGES=y +# CONFIG_X86_CPA_STATISTICS is not set +CONFIG_NUMA=y +# CONFIG_AMD_NUMA is not set +CONFIG_X86_64_ACPI_NUMA=y +# CONFIG_NUMA_EMU is not set +CONFIG_NODES_SHIFT=10 +CONFIG_ARCH_SPARSEMEM_ENABLE=y +CONFIG_ARCH_SPARSEMEM_DEFAULT=y +CONFIG_ARCH_MEMORY_PROBE=y +CONFIG_ARCH_PROC_KCORE_TEXT=y +CONFIG_ILLEGAL_POINTER_VALUE=0xdead000000000000 +# CONFIG_X86_PMEM_LEGACY is not set +CONFIG_X86_CHECK_BIOS_CORRUPTION=y +CONFIG_X86_BOOTPARAM_MEMORY_CORRUPTION_CHECK=y +CONFIG_MTRR=y +CONFIG_MTRR_SANITIZER=y +CONFIG_MTRR_SANITIZER_ENABLE_DEFAULT=0 +CONFIG_MTRR_SANITIZER_SPARE_REG_NR_DEFAULT=1 +CONFIG_X86_PAT=y +CONFIG_ARCH_USES_PG_UNCACHED=y +# CONFIG_X86_UMIP is not set +CONFIG_CC_HAS_IBT=y +# CONFIG_X86_KERNEL_IBT is not set +# CONFIG_X86_INTEL_MEMORY_PROTECTION_KEYS is not set +CONFIG_X86_INTEL_TSX_MODE_OFF=y +# CONFIG_X86_INTEL_TSX_MODE_ON is not set +# CONFIG_X86_INTEL_TSX_MODE_AUTO is not set +CONFIG_EFI=y +CONFIG_EFI_STUB=y +# CONFIG_EFI_MIXED is not set +# CONFIG_HZ_100 is not set +CONFIG_HZ_250=y +# CONFIG_HZ_300 is not set +# CONFIG_HZ_1000 is not set +CONFIG_HZ=250 +CONFIG_SCHED_HRTICK=y +# CONFIG_KEXEC is not set +CONFIG_KEXEC_FILE=y +CONFIG_ARCH_HAS_KEXEC_PURGATORY=y +# CONFIG_KEXEC_SIG is not set +# CONFIG_CRASH_DUMP is not set +CONFIG_PHYSICAL_START=0x1000000 +CONFIG_RELOCATABLE=y +CONFIG_RANDOMIZE_BASE=y +CONFIG_X86_NEED_RELOCS=y +CONFIG_PHYSICAL_ALIGN=0x1000000 +CONFIG_DYNAMIC_MEMORY_LAYOUT=y +CONFIG_RANDOMIZE_MEMORY=y +CONFIG_RANDOMIZE_MEMORY_PHYSICAL_PADDING=0xa +CONFIG_HOTPLUG_CPU=y +# CONFIG_BOOTPARAM_HOTPLUG_CPU0 is not set +# CONFIG_DEBUG_HOTPLUG_CPU0 is not set +CONFIG_LEGACY_VSYSCALL_XONLY=y +# CONFIG_LEGACY_VSYSCALL_NONE is not set +# CONFIG_CMDLINE_BOOL is not set +CONFIG_MODIFY_LDT_SYSCALL=y +# CONFIG_STRICT_SIGALTSTACK_SIZE is not set +CONFIG_HAVE_LIVEPATCH=y +# end of Processor type and features + +CONFIG_CC_HAS_RETURN_THUNK=y +CONFIG_SPECULATION_MITIGATIONS=y +CONFIG_PAGE_TABLE_ISOLATION=y +CONFIG_RETPOLINE=y +CONFIG_RETHUNK=y +CONFIG_CPU_IBRS_ENTRY=y +# CONFIG_GDS_FORCE_MITIGATION is not set +CONFIG_ARCH_HAS_ADD_PAGES=y +CONFIG_ARCH_MHP_MEMMAP_ON_MEMORY_ENABLE=y + +# +# Power management and ACPI options +# +# CONFIG_SUSPEND is not set +# CONFIG_HIBERNATION is not set +CONFIG_PM=y +# CONFIG_PM_DEBUG is not set +CONFIG_PM_CLK=y +# CONFIG_WQ_POWER_EFFICIENT_DEFAULT is not set +# CONFIG_ENERGY_MODEL is not set +CONFIG_ARCH_SUPPORTS_ACPI=y +CONFIG_ACPI=y +CONFIG_ACPI_LEGACY_TABLES_LOOKUP=y +CONFIG_ARCH_MIGHT_HAVE_ACPI_PDC=y +CONFIG_ACPI_SYSTEM_POWER_STATES_SUPPORT=y +# CONFIG_ACPI_DEBUGGER is not set +CONFIG_ACPI_SPCR_TABLE=y +# CONFIG_ACPI_FPDT is not set +CONFIG_ACPI_LPIT=y +CONFIG_ACPI_REV_OVERRIDE_POSSIBLE=y +# CONFIG_ACPI_EC_DEBUGFS is not set +# CONFIG_ACPI_AC is not set +# CONFIG_ACPI_BATTERY is not set +CONFIG_ACPI_BUTTON=y +# CONFIG_ACPI_FAN is not set +# CONFIG_ACPI_DOCK is not set +CONFIG_ACPI_CPU_FREQ_PSS=y +CONFIG_ACPI_PROCESSOR_CSTATE=y +CONFIG_ACPI_PROCESSOR_IDLE=y +CONFIG_ACPI_CPPC_LIB=y +CONFIG_ACPI_PROCESSOR=y +CONFIG_ACPI_HOTPLUG_CPU=y +# CONFIG_ACPI_PROCESSOR_AGGREGATOR is not set +CONFIG_ACPI_THERMAL=y +CONFIG_ARCH_HAS_ACPI_TABLE_UPGRADE=y +CONFIG_ACPI_TABLE_UPGRADE=y +# CONFIG_ACPI_DEBUG is not set +# CONFIG_ACPI_PCI_SLOT is not set +CONFIG_ACPI_CONTAINER=y +CONFIG_ACPI_HOTPLUG_MEMORY=y +CONFIG_ACPI_HOTPLUG_IOAPIC=y +# CONFIG_ACPI_SBS is not set +# CONFIG_ACPI_HED is not set +# CONFIG_ACPI_CUSTOM_METHOD is not set +# CONFIG_ACPI_BGRT is not set +# CONFIG_ACPI_REDUCED_HARDWARE_ONLY is not set +# CONFIG_ACPI_NFIT is not set +CONFIG_ACPI_NUMA=y +# CONFIG_ACPI_HMAT is not set +CONFIG_HAVE_ACPI_APEI=y +CONFIG_HAVE_ACPI_APEI_NMI=y +# CONFIG_ACPI_APEI is not set +# CONFIG_ACPI_DPTF is not set +# CONFIG_ACPI_CONFIGFS is not set +# CONFIG_ACPI_PFRUT is not set +CONFIG_ACPI_PCC=y +CONFIG_PMIC_OPREGION=y +CONFIG_ACPI_VIOT=y +CONFIG_ACPI_PRMT=y +CONFIG_X86_PM_TIMER=y + +# +# CPU Frequency scaling +# +CONFIG_CPU_FREQ=y +CONFIG_CPU_FREQ_GOV_ATTR_SET=y +# CONFIG_CPU_FREQ_STAT is not set +CONFIG_CPU_FREQ_DEFAULT_GOV_PERFORMANCE=y +# CONFIG_CPU_FREQ_DEFAULT_GOV_POWERSAVE is not set +# CONFIG_CPU_FREQ_DEFAULT_GOV_USERSPACE is not set +# CONFIG_CPU_FREQ_DEFAULT_GOV_SCHEDUTIL is not set +CONFIG_CPU_FREQ_GOV_PERFORMANCE=y +# CONFIG_CPU_FREQ_GOV_POWERSAVE is not set +# CONFIG_CPU_FREQ_GOV_USERSPACE is not set +# CONFIG_CPU_FREQ_GOV_ONDEMAND is not set +# CONFIG_CPU_FREQ_GOV_CONSERVATIVE is not set +CONFIG_CPU_FREQ_GOV_SCHEDUTIL=y + +# +# CPU frequency scaling drivers +# +CONFIG_X86_INTEL_PSTATE=y +# CONFIG_X86_PCC_CPUFREQ is not set +# CONFIG_X86_AMD_PSTATE is not set +# CONFIG_X86_AMD_PSTATE_UT is not set +# CONFIG_X86_ACPI_CPUFREQ is not set +# CONFIG_X86_SPEEDSTEP_CENTRINO is not set +# CONFIG_X86_P4_CLOCKMOD is not set + +# +# shared options +# +# end of CPU Frequency scaling + +# +# CPU Idle +# +CONFIG_CPU_IDLE=y +CONFIG_CPU_IDLE_GOV_LADDER=y +CONFIG_CPU_IDLE_GOV_MENU=y +# CONFIG_CPU_IDLE_GOV_TEO is not set +# end of CPU Idle + +CONFIG_INTEL_IDLE=y +# end of Power management and ACPI options + +# +# Bus options (PCI etc.) +# +CONFIG_PCI_DIRECT=y +CONFIG_PCI_MMCONFIG=y +CONFIG_MMCONF_FAM10H=y +# CONFIG_PCI_CNB20LE_QUIRK is not set +# CONFIG_ISA_BUS is not set +# CONFIG_ISA_DMA_API is not set +# end of Bus options (PCI etc.) + +# +# Binary Emulations +# +# CONFIG_IA32_EMULATION is not set +# CONFIG_X86_X32_ABI is not set +# end of Binary Emulations + +CONFIG_HAVE_KVM=y +CONFIG_HAVE_KVM_PFNCACHE=y +CONFIG_HAVE_KVM_IRQCHIP=y +CONFIG_HAVE_KVM_IRQFD=y +CONFIG_HAVE_KVM_IRQ_ROUTING=y +CONFIG_HAVE_KVM_DIRTY_RING=y +CONFIG_HAVE_KVM_DIRTY_RING_TSO=y +CONFIG_HAVE_KVM_DIRTY_RING_ACQ_REL=y +CONFIG_HAVE_KVM_EVENTFD=y +CONFIG_KVM_MMIO=y +CONFIG_KVM_ASYNC_PF=y +CONFIG_HAVE_KVM_MSI=y +CONFIG_HAVE_KVM_CPU_RELAX_INTERCEPT=y +CONFIG_KVM_VFIO=y +CONFIG_KVM_GENERIC_DIRTYLOG_READ_PROTECT=y +CONFIG_HAVE_KVM_IRQ_BYPASS=y +CONFIG_HAVE_KVM_NO_POLL=y +CONFIG_KVM_XFER_TO_GUEST_WORK=y +CONFIG_HAVE_KVM_PM_NOTIFIER=y +CONFIG_VIRTUALIZATION=y +CONFIG_KVM=y +# CONFIG_KVM_WERROR is not set +CONFIG_KVM_INTEL=y +# CONFIG_KVM_AMD is not set +# CONFIG_KVM_XEN is not set +CONFIG_AS_AVX512=y +CONFIG_AS_SHA1_NI=y +CONFIG_AS_SHA256_NI=y +CONFIG_AS_TPAUSE=y + +# +# General architecture-dependent options +# +CONFIG_CRASH_CORE=y +CONFIG_KEXEC_CORE=y +CONFIG_HOTPLUG_SMT=y +CONFIG_GENERIC_ENTRY=y +CONFIG_JUMP_LABEL=y +# CONFIG_STATIC_KEYS_SELFTEST is not set +# CONFIG_STATIC_CALL_SELFTEST is not set +CONFIG_HAVE_EFFICIENT_UNALIGNED_ACCESS=y +CONFIG_ARCH_USE_BUILTIN_BSWAP=y +CONFIG_USER_RETURN_NOTIFIER=y +CONFIG_HAVE_IOREMAP_PROT=y +CONFIG_HAVE_KPROBES=y +CONFIG_HAVE_KRETPROBES=y +CONFIG_HAVE_OPTPROBES=y +CONFIG_HAVE_KPROBES_ON_FTRACE=y +CONFIG_ARCH_CORRECT_STACKTRACE_ON_KRETPROBE=y +CONFIG_HAVE_FUNCTION_ERROR_INJECTION=y +CONFIG_HAVE_NMI=y +CONFIG_TRACE_IRQFLAGS_SUPPORT=y +CONFIG_TRACE_IRQFLAGS_NMI_SUPPORT=y +CONFIG_HAVE_ARCH_TRACEHOOK=y +CONFIG_HAVE_DMA_CONTIGUOUS=y +CONFIG_GENERIC_SMP_IDLE_THREAD=y +CONFIG_ARCH_HAS_FORTIFY_SOURCE=y +CONFIG_ARCH_HAS_SET_MEMORY=y +CONFIG_ARCH_HAS_SET_DIRECT_MAP=y +CONFIG_ARCH_HAS_CPU_FINALIZE_INIT=y +CONFIG_HAVE_ARCH_THREAD_STRUCT_WHITELIST=y +CONFIG_ARCH_WANTS_DYNAMIC_TASK_STRUCT=y +CONFIG_ARCH_WANTS_NO_INSTR=y +CONFIG_HAVE_ASM_MODVERSIONS=y +CONFIG_HAVE_REGS_AND_STACK_ACCESS_API=y +CONFIG_HAVE_RSEQ=y +CONFIG_HAVE_RUST=y +CONFIG_HAVE_FUNCTION_ARG_ACCESS_API=y +CONFIG_HAVE_HW_BREAKPOINT=y +CONFIG_HAVE_MIXED_BREAKPOINTS_REGS=y +CONFIG_HAVE_USER_RETURN_NOTIFIER=y +CONFIG_HAVE_PERF_EVENTS_NMI=y +CONFIG_HAVE_HARDLOCKUP_DETECTOR_PERF=y +CONFIG_HAVE_PERF_REGS=y +CONFIG_HAVE_PERF_USER_STACK_DUMP=y +CONFIG_HAVE_ARCH_JUMP_LABEL=y +CONFIG_HAVE_ARCH_JUMP_LABEL_RELATIVE=y +CONFIG_MMU_GATHER_MERGE_VMAS=y +CONFIG_ARCH_HAVE_NMI_SAFE_CMPXCHG=y +CONFIG_HAVE_ALIGNED_STRUCT_PAGE=y +CONFIG_HAVE_CMPXCHG_LOCAL=y +CONFIG_HAVE_CMPXCHG_DOUBLE=y +CONFIG_HAVE_ARCH_SECCOMP=y +CONFIG_HAVE_ARCH_SECCOMP_FILTER=y +CONFIG_SECCOMP=y +CONFIG_SECCOMP_FILTER=y +# CONFIG_SECCOMP_CACHE_DEBUG is not set +CONFIG_HAVE_ARCH_STACKLEAK=y +CONFIG_HAVE_STACKPROTECTOR=y +CONFIG_STACKPROTECTOR=y +CONFIG_STACKPROTECTOR_STRONG=y +CONFIG_ARCH_SUPPORTS_LTO_CLANG=y +CONFIG_ARCH_SUPPORTS_LTO_CLANG_THIN=y +CONFIG_LTO_NONE=y +CONFIG_ARCH_SUPPORTS_CFI_CLANG=y +CONFIG_HAVE_ARCH_WITHIN_STACK_FRAMES=y +CONFIG_HAVE_CONTEXT_TRACKING_USER=y +CONFIG_HAVE_CONTEXT_TRACKING_USER_OFFSTACK=y +CONFIG_HAVE_VIRT_CPU_ACCOUNTING_GEN=y +CONFIG_HAVE_IRQ_TIME_ACCOUNTING=y +CONFIG_HAVE_MOVE_PUD=y +CONFIG_HAVE_MOVE_PMD=y +CONFIG_HAVE_ARCH_TRANSPARENT_HUGEPAGE=y +CONFIG_HAVE_ARCH_TRANSPARENT_HUGEPAGE_PUD=y +CONFIG_HAVE_ARCH_HUGE_VMAP=y +CONFIG_HAVE_ARCH_HUGE_VMALLOC=y +CONFIG_ARCH_WANT_HUGE_PMD_SHARE=y +CONFIG_HAVE_ARCH_SOFT_DIRTY=y +CONFIG_HAVE_MOD_ARCH_SPECIFIC=y +CONFIG_MODULES_USE_ELF_RELA=y +CONFIG_HAVE_IRQ_EXIT_ON_IRQ_STACK=y +CONFIG_HAVE_SOFTIRQ_ON_OWN_STACK=y +CONFIG_SOFTIRQ_ON_OWN_STACK=y +CONFIG_ARCH_HAS_ELF_RANDOMIZE=y +CONFIG_HAVE_ARCH_MMAP_RND_BITS=y +CONFIG_HAVE_EXIT_THREAD=y +CONFIG_ARCH_MMAP_RND_BITS=28 +CONFIG_PAGE_SIZE_LESS_THAN_64KB=y +CONFIG_PAGE_SIZE_LESS_THAN_256KB=y +CONFIG_HAVE_OBJTOOL=y +CONFIG_HAVE_JUMP_LABEL_HACK=y +CONFIG_HAVE_NOINSTR_HACK=y +CONFIG_HAVE_NOINSTR_VALIDATION=y +CONFIG_HAVE_UACCESS_VALIDATION=y +CONFIG_HAVE_STACK_VALIDATION=y +CONFIG_HAVE_RELIABLE_STACKTRACE=y +# CONFIG_COMPAT_32BIT_TIME is not set +CONFIG_HAVE_ARCH_VMAP_STACK=y +CONFIG_VMAP_STACK=y +CONFIG_HAVE_ARCH_RANDOMIZE_KSTACK_OFFSET=y +CONFIG_RANDOMIZE_KSTACK_OFFSET=y +# CONFIG_RANDOMIZE_KSTACK_OFFSET_DEFAULT is not set +CONFIG_ARCH_HAS_STRICT_KERNEL_RWX=y +CONFIG_STRICT_KERNEL_RWX=y +CONFIG_ARCH_HAS_STRICT_MODULE_RWX=y +CONFIG_HAVE_ARCH_PREL32_RELOCATIONS=y +CONFIG_ARCH_USE_MEMREMAP_PROT=y +# CONFIG_LOCK_EVENT_COUNTS is not set +CONFIG_ARCH_HAS_MEM_ENCRYPT=y +CONFIG_HAVE_STATIC_CALL=y +CONFIG_HAVE_STATIC_CALL_INLINE=y +CONFIG_HAVE_PREEMPT_DYNAMIC=y +CONFIG_HAVE_PREEMPT_DYNAMIC_CALL=y +CONFIG_ARCH_WANT_LD_ORPHAN_WARN=y +CONFIG_ARCH_SUPPORTS_DEBUG_PAGEALLOC=y +CONFIG_ARCH_SUPPORTS_PAGE_TABLE_CHECK=y +CONFIG_ARCH_HAS_ELFCORE_COMPAT=y +CONFIG_ARCH_HAS_PARANOID_L1D_FLUSH=y +CONFIG_DYNAMIC_SIGFRAME=y +CONFIG_ARCH_HAS_NONLEAF_PMD_YOUNG=y + +# +# GCOV-based kernel profiling +# +# CONFIG_GCOV_KERNEL is not set +CONFIG_ARCH_HAS_GCOV_PROFILE_ALL=y +# end of GCOV-based kernel profiling + +CONFIG_HAVE_GCC_PLUGINS=y +# end of General architecture-dependent options + +CONFIG_RT_MUTEXES=y +CONFIG_BASE_SMALL=0 +# CONFIG_MODULES is not set +CONFIG_BLOCK=y +CONFIG_BLOCK_LEGACY_AUTOLOAD=y +CONFIG_BLK_CGROUP_RWSTAT=y +CONFIG_BLK_DEV_BSG_COMMON=y +CONFIG_BLK_DEV_BSGLIB=y +CONFIG_BLK_DEV_INTEGRITY=y +# CONFIG_BLK_DEV_ZONED is not set +CONFIG_BLK_DEV_THROTTLING=y +# CONFIG_BLK_DEV_THROTTLING_LOW is not set +CONFIG_BLK_WBT=y +CONFIG_BLK_WBT_MQ=y +# CONFIG_BLK_CGROUP_IOLATENCY is not set +# CONFIG_BLK_CGROUP_IOCOST is not set +# CONFIG_BLK_CGROUP_IOPRIO is not set +CONFIG_BLK_DEBUG_FS=y +# CONFIG_BLK_SED_OPAL is not set +# CONFIG_BLK_INLINE_ENCRYPTION is not set + +# +# Partition Types +# +CONFIG_PARTITION_ADVANCED=y +# CONFIG_ACORN_PARTITION is not set +# CONFIG_AIX_PARTITION is not set +# CONFIG_OSF_PARTITION is not set +# CONFIG_AMIGA_PARTITION is not set +# CONFIG_ATARI_PARTITION is not set +# CONFIG_MAC_PARTITION is not set +# CONFIG_MSDOS_PARTITION is not set +# CONFIG_LDM_PARTITION is not set +# CONFIG_SGI_PARTITION is not set +# CONFIG_ULTRIX_PARTITION is not set +# CONFIG_SUN_PARTITION is not set +# CONFIG_KARMA_PARTITION is not set +CONFIG_EFI_PARTITION=y +# CONFIG_SYSV68_PARTITION is not set +# CONFIG_CMDLINE_PARTITION is not set +# end of Partition Types + +CONFIG_BLK_MQ_PCI=y +CONFIG_BLK_MQ_VIRTIO=y +CONFIG_BLK_PM=y + +# +# IO Schedulers +# +# CONFIG_MQ_IOSCHED_DEADLINE is not set +# CONFIG_MQ_IOSCHED_KYBER is not set +# CONFIG_IOSCHED_BFQ is not set +# end of IO Schedulers + +CONFIG_PREEMPT_NOTIFIERS=y +CONFIG_UNINLINE_SPIN_UNLOCK=y +CONFIG_ARCH_SUPPORTS_ATOMIC_RMW=y +CONFIG_MUTEX_SPIN_ON_OWNER=y +CONFIG_RWSEM_SPIN_ON_OWNER=y +CONFIG_LOCK_SPIN_ON_OWNER=y +CONFIG_ARCH_USE_QUEUED_SPINLOCKS=y +CONFIG_QUEUED_SPINLOCKS=y +CONFIG_ARCH_USE_QUEUED_RWLOCKS=y +CONFIG_QUEUED_RWLOCKS=y +CONFIG_ARCH_HAS_NON_OVERLAPPING_ADDRESS_SPACE=y +CONFIG_ARCH_HAS_SYNC_CORE_BEFORE_USERMODE=y +CONFIG_ARCH_HAS_SYSCALL_WRAPPER=y +CONFIG_FREEZER=y + +# +# Executable file formats +# +CONFIG_BINFMT_ELF=y +CONFIG_ELFCORE=y +CONFIG_CORE_DUMP_DEFAULT_ELF_HEADERS=y +CONFIG_BINFMT_SCRIPT=y +CONFIG_BINFMT_MISC=y +CONFIG_COREDUMP=y +# end of Executable file formats + +# +# Memory Management options +# +CONFIG_ZPOOL=y +CONFIG_SWAP=y +CONFIG_ZSWAP=y +# CONFIG_ZSWAP_DEFAULT_ON is not set +# CONFIG_ZSWAP_COMPRESSOR_DEFAULT_DEFLATE is not set +CONFIG_ZSWAP_COMPRESSOR_DEFAULT_LZO=y +# CONFIG_ZSWAP_COMPRESSOR_DEFAULT_842 is not set +# CONFIG_ZSWAP_COMPRESSOR_DEFAULT_LZ4 is not set +# CONFIG_ZSWAP_COMPRESSOR_DEFAULT_LZ4HC is not set +# CONFIG_ZSWAP_COMPRESSOR_DEFAULT_ZSTD is not set +CONFIG_ZSWAP_COMPRESSOR_DEFAULT="lzo" +CONFIG_ZSWAP_ZPOOL_DEFAULT_ZBUD=y +# CONFIG_ZSWAP_ZPOOL_DEFAULT_Z3FOLD is not set +# CONFIG_ZSWAP_ZPOOL_DEFAULT_ZSMALLOC is not set +CONFIG_ZSWAP_ZPOOL_DEFAULT="zbud" +CONFIG_ZBUD=y +# CONFIG_Z3FOLD is not set +CONFIG_ZSMALLOC=y +CONFIG_ZSMALLOC_STAT=y + +# +# SLAB allocator options +# +# CONFIG_SLAB is not set +CONFIG_SLUB=y +# CONFIG_SLOB is not set +CONFIG_SLAB_MERGE_DEFAULT=y +# CONFIG_SLAB_FREELIST_RANDOM is not set +CONFIG_SLAB_FREELIST_HARDENED=y +# CONFIG_SLUB_STATS is not set +CONFIG_SLUB_CPU_PARTIAL=y +# end of SLAB allocator options + +# CONFIG_SHUFFLE_PAGE_ALLOCATOR is not set +# CONFIG_COMPAT_BRK is not set +CONFIG_SPARSEMEM=y +CONFIG_SPARSEMEM_EXTREME=y +CONFIG_SPARSEMEM_VMEMMAP_ENABLE=y +CONFIG_SPARSEMEM_VMEMMAP=y +CONFIG_HAVE_FAST_GUP=y +CONFIG_NUMA_KEEP_MEMINFO=y +CONFIG_MEMORY_ISOLATION=y +CONFIG_EXCLUSIVE_SYSTEM_RAM=y +CONFIG_HAVE_BOOTMEM_INFO_NODE=y +CONFIG_ARCH_ENABLE_MEMORY_HOTPLUG=y +CONFIG_ARCH_ENABLE_MEMORY_HOTREMOVE=y +CONFIG_MEMORY_HOTPLUG=y +CONFIG_MEMORY_HOTPLUG_DEFAULT_ONLINE=y +CONFIG_MEMORY_HOTREMOVE=y +CONFIG_MHP_MEMMAP_ON_MEMORY=y +CONFIG_SPLIT_PTLOCK_CPUS=4 +CONFIG_ARCH_ENABLE_SPLIT_PMD_PTLOCK=y +CONFIG_MEMORY_BALLOON=y +CONFIG_BALLOON_COMPACTION=y +CONFIG_COMPACTION=y +CONFIG_COMPACT_UNEVICTABLE_DEFAULT=1 +CONFIG_PAGE_REPORTING=y +CONFIG_MIGRATION=y +CONFIG_DEVICE_MIGRATION=y +CONFIG_ARCH_ENABLE_HUGEPAGE_MIGRATION=y +CONFIG_ARCH_ENABLE_THP_MIGRATION=y +CONFIG_CONTIG_ALLOC=y +CONFIG_PHYS_ADDR_T_64BIT=y +CONFIG_MMU_NOTIFIER=y +CONFIG_KSM=y +CONFIG_DEFAULT_MMAP_MIN_ADDR=4096 +CONFIG_ARCH_WANT_GENERAL_HUGETLB=y +CONFIG_ARCH_WANTS_THP_SWAP=y +CONFIG_TRANSPARENT_HUGEPAGE=y +# CONFIG_TRANSPARENT_HUGEPAGE_ALWAYS is not set +CONFIG_TRANSPARENT_HUGEPAGE_MADVISE=y +CONFIG_THP_SWAP=y +# CONFIG_READ_ONLY_THP_FOR_FS is not set +CONFIG_NEED_PER_CPU_EMBED_FIRST_CHUNK=y +CONFIG_NEED_PER_CPU_PAGE_FIRST_CHUNK=y +CONFIG_USE_PERCPU_NUMA_NODE_ID=y +CONFIG_HAVE_SETUP_PER_CPU_AREA=y +CONFIG_FRONTSWAP=y +# CONFIG_CMA is not set +CONFIG_GENERIC_EARLY_IOREMAP=y +# CONFIG_DEFERRED_STRUCT_PAGE_INIT is not set +# CONFIG_IDLE_PAGE_TRACKING is not set +CONFIG_ARCH_HAS_CACHE_LINE_SIZE=y +CONFIG_ARCH_HAS_CURRENT_STACK_POINTER=y +CONFIG_ARCH_HAS_PTE_DEVMAP=y +CONFIG_ARCH_HAS_ZONE_DMA_SET=y +CONFIG_ZONE_DMA=y +CONFIG_ZONE_DMA32=y +CONFIG_ZONE_DEVICE=y +# CONFIG_DEVICE_PRIVATE is not set +CONFIG_VM_EVENT_COUNTERS=y +CONFIG_PERCPU_STATS=y +# CONFIG_GUP_TEST is not set +CONFIG_ARCH_HAS_PTE_SPECIAL=y +CONFIG_SECRETMEM=y +# CONFIG_ANON_VMA_NAME is not set +CONFIG_USERFAULTFD=y +CONFIG_HAVE_ARCH_USERFAULTFD_WP=y +CONFIG_HAVE_ARCH_USERFAULTFD_MINOR=y +CONFIG_PTE_MARKER=y +CONFIG_PTE_MARKER_UFFD_WP=y +# CONFIG_LRU_GEN is not set +CONFIG_LOCK_MM_AND_FIND_VMA=y + +# +# Data Access Monitoring +# +# CONFIG_DAMON is not set +# end of Data Access Monitoring +# end of Memory Management options + +CONFIG_NET=y +CONFIG_NET_INGRESS=y +CONFIG_NET_EGRESS=y +CONFIG_SKB_EXTENSIONS=y + +# +# Networking options +# +CONFIG_PACKET=y +CONFIG_PACKET_DIAG=y +CONFIG_UNIX=y +CONFIG_UNIX_SCM=y +CONFIG_AF_UNIX_OOB=y +CONFIG_UNIX_DIAG=y +CONFIG_TLS=y +# CONFIG_TLS_DEVICE is not set +# CONFIG_TLS_TOE is not set +CONFIG_XFRM=y +CONFIG_XFRM_OFFLOAD=y +CONFIG_XFRM_ALGO=y +CONFIG_XFRM_USER=y +# CONFIG_XFRM_INTERFACE is not set +CONFIG_XFRM_SUB_POLICY=y +CONFIG_XFRM_MIGRATE=y +CONFIG_XFRM_STATISTICS=y +CONFIG_XFRM_AH=y +CONFIG_XFRM_ESP=y +CONFIG_XFRM_IPCOMP=y +CONFIG_NET_KEY=y +CONFIG_NET_KEY_MIGRATE=y +CONFIG_XFRM_ESPINTCP=y +CONFIG_XDP_SOCKETS=y +# CONFIG_XDP_SOCKETS_DIAG is not set +CONFIG_INET=y +# CONFIG_IP_MULTICAST is not set +CONFIG_IP_ADVANCED_ROUTER=y +# CONFIG_IP_FIB_TRIE_STATS is not set +CONFIG_IP_MULTIPLE_TABLES=y +# CONFIG_IP_ROUTE_MULTIPATH is not set +# CONFIG_IP_ROUTE_VERBOSE is not set +CONFIG_IP_ROUTE_CLASSID=y +CONFIG_IP_PNP=y +CONFIG_IP_PNP_DHCP=y +CONFIG_IP_PNP_BOOTP=y +CONFIG_IP_PNP_RARP=y +CONFIG_NET_IPIP=y +# CONFIG_NET_IPGRE_DEMUX is not set +CONFIG_NET_IP_TUNNEL=y +CONFIG_IP_MROUTE_COMMON=y +CONFIG_SYN_COOKIES=y +# CONFIG_NET_IPVTI is not set +CONFIG_NET_UDP_TUNNEL=y +CONFIG_NET_FOU=y +CONFIG_NET_FOU_IP_TUNNELS=y +# CONFIG_INET_AH is not set +# CONFIG_INET_ESP is not set +# CONFIG_INET_IPCOMP is not set +CONFIG_INET_TABLE_PERTURB_ORDER=16 +CONFIG_INET_TUNNEL=y +CONFIG_INET_DIAG=y +CONFIG_INET_TCP_DIAG=y +CONFIG_INET_UDP_DIAG=y +CONFIG_INET_RAW_DIAG=y +# CONFIG_INET_DIAG_DESTROY is not set +# CONFIG_TCP_CONG_ADVANCED is not set +CONFIG_TCP_CONG_CUBIC=y +CONFIG_DEFAULT_TCP_CONG="cubic" +# CONFIG_TCP_MD5SIG is not set +CONFIG_IPV6=y +CONFIG_IPV6_ROUTER_PREF=y +CONFIG_IPV6_ROUTE_INFO=y +CONFIG_IPV6_OPTIMISTIC_DAD=y +CONFIG_INET6_AH=y +CONFIG_INET6_ESP=y +CONFIG_INET6_ESP_OFFLOAD=y +CONFIG_INET6_ESPINTCP=y +CONFIG_INET6_IPCOMP=y +# CONFIG_IPV6_MIP6 is not set +CONFIG_IPV6_ILA=y +CONFIG_INET6_XFRM_TUNNEL=y +CONFIG_INET6_TUNNEL=y +CONFIG_IPV6_VTI=y +CONFIG_IPV6_SIT=y +# CONFIG_IPV6_SIT_6RD is not set +CONFIG_IPV6_NDISC_NODETYPE=y +CONFIG_IPV6_TUNNEL=y +CONFIG_IPV6_FOU=y +CONFIG_IPV6_FOU_TUNNEL=y +CONFIG_IPV6_MULTIPLE_TABLES=y +CONFIG_IPV6_SUBTREES=y +CONFIG_IPV6_MROUTE=y +CONFIG_IPV6_MROUTE_MULTIPLE_TABLES=y +CONFIG_IPV6_PIMSM_V2=y +CONFIG_IPV6_SEG6_LWTUNNEL=y +CONFIG_IPV6_SEG6_HMAC=y +CONFIG_IPV6_SEG6_BPF=y +CONFIG_IPV6_RPL_LWTUNNEL=y +# CONFIG_IPV6_IOAM6_LWTUNNEL is not set +# CONFIG_MPTCP is not set +CONFIG_NETWORK_SECMARK=y +CONFIG_NET_PTP_CLASSIFY=y +# CONFIG_NETWORK_PHY_TIMESTAMPING is not set +CONFIG_NETFILTER=y +CONFIG_NETFILTER_ADVANCED=y +CONFIG_BRIDGE_NETFILTER=y + +# +# Core Netfilter Configuration +# +CONFIG_NETFILTER_INGRESS=y +CONFIG_NETFILTER_EGRESS=y +CONFIG_NETFILTER_SKIP_EGRESS=y +CONFIG_NETFILTER_NETLINK=y +CONFIG_NETFILTER_FAMILY_BRIDGE=y +CONFIG_NETFILTER_FAMILY_ARP=y +# CONFIG_NETFILTER_NETLINK_HOOK is not set +CONFIG_NETFILTER_NETLINK_ACCT=y +CONFIG_NETFILTER_NETLINK_QUEUE=y +CONFIG_NETFILTER_NETLINK_LOG=y +CONFIG_NETFILTER_NETLINK_OSF=y +CONFIG_NF_CONNTRACK=y +CONFIG_NF_LOG_SYSLOG=y +CONFIG_NETFILTER_CONNCOUNT=y +CONFIG_NF_CONNTRACK_MARK=y +# CONFIG_NF_CONNTRACK_SECMARK is not set +CONFIG_NF_CONNTRACK_ZONES=y +CONFIG_NF_CONNTRACK_PROCFS=y +CONFIG_NF_CONNTRACK_EVENTS=y +CONFIG_NF_CONNTRACK_TIMEOUT=y +CONFIG_NF_CONNTRACK_TIMESTAMP=y +CONFIG_NF_CONNTRACK_LABELS=y +CONFIG_NF_CT_PROTO_DCCP=y +CONFIG_NF_CT_PROTO_SCTP=y +CONFIG_NF_CT_PROTO_UDPLITE=y +# CONFIG_NF_CONNTRACK_AMANDA is not set +# CONFIG_NF_CONNTRACK_FTP is not set +# CONFIG_NF_CONNTRACK_H323 is not set +# CONFIG_NF_CONNTRACK_IRC is not set +# CONFIG_NF_CONNTRACK_NETBIOS_NS is not set +# CONFIG_NF_CONNTRACK_SNMP is not set +# CONFIG_NF_CONNTRACK_PPTP is not set +# CONFIG_NF_CONNTRACK_SANE is not set +# CONFIG_NF_CONNTRACK_SIP is not set +# CONFIG_NF_CONNTRACK_TFTP is not set +CONFIG_NF_CT_NETLINK=y +# CONFIG_NF_CT_NETLINK_TIMEOUT is not set +# CONFIG_NETFILTER_NETLINK_GLUE_CT is not set +CONFIG_NF_NAT=y +CONFIG_NF_NAT_REDIRECT=y +CONFIG_NF_NAT_MASQUERADE=y +CONFIG_NETFILTER_SYNPROXY=y +CONFIG_NF_TABLES=y +CONFIG_NF_TABLES_INET=y +CONFIG_NF_TABLES_NETDEV=y +CONFIG_NFT_NUMGEN=y +CONFIG_NFT_CT=y +CONFIG_NFT_CONNLIMIT=y +CONFIG_NFT_LOG=y +CONFIG_NFT_LIMIT=y +CONFIG_NFT_MASQ=y +CONFIG_NFT_REDIR=y +CONFIG_NFT_NAT=y +CONFIG_NFT_TUNNEL=y +CONFIG_NFT_OBJREF=y +CONFIG_NFT_QUEUE=y +CONFIG_NFT_QUOTA=y +CONFIG_NFT_REJECT=y +CONFIG_NFT_REJECT_INET=y +CONFIG_NFT_COMPAT=y +CONFIG_NFT_HASH=y +CONFIG_NFT_FIB=y +CONFIG_NFT_FIB_INET=y +CONFIG_NFT_XFRM=y +CONFIG_NFT_SOCKET=y +CONFIG_NFT_OSF=y +CONFIG_NFT_TPROXY=y +CONFIG_NFT_SYNPROXY=y +CONFIG_NF_DUP_NETDEV=y +CONFIG_NFT_DUP_NETDEV=y +CONFIG_NFT_FWD_NETDEV=y +CONFIG_NFT_FIB_NETDEV=y +CONFIG_NFT_REJECT_NETDEV=y +CONFIG_NETFILTER_XTABLES=y + +# +# Xtables combined modules +# +CONFIG_NETFILTER_XT_MARK=y +CONFIG_NETFILTER_XT_CONNMARK=y +CONFIG_NETFILTER_XT_SET=y + +# +# Xtables targets +# +CONFIG_NETFILTER_XT_TARGET_AUDIT=y +CONFIG_NETFILTER_XT_TARGET_CHECKSUM=y +CONFIG_NETFILTER_XT_TARGET_CLASSIFY=y +CONFIG_NETFILTER_XT_TARGET_CONNMARK=y +CONFIG_NETFILTER_XT_TARGET_CT=y +CONFIG_NETFILTER_XT_TARGET_DSCP=y +CONFIG_NETFILTER_XT_TARGET_HL=y +CONFIG_NETFILTER_XT_TARGET_HMARK=y +CONFIG_NETFILTER_XT_TARGET_IDLETIMER=y +CONFIG_NETFILTER_XT_TARGET_LOG=y +CONFIG_NETFILTER_XT_TARGET_MARK=y +CONFIG_NETFILTER_XT_NAT=y +CONFIG_NETFILTER_XT_TARGET_NETMAP=y +CONFIG_NETFILTER_XT_TARGET_NFLOG=y +CONFIG_NETFILTER_XT_TARGET_NFQUEUE=y +CONFIG_NETFILTER_XT_TARGET_NOTRACK=y +CONFIG_NETFILTER_XT_TARGET_RATEEST=y +CONFIG_NETFILTER_XT_TARGET_REDIRECT=y +CONFIG_NETFILTER_XT_TARGET_MASQUERADE=y +CONFIG_NETFILTER_XT_TARGET_TEE=y +CONFIG_NETFILTER_XT_TARGET_TPROXY=y +CONFIG_NETFILTER_XT_TARGET_TRACE=y +# CONFIG_NETFILTER_XT_TARGET_SECMARK is not set +CONFIG_NETFILTER_XT_TARGET_TCPMSS=y +CONFIG_NETFILTER_XT_TARGET_TCPOPTSTRIP=y + +# +# Xtables matches +# +CONFIG_NETFILTER_XT_MATCH_ADDRTYPE=y +CONFIG_NETFILTER_XT_MATCH_BPF=y +CONFIG_NETFILTER_XT_MATCH_CGROUP=y +CONFIG_NETFILTER_XT_MATCH_CLUSTER=y +CONFIG_NETFILTER_XT_MATCH_COMMENT=y +CONFIG_NETFILTER_XT_MATCH_CONNBYTES=y +CONFIG_NETFILTER_XT_MATCH_CONNLABEL=y +CONFIG_NETFILTER_XT_MATCH_CONNLIMIT=y +CONFIG_NETFILTER_XT_MATCH_CONNMARK=y +CONFIG_NETFILTER_XT_MATCH_CONNTRACK=y +CONFIG_NETFILTER_XT_MATCH_CPU=y +CONFIG_NETFILTER_XT_MATCH_DCCP=y +CONFIG_NETFILTER_XT_MATCH_DEVGROUP=y +CONFIG_NETFILTER_XT_MATCH_DSCP=y +CONFIG_NETFILTER_XT_MATCH_ECN=y +CONFIG_NETFILTER_XT_MATCH_ESP=y +CONFIG_NETFILTER_XT_MATCH_HASHLIMIT=y +CONFIG_NETFILTER_XT_MATCH_HELPER=y +CONFIG_NETFILTER_XT_MATCH_HL=y +CONFIG_NETFILTER_XT_MATCH_IPCOMP=y +CONFIG_NETFILTER_XT_MATCH_IPRANGE=y +# CONFIG_NETFILTER_XT_MATCH_IPVS is not set +CONFIG_NETFILTER_XT_MATCH_L2TP=y +CONFIG_NETFILTER_XT_MATCH_LENGTH=y +CONFIG_NETFILTER_XT_MATCH_LIMIT=y +CONFIG_NETFILTER_XT_MATCH_MAC=y +CONFIG_NETFILTER_XT_MATCH_MARK=y +CONFIG_NETFILTER_XT_MATCH_MULTIPORT=y +CONFIG_NETFILTER_XT_MATCH_NFACCT=y +CONFIG_NETFILTER_XT_MATCH_OSF=y +CONFIG_NETFILTER_XT_MATCH_OWNER=y +CONFIG_NETFILTER_XT_MATCH_POLICY=y +CONFIG_NETFILTER_XT_MATCH_PHYSDEV=y +CONFIG_NETFILTER_XT_MATCH_PKTTYPE=y +CONFIG_NETFILTER_XT_MATCH_QUOTA=y +CONFIG_NETFILTER_XT_MATCH_RATEEST=y +CONFIG_NETFILTER_XT_MATCH_REALM=y +CONFIG_NETFILTER_XT_MATCH_RECENT=y +CONFIG_NETFILTER_XT_MATCH_SCTP=y +CONFIG_NETFILTER_XT_MATCH_SOCKET=y +CONFIG_NETFILTER_XT_MATCH_STATE=y +CONFIG_NETFILTER_XT_MATCH_STATISTIC=y +CONFIG_NETFILTER_XT_MATCH_STRING=y +CONFIG_NETFILTER_XT_MATCH_TCPMSS=y +CONFIG_NETFILTER_XT_MATCH_TIME=y +CONFIG_NETFILTER_XT_MATCH_U32=y +# end of Core Netfilter Configuration + +CONFIG_IP_SET=y +CONFIG_IP_SET_MAX=256 +# CONFIG_IP_SET_BITMAP_IP is not set +# CONFIG_IP_SET_BITMAP_IPMAC is not set +# CONFIG_IP_SET_BITMAP_PORT is not set +CONFIG_IP_SET_HASH_IP=y +# CONFIG_IP_SET_HASH_IPMARK is not set +# CONFIG_IP_SET_HASH_IPPORT is not set +# CONFIG_IP_SET_HASH_IPPORTIP is not set +# CONFIG_IP_SET_HASH_IPPORTNET is not set +# CONFIG_IP_SET_HASH_IPMAC is not set +# CONFIG_IP_SET_HASH_MAC is not set +# CONFIG_IP_SET_HASH_NETPORTNET is not set +# CONFIG_IP_SET_HASH_NET is not set +# CONFIG_IP_SET_HASH_NETNET is not set +# CONFIG_IP_SET_HASH_NETPORT is not set +# CONFIG_IP_SET_HASH_NETIFACE is not set +# CONFIG_IP_SET_LIST_SET is not set +CONFIG_IP_VS=y +# CONFIG_IP_VS_IPV6 is not set +# CONFIG_IP_VS_DEBUG is not set +CONFIG_IP_VS_TAB_BITS=12 + +# +# IPVS transport protocol load balancing support +# +CONFIG_IP_VS_PROTO_TCP=y +CONFIG_IP_VS_PROTO_UDP=y +CONFIG_IP_VS_PROTO_AH_ESP=y +CONFIG_IP_VS_PROTO_ESP=y +CONFIG_IP_VS_PROTO_AH=y +CONFIG_IP_VS_PROTO_SCTP=y + +# +# IPVS scheduler +# +# CONFIG_IP_VS_RR is not set +# CONFIG_IP_VS_WRR is not set +# CONFIG_IP_VS_LC is not set +# CONFIG_IP_VS_WLC is not set +# CONFIG_IP_VS_FO is not set +# CONFIG_IP_VS_OVF is not set +# CONFIG_IP_VS_LBLC is not set +# CONFIG_IP_VS_LBLCR is not set +CONFIG_IP_VS_DH=y +# CONFIG_IP_VS_SH is not set +# CONFIG_IP_VS_MH is not set +# CONFIG_IP_VS_SED is not set +# CONFIG_IP_VS_NQ is not set +# CONFIG_IP_VS_TWOS is not set + +# +# IPVS SH scheduler +# +CONFIG_IP_VS_SH_TAB_BITS=8 + +# +# IPVS MH scheduler +# +CONFIG_IP_VS_MH_TAB_INDEX=12 + +# +# IPVS application helper +# +# CONFIG_IP_VS_NFCT is not set + +# +# IP: Netfilter Configuration +# +CONFIG_NF_DEFRAG_IPV4=y +CONFIG_NF_SOCKET_IPV4=y +CONFIG_NF_TPROXY_IPV4=y +CONFIG_NF_TABLES_IPV4=y +CONFIG_NFT_REJECT_IPV4=y +CONFIG_NFT_DUP_IPV4=y +CONFIG_NFT_FIB_IPV4=y +CONFIG_NF_TABLES_ARP=y +CONFIG_NF_DUP_IPV4=y +CONFIG_NF_LOG_ARP=y +CONFIG_NF_LOG_IPV4=y +CONFIG_NF_REJECT_IPV4=y +CONFIG_IP_NF_IPTABLES=y +CONFIG_IP_NF_MATCH_AH=y +CONFIG_IP_NF_MATCH_ECN=y +CONFIG_IP_NF_MATCH_RPFILTER=y +CONFIG_IP_NF_MATCH_TTL=y +CONFIG_IP_NF_FILTER=y +CONFIG_IP_NF_TARGET_REJECT=y +CONFIG_IP_NF_TARGET_SYNPROXY=y +CONFIG_IP_NF_NAT=y +CONFIG_IP_NF_TARGET_MASQUERADE=y +CONFIG_IP_NF_TARGET_NETMAP=y +CONFIG_IP_NF_TARGET_REDIRECT=y +CONFIG_IP_NF_MANGLE=y +CONFIG_IP_NF_TARGET_CLUSTERIP=y +CONFIG_IP_NF_TARGET_ECN=y +CONFIG_IP_NF_TARGET_TTL=y +CONFIG_IP_NF_RAW=y +CONFIG_IP_NF_ARPTABLES=y +CONFIG_IP_NF_ARPFILTER=y +CONFIG_IP_NF_ARP_MANGLE=y +# end of IP: Netfilter Configuration + +# +# IPv6: Netfilter Configuration +# +CONFIG_NF_SOCKET_IPV6=y +CONFIG_NF_TPROXY_IPV6=y +CONFIG_NF_TABLES_IPV6=y +CONFIG_NFT_REJECT_IPV6=y +CONFIG_NFT_DUP_IPV6=y +CONFIG_NFT_FIB_IPV6=y +CONFIG_NF_DUP_IPV6=y +CONFIG_NF_REJECT_IPV6=y +CONFIG_NF_LOG_IPV6=y +CONFIG_IP6_NF_IPTABLES=y +CONFIG_IP6_NF_MATCH_AH=y +CONFIG_IP6_NF_MATCH_EUI64=y +CONFIG_IP6_NF_MATCH_FRAG=y +CONFIG_IP6_NF_MATCH_OPTS=y +CONFIG_IP6_NF_MATCH_HL=y +CONFIG_IP6_NF_MATCH_IPV6HEADER=y +CONFIG_IP6_NF_MATCH_MH=y +CONFIG_IP6_NF_MATCH_RPFILTER=y +CONFIG_IP6_NF_MATCH_RT=y +CONFIG_IP6_NF_MATCH_SRH=y +CONFIG_IP6_NF_TARGET_HL=y +CONFIG_IP6_NF_FILTER=y +CONFIG_IP6_NF_TARGET_REJECT=y +CONFIG_IP6_NF_TARGET_SYNPROXY=y +CONFIG_IP6_NF_MANGLE=y +CONFIG_IP6_NF_RAW=y +CONFIG_IP6_NF_NAT=y +CONFIG_IP6_NF_TARGET_MASQUERADE=y +CONFIG_IP6_NF_TARGET_NPT=y +# end of IPv6: Netfilter Configuration + +CONFIG_NF_DEFRAG_IPV6=y +CONFIG_NF_TABLES_BRIDGE=y +# CONFIG_NFT_BRIDGE_META is not set +# CONFIG_NFT_BRIDGE_REJECT is not set +CONFIG_NF_CONNTRACK_BRIDGE=y +CONFIG_BRIDGE_NF_EBTABLES=y +CONFIG_BRIDGE_EBT_BROUTE=y +CONFIG_BRIDGE_EBT_T_FILTER=y +CONFIG_BRIDGE_EBT_T_NAT=y +CONFIG_BRIDGE_EBT_802_3=y +CONFIG_BRIDGE_EBT_AMONG=y +CONFIG_BRIDGE_EBT_ARP=y +CONFIG_BRIDGE_EBT_IP=y +CONFIG_BRIDGE_EBT_IP6=y +CONFIG_BRIDGE_EBT_LIMIT=y +CONFIG_BRIDGE_EBT_MARK=y +CONFIG_BRIDGE_EBT_PKTTYPE=y +CONFIG_BRIDGE_EBT_STP=y +CONFIG_BRIDGE_EBT_VLAN=y +CONFIG_BRIDGE_EBT_ARPREPLY=y +CONFIG_BRIDGE_EBT_DNAT=y +CONFIG_BRIDGE_EBT_MARK_T=y +CONFIG_BRIDGE_EBT_REDIRECT=y +CONFIG_BRIDGE_EBT_SNAT=y +CONFIG_BRIDGE_EBT_LOG=y +CONFIG_BRIDGE_EBT_NFLOG=y +CONFIG_BPFILTER=y +CONFIG_BPFILTER_UMH=y +# CONFIG_IP_DCCP is not set +CONFIG_IP_SCTP=y +# CONFIG_SCTP_DBG_OBJCNT is not set +CONFIG_SCTP_DEFAULT_COOKIE_HMAC_MD5=y +# CONFIG_SCTP_DEFAULT_COOKIE_HMAC_SHA1 is not set +# CONFIG_SCTP_DEFAULT_COOKIE_HMAC_NONE is not set +CONFIG_SCTP_COOKIE_HMAC_MD5=y +# CONFIG_SCTP_COOKIE_HMAC_SHA1 is not set +CONFIG_INET_SCTP_DIAG=y +# CONFIG_RDS is not set +# CONFIG_TIPC is not set +# CONFIG_ATM is not set +# CONFIG_L2TP is not set +CONFIG_STP=y +CONFIG_BRIDGE=y +CONFIG_BRIDGE_IGMP_SNOOPING=y +# CONFIG_BRIDGE_VLAN_FILTERING is not set +# CONFIG_BRIDGE_MRP is not set +# CONFIG_BRIDGE_CFM is not set +# CONFIG_NET_DSA is not set +CONFIG_VLAN_8021Q=y +# CONFIG_VLAN_8021Q_GVRP is not set +# CONFIG_VLAN_8021Q_MVRP is not set +CONFIG_LLC=y +# CONFIG_LLC2 is not set +# CONFIG_ATALK is not set +# CONFIG_X25 is not set +# CONFIG_LAPB is not set +# CONFIG_PHONET is not set +# CONFIG_6LOWPAN is not set +# CONFIG_IEEE802154 is not set +CONFIG_NET_SCHED=y + +# +# Queueing/Scheduling +# +CONFIG_NET_SCH_CBQ=y +CONFIG_NET_SCH_HTB=y +CONFIG_NET_SCH_HFSC=y +CONFIG_NET_SCH_PRIO=y +CONFIG_NET_SCH_MULTIQ=y +CONFIG_NET_SCH_RED=y +CONFIG_NET_SCH_SFB=y +CONFIG_NET_SCH_SFQ=y +CONFIG_NET_SCH_TEQL=y +CONFIG_NET_SCH_TBF=y +CONFIG_NET_SCH_CBS=y +CONFIG_NET_SCH_ETF=y +CONFIG_NET_SCH_TAPRIO=y +CONFIG_NET_SCH_GRED=y +CONFIG_NET_SCH_DSMARK=y +CONFIG_NET_SCH_NETEM=y +CONFIG_NET_SCH_DRR=y +CONFIG_NET_SCH_MQPRIO=y +CONFIG_NET_SCH_SKBPRIO=y +CONFIG_NET_SCH_CHOKE=y +CONFIG_NET_SCH_QFQ=y +CONFIG_NET_SCH_CODEL=y +CONFIG_NET_SCH_FQ_CODEL=y +CONFIG_NET_SCH_CAKE=y +CONFIG_NET_SCH_FQ=y +CONFIG_NET_SCH_HHF=y +CONFIG_NET_SCH_PIE=y +CONFIG_NET_SCH_FQ_PIE=y +CONFIG_NET_SCH_INGRESS=y +CONFIG_NET_SCH_PLUG=y +CONFIG_NET_SCH_ETS=y +# CONFIG_NET_SCH_DEFAULT is not set + +# +# Classification +# +CONFIG_NET_CLS=y +CONFIG_NET_CLS_BASIC=y +CONFIG_NET_CLS_ROUTE4=y +CONFIG_NET_CLS_FW=y +CONFIG_NET_CLS_U32=y +CONFIG_CLS_U32_PERF=y +CONFIG_CLS_U32_MARK=y +CONFIG_NET_CLS_FLOW=y +CONFIG_NET_CLS_CGROUP=y +CONFIG_NET_CLS_BPF=y +CONFIG_NET_CLS_FLOWER=y +CONFIG_NET_CLS_MATCHALL=y +CONFIG_NET_EMATCH=y +CONFIG_NET_EMATCH_STACK=32 +CONFIG_NET_EMATCH_CMP=y +CONFIG_NET_EMATCH_NBYTE=y +CONFIG_NET_EMATCH_U32=y +CONFIG_NET_EMATCH_META=y +CONFIG_NET_EMATCH_TEXT=y +CONFIG_NET_EMATCH_IPT=y +CONFIG_NET_CLS_ACT=y +CONFIG_NET_ACT_POLICE=y +CONFIG_NET_ACT_GACT=y +CONFIG_GACT_PROB=y +CONFIG_NET_ACT_MIRRED=y +CONFIG_NET_ACT_SAMPLE=y +CONFIG_NET_ACT_IPT=y +CONFIG_NET_ACT_NAT=y +CONFIG_NET_ACT_PEDIT=y +CONFIG_NET_ACT_SIMP=y +CONFIG_NET_ACT_SKBEDIT=y +CONFIG_NET_ACT_CSUM=y +CONFIG_NET_ACT_MPLS=y +CONFIG_NET_ACT_VLAN=y +CONFIG_NET_ACT_BPF=y +CONFIG_NET_ACT_CONNMARK=y +CONFIG_NET_ACT_CTINFO=y +CONFIG_NET_ACT_SKBMOD=y +CONFIG_NET_ACT_IFE=y +CONFIG_NET_ACT_TUNNEL_KEY=y +CONFIG_NET_ACT_GATE=y +CONFIG_NET_IFE_SKBMARK=y +CONFIG_NET_IFE_SKBPRIO=y +CONFIG_NET_IFE_SKBTCINDEX=y +CONFIG_NET_TC_SKB_EXT=y +CONFIG_NET_SCH_FIFO=y +# CONFIG_DCB is not set +CONFIG_DNS_RESOLVER=y +# CONFIG_BATMAN_ADV is not set +# CONFIG_OPENVSWITCH is not set +CONFIG_VSOCKETS=y +CONFIG_VSOCKETS_DIAG=y +# CONFIG_VSOCKETS_LOOPBACK is not set +CONFIG_VIRTIO_VSOCKETS=y +CONFIG_VIRTIO_VSOCKETS_COMMON=y +CONFIG_NETLINK_DIAG=y +# CONFIG_MPLS is not set +# CONFIG_NET_NSH is not set +# CONFIG_HSR is not set +# CONFIG_NET_SWITCHDEV is not set +CONFIG_NET_L3_MASTER_DEV=y +# CONFIG_QRTR is not set +# CONFIG_NET_NCSI is not set +CONFIG_PCPU_DEV_REFCNT=y +CONFIG_RPS=y +CONFIG_RFS_ACCEL=y +CONFIG_SOCK_RX_QUEUE_MAPPING=y +CONFIG_XPS=y +CONFIG_CGROUP_NET_PRIO=y +CONFIG_CGROUP_NET_CLASSID=y +CONFIG_NET_RX_BUSY_POLL=y +CONFIG_BQL=y +# CONFIG_BPF_STREAM_PARSER is not set +CONFIG_NET_FLOW_LIMIT=y + +# +# Network testing +# +# CONFIG_NET_PKTGEN is not set +# end of Network testing +# end of Networking options + +# CONFIG_HAMRADIO is not set +# CONFIG_CAN is not set +# CONFIG_BT is not set +# CONFIG_AF_RXRPC is not set +# CONFIG_AF_KCM is not set +CONFIG_STREAM_PARSER=y +# CONFIG_MCTP is not set +CONFIG_FIB_RULES=y +# CONFIG_WIRELESS is not set +# CONFIG_RFKILL is not set +# CONFIG_NET_9P is not set +# CONFIG_CAIF is not set +# CONFIG_CEPH_LIB is not set +# CONFIG_NFC is not set +CONFIG_PSAMPLE=y +CONFIG_NET_IFE=y +CONFIG_LWTUNNEL=y +CONFIG_LWTUNNEL_BPF=y +CONFIG_DST_CACHE=y +CONFIG_GRO_CELLS=y +CONFIG_NET_SOCK_MSG=y +CONFIG_PAGE_POOL=y +# CONFIG_PAGE_POOL_STATS is not set +CONFIG_FAILOVER=y +# CONFIG_ETHTOOL_NETLINK is not set + +# +# Device Drivers +# +CONFIG_HAVE_EISA=y +# CONFIG_EISA is not set +CONFIG_HAVE_PCI=y +CONFIG_PCI=y +CONFIG_PCI_DOMAINS=y +CONFIG_PCIEPORTBUS=y +# CONFIG_HOTPLUG_PCI_PCIE is not set +CONFIG_PCIEAER=y +# CONFIG_PCIEAER_INJECT is not set +# CONFIG_PCIE_ECRC is not set +CONFIG_PCIEASPM=y +CONFIG_PCIEASPM_DEFAULT=y +# CONFIG_PCIEASPM_POWERSAVE is not set +# CONFIG_PCIEASPM_POWER_SUPERSAVE is not set +# CONFIG_PCIEASPM_PERFORMANCE is not set +CONFIG_PCIE_PME=y +# CONFIG_PCIE_DPC is not set +# CONFIG_PCIE_PTM is not set +CONFIG_PCI_MSI=y +CONFIG_PCI_MSI_IRQ_DOMAIN=y +CONFIG_PCI_QUIRKS=y +CONFIG_PCI_DEBUG=y +CONFIG_PCI_STUB=y +CONFIG_PCI_LOCKLESS_CONFIG=y +# CONFIG_PCI_IOV is not set +# CONFIG_PCI_PRI is not set +# CONFIG_PCI_PASID is not set +# CONFIG_PCI_P2PDMA is not set +CONFIG_PCI_LABEL=y +# CONFIG_PCIE_BUS_TUNE_OFF is not set +CONFIG_PCIE_BUS_DEFAULT=y +# CONFIG_PCIE_BUS_SAFE is not set +# CONFIG_PCIE_BUS_PERFORMANCE is not set +# CONFIG_PCIE_BUS_PEER2PEER is not set +CONFIG_VGA_ARB=y +CONFIG_VGA_ARB_MAX_GPUS=16 +CONFIG_HOTPLUG_PCI=y +CONFIG_HOTPLUG_PCI_ACPI=y +# CONFIG_HOTPLUG_PCI_ACPI_IBM is not set +# CONFIG_HOTPLUG_PCI_CPCI is not set +# CONFIG_HOTPLUG_PCI_SHPC is not set + +# +# PCI controller drivers +# +# CONFIG_VMD is not set + +# +# DesignWare PCI Core Support +# +# CONFIG_PCIE_DW_PLAT_HOST is not set +# CONFIG_PCI_MESON is not set +# end of DesignWare PCI Core Support + +# +# Mobiveil PCIe Core Support +# +# end of Mobiveil PCIe Core Support + +# +# Cadence PCIe controllers support +# +# end of Cadence PCIe controllers support +# end of PCI controller drivers + +# +# PCI Endpoint +# +# CONFIG_PCI_ENDPOINT is not set +# end of PCI Endpoint + +# +# PCI switch controller drivers +# +# CONFIG_PCI_SW_SWITCHTEC is not set +# end of PCI switch controller drivers + +# CONFIG_CXL_BUS is not set +# CONFIG_PCCARD is not set +# CONFIG_RAPIDIO is not set + +# +# Generic Driver Options +# +CONFIG_UEVENT_HELPER=y +CONFIG_UEVENT_HELPER_PATH="/sbin/hotplug" +CONFIG_DEVTMPFS=y +CONFIG_DEVTMPFS_MOUNT=y +# CONFIG_DEVTMPFS_SAFE is not set +CONFIG_STANDALONE=y +CONFIG_PREVENT_FIRMWARE_BUILD=y + +# +# Firmware loader +# +CONFIG_FW_LOADER=y +CONFIG_FW_LOADER_PAGED_BUF=y +CONFIG_FW_LOADER_SYSFS=y +CONFIG_EXTRA_FIRMWARE="" +CONFIG_FW_LOADER_USER_HELPER=y +# CONFIG_FW_LOADER_USER_HELPER_FALLBACK is not set +# CONFIG_FW_LOADER_COMPRESS is not set +# CONFIG_FW_UPLOAD is not set +# end of Firmware loader + +CONFIG_ALLOW_DEV_COREDUMP=y +# CONFIG_DEBUG_DRIVER is not set +# CONFIG_DEBUG_DEVRES is not set +# CONFIG_DEBUG_TEST_DRIVER_REMOVE is not set +CONFIG_GENERIC_CPU_AUTOPROBE=y +CONFIG_GENERIC_CPU_VULNERABILITIES=y +# end of Generic Driver Options + +# +# Bus devices +# +# CONFIG_MHI_BUS is not set +# CONFIG_MHI_BUS_EP is not set +# end of Bus devices + +CONFIG_CONNECTOR=y +CONFIG_PROC_EVENTS=y + +# +# Firmware Drivers +# + +# +# ARM System Control and Management Interface Protocol +# +# end of ARM System Control and Management Interface Protocol + +# CONFIG_EDD is not set +CONFIG_FIRMWARE_MEMMAP=y +CONFIG_DMIID=y +CONFIG_DMI_SYSFS=y +CONFIG_DMI_SCAN_MACHINE_NON_EFI_FALLBACK=y +# CONFIG_FW_CFG_SYSFS is not set +# CONFIG_SYSFB_SIMPLEFB is not set +# CONFIG_GOOGLE_FIRMWARE is not set + +# +# EFI (Extensible Firmware Interface) Support +# +CONFIG_EFI_ESRT=y +CONFIG_EFI_RUNTIME_MAP=y +# CONFIG_EFI_FAKE_MEMMAP is not set +CONFIG_EFI_DXE_MEM_ATTRIBUTES=y +CONFIG_EFI_RUNTIME_WRAPPERS=y +CONFIG_EFI_GENERIC_STUB_INITRD_CMDLINE_LOADER=y +# CONFIG_EFI_BOOTLOADER_CONTROL is not set +# CONFIG_EFI_CAPSULE_LOADER is not set +# CONFIG_EFI_TEST is not set +# CONFIG_APPLE_PROPERTIES is not set +# CONFIG_RESET_ATTACK_MITIGATION is not set +# CONFIG_EFI_RCI2_TABLE is not set +# CONFIG_EFI_DISABLE_PCI_DMA is not set +CONFIG_EFI_EARLYCON=y +# CONFIG_EFI_CUSTOM_SSDT_OVERLAYS is not set +# CONFIG_EFI_DISABLE_RUNTIME is not set +# CONFIG_EFI_COCO_SECRET is not set +# end of EFI (Extensible Firmware Interface) Support + +# +# Tegra firmware driver +# +# end of Tegra firmware driver +# end of Firmware Drivers + +# CONFIG_GNSS is not set +# CONFIG_MTD is not set +# CONFIG_OF is not set +CONFIG_ARCH_MIGHT_HAVE_PC_PARPORT=y +# CONFIG_PARPORT is not set +CONFIG_PNP=y +# CONFIG_PNP_DEBUG_MESSAGES is not set + +# +# Protocols +# +CONFIG_PNPACPI=y +CONFIG_BLK_DEV=y +CONFIG_BLK_DEV_NULL_BLK=y +# CONFIG_BLK_DEV_PCIESSD_MTIP32XX is not set +CONFIG_ZRAM=y +CONFIG_ZRAM_DEF_COMP_LZORLE=y +# CONFIG_ZRAM_DEF_COMP_LZO is not set +CONFIG_ZRAM_DEF_COMP="lzo-rle" +# CONFIG_ZRAM_WRITEBACK is not set +# CONFIG_ZRAM_MEMORY_TRACKING is not set +CONFIG_BLK_DEV_LOOP=y +CONFIG_BLK_DEV_LOOP_MIN_COUNT=8 +# CONFIG_BLK_DEV_DRBD is not set +# CONFIG_BLK_DEV_NBD is not set +CONFIG_BLK_DEV_RAM=y +CONFIG_BLK_DEV_RAM_COUNT=16 +CONFIG_BLK_DEV_RAM_SIZE=16384 +# CONFIG_ATA_OVER_ETH is not set +CONFIG_VIRTIO_BLK=y +# CONFIG_BLK_DEV_RBD is not set +# CONFIG_BLK_DEV_UBLK is not set + +# +# NVME Support +# +# CONFIG_BLK_DEV_NVME is not set +# CONFIG_NVME_FC is not set +# CONFIG_NVME_TCP is not set +# CONFIG_NVME_TARGET is not set +# end of NVME Support + +# +# Misc devices +# +# CONFIG_DUMMY_IRQ is not set +# CONFIG_IBM_ASM is not set +# CONFIG_PHANTOM is not set +# CONFIG_TIFM_CORE is not set +# CONFIG_ENCLOSURE_SERVICES is not set +# CONFIG_HP_ILO is not set +# CONFIG_SRAM is not set +# CONFIG_DW_XDATA_PCIE is not set +# CONFIG_PCI_ENDPOINT_TEST is not set +# CONFIG_XILINX_SDFEC is not set +# CONFIG_C2PORT is not set + +# +# EEPROM support +# +# CONFIG_EEPROM_93CX6 is not set +# end of EEPROM support + +# CONFIG_CB710_CORE is not set + +# +# Texas Instruments shared transport line discipline +# +# end of Texas Instruments shared transport line discipline + +# +# Altera FPGA firmware download module (requires I2C) +# +# CONFIG_INTEL_MEI is not set +# CONFIG_INTEL_MEI_ME is not set +# CONFIG_INTEL_MEI_TXE is not set +# CONFIG_VMWARE_VMCI is not set +# CONFIG_GENWQE is not set +# CONFIG_ECHO is not set +# CONFIG_BCM_VK is not set +# CONFIG_MISC_ALCOR_PCI is not set +# CONFIG_MISC_RTSX_PCI is not set +# CONFIG_HABANA_AI is not set +# CONFIG_UACCE is not set +# CONFIG_PVPANIC is not set +# end of Misc devices + +# +# SCSI device support +# +CONFIG_SCSI_MOD=y +# CONFIG_RAID_ATTRS is not set +# CONFIG_SCSI is not set +# end of SCSI device support + +# CONFIG_ATA is not set +# CONFIG_MD is not set +# CONFIG_TARGET_CORE is not set +# CONFIG_FUSION is not set + +# +# IEEE 1394 (FireWire) support +# +# CONFIG_FIREWIRE is not set +# CONFIG_FIREWIRE_NOSY is not set +# end of IEEE 1394 (FireWire) support + +# CONFIG_MACINTOSH_DRIVERS is not set +CONFIG_NETDEVICES=y +CONFIG_NET_CORE=y +# CONFIG_BONDING is not set +# CONFIG_DUMMY is not set +CONFIG_WIREGUARD=y +# CONFIG_WIREGUARD_DEBUG is not set +# CONFIG_EQUALIZER is not set +# CONFIG_IFB is not set +# CONFIG_NET_TEAM is not set +CONFIG_MACVLAN=y +# CONFIG_MACVTAP is not set +CONFIG_IPVLAN_L3S=y +CONFIG_IPVLAN=y +# CONFIG_IPVTAP is not set +CONFIG_VXLAN=y +CONFIG_GENEVE=y +# CONFIG_BAREUDP is not set +# CONFIG_GTP is not set +# CONFIG_MACSEC is not set +# CONFIG_NETCONSOLE is not set +CONFIG_TUN=y +# CONFIG_TUN_VNET_CROSS_LE is not set +CONFIG_VETH=y +CONFIG_VIRTIO_NET=y +# CONFIG_NLMON is not set +# CONFIG_NET_VRF is not set +# CONFIG_ARCNET is not set +# CONFIG_ETHERNET is not set +# CONFIG_FDDI is not set +# CONFIG_HIPPI is not set +# CONFIG_NET_SB1000 is not set +# CONFIG_PHYLIB is not set +# CONFIG_PSE_CONTROLLER is not set +# CONFIG_MDIO_DEVICE is not set + +# +# PCS device drivers +# +# end of PCS device drivers + +# CONFIG_PPP is not set +# CONFIG_SLIP is not set + +# +# Host-side USB support is needed for USB Network Adapter support +# +# CONFIG_WLAN is not set +# CONFIG_WAN is not set + +# +# Wireless WAN +# +# CONFIG_WWAN is not set +# end of Wireless WAN + +# CONFIG_VMXNET3 is not set +# CONFIG_FUJITSU_ES is not set +# CONFIG_NETDEVSIM is not set +CONFIG_NET_FAILOVER=y +# CONFIG_ISDN is not set + +# +# Input device support +# +CONFIG_INPUT=y +CONFIG_INPUT_FF_MEMLESS=y +CONFIG_INPUT_SPARSEKMAP=y +# CONFIG_INPUT_MATRIXKMAP is not set + +# +# Userland interfaces +# +CONFIG_INPUT_MOUSEDEV=y +CONFIG_INPUT_MOUSEDEV_PSAUX=y +CONFIG_INPUT_MOUSEDEV_SCREEN_X=1024 +CONFIG_INPUT_MOUSEDEV_SCREEN_Y=768 +# CONFIG_INPUT_JOYDEV is not set +CONFIG_INPUT_EVDEV=y +# CONFIG_INPUT_EVBUG is not set + +# +# Input Device Drivers +# +CONFIG_INPUT_KEYBOARD=y +# CONFIG_KEYBOARD_ATKBD is not set +# CONFIG_KEYBOARD_LKKBD is not set +# CONFIG_KEYBOARD_NEWTON is not set +# CONFIG_KEYBOARD_OPENCORES is not set +# CONFIG_KEYBOARD_SAMSUNG is not set +# CONFIG_KEYBOARD_STOWAWAY is not set +# CONFIG_KEYBOARD_SUNKBD is not set +# CONFIG_KEYBOARD_XTKBD is not set +# CONFIG_INPUT_MOUSE is not set +# CONFIG_INPUT_JOYSTICK is not set +# CONFIG_INPUT_TABLET is not set +# CONFIG_INPUT_TOUCHSCREEN is not set +CONFIG_INPUT_MISC=y +# CONFIG_INPUT_AD714X is not set +# CONFIG_INPUT_E3X0_BUTTON is not set +# CONFIG_INPUT_PCSPKR is not set +# CONFIG_INPUT_ATLAS_BTNS is not set +CONFIG_INPUT_UINPUT=y +# CONFIG_INPUT_ADXL34X is not set +# CONFIG_INPUT_CMA3000 is not set +# CONFIG_RMI4_CORE is not set + +# +# Hardware I/O ports +# +# CONFIG_SERIO is not set +CONFIG_ARCH_MIGHT_HAVE_PC_SERIO=y +# CONFIG_GAMEPORT is not set +# end of Hardware I/O ports +# end of Input device support + +# +# Character devices +# +CONFIG_TTY=y +CONFIG_VT=y +CONFIG_CONSOLE_TRANSLATIONS=y +CONFIG_VT_CONSOLE=y +CONFIG_HW_CONSOLE=y +CONFIG_VT_HW_CONSOLE_BINDING=y +CONFIG_UNIX98_PTYS=y +# CONFIG_LEGACY_PTYS is not set +# CONFIG_LDISC_AUTOLOAD is not set + +# +# Serial drivers +# +CONFIG_SERIAL_EARLYCON=y +CONFIG_SERIAL_8250=y +# CONFIG_SERIAL_8250_DEPRECATED_OPTIONS is not set +CONFIG_SERIAL_8250_PNP=y +# CONFIG_SERIAL_8250_16550A_VARIANTS is not set +# CONFIG_SERIAL_8250_FINTEK is not set +CONFIG_SERIAL_8250_CONSOLE=y +CONFIG_SERIAL_8250_DMA=y +CONFIG_SERIAL_8250_PCI=y +CONFIG_SERIAL_8250_EXAR=y +CONFIG_SERIAL_8250_NR_UARTS=1 +CONFIG_SERIAL_8250_RUNTIME_UARTS=1 +# CONFIG_SERIAL_8250_EXTENDED is not set +CONFIG_SERIAL_8250_DWLIB=y +# CONFIG_SERIAL_8250_DW is not set +# CONFIG_SERIAL_8250_RT288X is not set +CONFIG_SERIAL_8250_LPSS=y +CONFIG_SERIAL_8250_MID=y +CONFIG_SERIAL_8250_PERICOM=y + +# +# Non-8250 serial port support +# +# CONFIG_SERIAL_UARTLITE is not set +CONFIG_SERIAL_CORE=y +CONFIG_SERIAL_CORE_CONSOLE=y +# CONFIG_SERIAL_JSM is not set +# CONFIG_SERIAL_LANTIQ is not set +# CONFIG_SERIAL_SCCNXP is not set +# CONFIG_SERIAL_ALTERA_JTAGUART is not set +# CONFIG_SERIAL_ALTERA_UART is not set +CONFIG_SERIAL_ARC=y +# CONFIG_SERIAL_ARC_CONSOLE is not set +CONFIG_SERIAL_ARC_NR_PORTS=1 +# CONFIG_SERIAL_RP2 is not set +# CONFIG_SERIAL_FSL_LPUART is not set +# CONFIG_SERIAL_FSL_LINFLEXUART is not set +# CONFIG_SERIAL_SPRD is not set +# end of Serial drivers + +# CONFIG_SERIAL_NONSTANDARD is not set +# CONFIG_N_GSM is not set +# CONFIG_NOZOMI is not set +# CONFIG_NULL_TTY is not set +CONFIG_HVC_DRIVER=y +CONFIG_SERIAL_DEV_BUS=y +CONFIG_SERIAL_DEV_CTRL_TTYPORT=y +# CONFIG_TTY_PRINTK is not set +CONFIG_VIRTIO_CONSOLE=y +# CONFIG_IPMI_HANDLER is not set +CONFIG_HW_RANDOM=y +# CONFIG_HW_RANDOM_TIMERIOMEM is not set +CONFIG_HW_RANDOM_INTEL=y +CONFIG_HW_RANDOM_AMD=y +# CONFIG_HW_RANDOM_BA431 is not set +CONFIG_HW_RANDOM_VIA=y +CONFIG_HW_RANDOM_VIRTIO=y +# CONFIG_HW_RANDOM_XIPHERA is not set +# CONFIG_APPLICOM is not set +# CONFIG_MWAVE is not set +CONFIG_DEVMEM=y +CONFIG_NVRAM=y +CONFIG_DEVPORT=y +# CONFIG_HPET is not set +CONFIG_HANGCHECK_TIMER=y +# CONFIG_TCG_TPM is not set +# CONFIG_TELCLOCK is not set +# CONFIG_XILLYBUS is not set +# CONFIG_RANDOM_TRUST_CPU is not set +# CONFIG_RANDOM_TRUST_BOOTLOADER is not set +# end of Character devices + +# +# I2C support +# +# CONFIG_I2C is not set +# end of I2C support + +# CONFIG_I3C is not set +# CONFIG_SPI is not set +# CONFIG_SPMI is not set +# CONFIG_HSI is not set +CONFIG_PPS=y +CONFIG_PPS_DEBUG=y + +# +# PPS clients support +# +CONFIG_PPS_CLIENT_KTIMER=y +CONFIG_PPS_CLIENT_LDISC=y +# CONFIG_PPS_CLIENT_GPIO is not set + +# +# PPS generators support +# + +# +# PTP clock support +# +CONFIG_PTP_1588_CLOCK=y +CONFIG_PTP_1588_CLOCK_OPTIONAL=y + +# +# Enable PHYLIB and NETWORK_PHY_TIMESTAMPING to see the additional clocks. +# +CONFIG_PTP_1588_CLOCK_KVM=y +# end of PTP clock support + +CONFIG_PINCTRL=y +# CONFIG_DEBUG_PINCTRL is not set +# CONFIG_PINCTRL_AMD is not set + +# +# Intel pinctrl drivers +# +# CONFIG_PINCTRL_BAYTRAIL is not set +# CONFIG_PINCTRL_CHERRYVIEW is not set +# CONFIG_PINCTRL_LYNXPOINT is not set +# CONFIG_PINCTRL_ALDERLAKE is not set +# CONFIG_PINCTRL_BROXTON is not set +# CONFIG_PINCTRL_CANNONLAKE is not set +# CONFIG_PINCTRL_CEDARFORK is not set +# CONFIG_PINCTRL_DENVERTON is not set +# CONFIG_PINCTRL_ELKHARTLAKE is not set +# CONFIG_PINCTRL_EMMITSBURG is not set +# CONFIG_PINCTRL_GEMINILAKE is not set +# CONFIG_PINCTRL_ICELAKE is not set +# CONFIG_PINCTRL_JASPERLAKE is not set +# CONFIG_PINCTRL_LAKEFIELD is not set +# CONFIG_PINCTRL_LEWISBURG is not set +# CONFIG_PINCTRL_METEORLAKE is not set +# CONFIG_PINCTRL_SUNRISEPOINT is not set +# CONFIG_PINCTRL_TIGERLAKE is not set +# end of Intel pinctrl drivers + +# +# Renesas pinctrl drivers +# +# end of Renesas pinctrl drivers + +# CONFIG_GPIOLIB is not set +# CONFIG_W1 is not set +CONFIG_POWER_RESET=y +CONFIG_POWER_SUPPLY=y +# CONFIG_POWER_SUPPLY_DEBUG is not set +# CONFIG_HWMON is not set +CONFIG_THERMAL=y +# CONFIG_THERMAL_NETLINK is not set +# CONFIG_THERMAL_STATISTICS is not set +CONFIG_THERMAL_EMERGENCY_POWEROFF_DELAY_MS=0 +CONFIG_THERMAL_WRITABLE_TRIPS=y +CONFIG_THERMAL_DEFAULT_GOV_STEP_WISE=y +# CONFIG_THERMAL_DEFAULT_GOV_FAIR_SHARE is not set +# CONFIG_THERMAL_DEFAULT_GOV_USER_SPACE is not set +CONFIG_THERMAL_GOV_FAIR_SHARE=y +CONFIG_THERMAL_GOV_STEP_WISE=y +# CONFIG_THERMAL_GOV_BANG_BANG is not set +CONFIG_THERMAL_GOV_USER_SPACE=y +# CONFIG_THERMAL_EMULATION is not set + +# +# Intel thermal drivers +# +# CONFIG_INTEL_POWERCLAMP is not set +CONFIG_X86_THERMAL_VECTOR=y +# CONFIG_X86_PKG_TEMP_THERMAL is not set +# CONFIG_INTEL_SOC_DTS_THERMAL is not set + +# +# ACPI INT340X thermal drivers +# +# CONFIG_INT340X_THERMAL is not set +# end of ACPI INT340X thermal drivers + +# CONFIG_INTEL_PCH_THERMAL is not set +# CONFIG_INTEL_TCC_COOLING is not set +# CONFIG_INTEL_MENLOW is not set +# CONFIG_INTEL_HFI_THERMAL is not set +# end of Intel thermal drivers + +CONFIG_WATCHDOG=y +CONFIG_WATCHDOG_CORE=y +# CONFIG_WATCHDOG_NOWAYOUT is not set +CONFIG_WATCHDOG_HANDLE_BOOT_ENABLED=y +CONFIG_WATCHDOG_OPEN_TIMEOUT=0 +# CONFIG_WATCHDOG_SYSFS is not set +# CONFIG_WATCHDOG_HRTIMER_PRETIMEOUT is not set + +# +# Watchdog Pretimeout Governors +# +# CONFIG_WATCHDOG_PRETIMEOUT_GOV is not set + +# +# Watchdog Device Drivers +# +# CONFIG_SOFT_WATCHDOG is not set +# CONFIG_WDAT_WDT is not set +# CONFIG_XILINX_WATCHDOG is not set +# CONFIG_CADENCE_WATCHDOG is not set +# CONFIG_DW_WATCHDOG is not set +# CONFIG_MAX63XX_WATCHDOG is not set +# CONFIG_ACQUIRE_WDT is not set +# CONFIG_ADVANTECH_WDT is not set +# CONFIG_ALIM1535_WDT is not set +# CONFIG_ALIM7101_WDT is not set +# CONFIG_EBC_C384_WDT is not set +# CONFIG_EXAR_WDT is not set +# CONFIG_F71808E_WDT is not set +# CONFIG_SP5100_TCO is not set +# CONFIG_SBC_FITPC2_WATCHDOG is not set +# CONFIG_EUROTECH_WDT is not set +# CONFIG_IB700_WDT is not set +# CONFIG_IBMASR is not set +# CONFIG_WAFER_WDT is not set +# CONFIG_I6300ESB_WDT is not set +# CONFIG_IE6XX_WDT is not set +# CONFIG_ITCO_WDT is not set +# CONFIG_IT8712F_WDT is not set +# CONFIG_IT87_WDT is not set +# CONFIG_HP_WATCHDOG is not set +# CONFIG_SC1200_WDT is not set +# CONFIG_PC87413_WDT is not set +# CONFIG_NV_TCO is not set +# CONFIG_60XX_WDT is not set +# CONFIG_CPU5_WDT is not set +# CONFIG_SMSC_SCH311X_WDT is not set +# CONFIG_SMSC37B787_WDT is not set +# CONFIG_TQMX86_WDT is not set +# CONFIG_VIA_WDT is not set +# CONFIG_W83627HF_WDT is not set +# CONFIG_W83877F_WDT is not set +# CONFIG_W83977F_WDT is not set +# CONFIG_MACHZ_WDT is not set +# CONFIG_SBC_EPX_C3_WATCHDOG is not set +# CONFIG_NI903X_WDT is not set +# CONFIG_NIC7018_WDT is not set + +# +# PCI-based Watchdog Cards +# +# CONFIG_PCIPCWATCHDOG is not set +# CONFIG_WDTPCI is not set +CONFIG_SSB_POSSIBLE=y +# CONFIG_SSB is not set +CONFIG_BCMA_POSSIBLE=y +# CONFIG_BCMA is not set + +# +# Multifunction device drivers +# +# CONFIG_MFD_MADERA is not set +# CONFIG_HTC_PASIC3 is not set +# CONFIG_MFD_INTEL_QUARK_I2C_GPIO is not set +# CONFIG_LPC_ICH is not set +# CONFIG_LPC_SCH is not set +# CONFIG_MFD_INTEL_LPSS_ACPI is not set +# CONFIG_MFD_INTEL_LPSS_PCI is not set +# CONFIG_MFD_JANZ_CMODIO is not set +# CONFIG_MFD_KEMPLD is not set +# CONFIG_MFD_MT6397 is not set +# CONFIG_MFD_RDC321X is not set +# CONFIG_MFD_SM501 is not set +# CONFIG_MFD_SYSCON is not set +# CONFIG_MFD_TI_AM335X_TSCADC is not set +# CONFIG_MFD_TQMX86 is not set +# CONFIG_MFD_VX855 is not set +# CONFIG_RAVE_SP_CORE is not set +# end of Multifunction device drivers + +# CONFIG_REGULATOR is not set +# CONFIG_RC_CORE is not set + +# +# CEC support +# +# CONFIG_MEDIA_CEC_SUPPORT is not set +# end of CEC support + +# CONFIG_MEDIA_SUPPORT is not set + +# +# Graphics support +# +CONFIG_APERTURE_HELPERS=y +# CONFIG_AGP is not set +# CONFIG_VGA_SWITCHEROO is not set +# CONFIG_DRM is not set +# CONFIG_DRM_DEBUG_MODESET_LOCK is not set + +# +# ARM devices +# +# end of ARM devices + +# +# Frame buffer Devices +# +CONFIG_FB_CMDLINE=y +CONFIG_FB_NOTIFY=y +CONFIG_FB=y +# CONFIG_FIRMWARE_EDID is not set +# CONFIG_FB_FOREIGN_ENDIAN is not set +# CONFIG_FB_MODE_HELPERS is not set +# CONFIG_FB_TILEBLITTING is not set + +# +# Frame buffer hardware drivers +# +# CONFIG_FB_CIRRUS is not set +# CONFIG_FB_PM2 is not set +# CONFIG_FB_CYBER2000 is not set +# CONFIG_FB_ARC is not set +# CONFIG_FB_ASILIANT is not set +# CONFIG_FB_IMSTT is not set +# CONFIG_FB_VGA16 is not set +# CONFIG_FB_UVESA is not set +# CONFIG_FB_VESA is not set +# CONFIG_FB_EFI is not set +# CONFIG_FB_N411 is not set +# CONFIG_FB_HGA is not set +# CONFIG_FB_OPENCORES is not set +# CONFIG_FB_S1D13XXX is not set +# CONFIG_FB_NVIDIA is not set +# CONFIG_FB_RIVA is not set +# CONFIG_FB_I740 is not set +# CONFIG_FB_LE80578 is not set +# CONFIG_FB_MATROX is not set +# CONFIG_FB_RADEON is not set +# CONFIG_FB_ATY128 is not set +# CONFIG_FB_ATY is not set +# CONFIG_FB_S3 is not set +# CONFIG_FB_SAVAGE is not set +# CONFIG_FB_SIS is not set +# CONFIG_FB_NEOMAGIC is not set +# CONFIG_FB_KYRO is not set +# CONFIG_FB_3DFX is not set +# CONFIG_FB_VOODOO1 is not set +# CONFIG_FB_VT8623 is not set +# CONFIG_FB_TRIDENT is not set +# CONFIG_FB_ARK is not set +# CONFIG_FB_PM3 is not set +# CONFIG_FB_CARMINE is not set +# CONFIG_FB_IBM_GXT4500 is not set +# CONFIG_FB_VIRTUAL is not set +# CONFIG_FB_METRONOME is not set +# CONFIG_FB_MB862XX is not set +# CONFIG_FB_SIMPLE is not set +# CONFIG_FB_SM712 is not set +# end of Frame buffer Devices + +# +# Backlight & LCD device support +# +CONFIG_LCD_CLASS_DEVICE=y +# CONFIG_LCD_PLATFORM is not set +CONFIG_BACKLIGHT_CLASS_DEVICE=y +# CONFIG_BACKLIGHT_APPLE is not set +# CONFIG_BACKLIGHT_QCOM_WLED is not set +# CONFIG_BACKLIGHT_SAHARA is not set +# end of Backlight & LCD device support + +# +# Console display driver support +# +CONFIG_VGA_CONSOLE=y +CONFIG_DUMMY_CONSOLE=y +CONFIG_DUMMY_CONSOLE_COLUMNS=80 +CONFIG_DUMMY_CONSOLE_ROWS=25 +CONFIG_FRAMEBUFFER_CONSOLE=y +# CONFIG_FRAMEBUFFER_CONSOLE_LEGACY_ACCELERATION is not set +CONFIG_FRAMEBUFFER_CONSOLE_DETECT_PRIMARY=y +# CONFIG_FRAMEBUFFER_CONSOLE_ROTATION is not set +# CONFIG_FRAMEBUFFER_CONSOLE_DEFERRED_TAKEOVER is not set +# end of Console display driver support + +# CONFIG_LOGO is not set +# end of Graphics support + +# CONFIG_SOUND is not set + +# +# HID support +# +CONFIG_HID=y +# CONFIG_HID_BATTERY_STRENGTH is not set +CONFIG_HIDRAW=y +CONFIG_UHID=y +CONFIG_HID_GENERIC=y + +# +# Special HID drivers +# +# CONFIG_HID_A4TECH is not set +# CONFIG_HID_ACRUX is not set +# CONFIG_HID_AUREAL is not set +# CONFIG_HID_BELKIN is not set +# CONFIG_HID_CHERRY is not set +# CONFIG_HID_COUGAR is not set +# CONFIG_HID_MACALLY is not set +# CONFIG_HID_CMEDIA is not set +# CONFIG_HID_CYPRESS is not set +# CONFIG_HID_DRAGONRISE is not set +# CONFIG_HID_EMS_FF is not set +# CONFIG_HID_ELECOM is not set +# CONFIG_HID_EZKEY is not set +# CONFIG_HID_GEMBIRD is not set +# CONFIG_HID_GFRM is not set +# CONFIG_HID_GLORIOUS is not set +# CONFIG_HID_VIVALDI is not set +# CONFIG_HID_KEYTOUCH is not set +# CONFIG_HID_KYE is not set +# CONFIG_HID_WALTOP is not set +# CONFIG_HID_VIEWSONIC is not set +# CONFIG_HID_VRC2 is not set +# CONFIG_HID_XIAOMI is not set +# CONFIG_HID_GYRATION is not set +# CONFIG_HID_ICADE is not set +# CONFIG_HID_ITE is not set +# CONFIG_HID_JABRA is not set +# CONFIG_HID_TWINHAN is not set +# CONFIG_HID_KENSINGTON is not set +# CONFIG_HID_LCPOWER is not set +# CONFIG_HID_LENOVO is not set +# CONFIG_HID_MAGICMOUSE is not set +# CONFIG_HID_MALTRON is not set +# CONFIG_HID_MAYFLASH is not set +CONFIG_HID_REDRAGON=y +# CONFIG_HID_MICROSOFT is not set +# CONFIG_HID_MONTEREY is not set +# CONFIG_HID_MULTITOUCH is not set +# CONFIG_HID_NTI is not set +# CONFIG_HID_ORTEK is not set +# CONFIG_HID_PANTHERLORD is not set +# CONFIG_HID_PETALYNX is not set +# CONFIG_HID_PICOLCD is not set +# CONFIG_HID_PLANTRONICS is not set +# CONFIG_HID_PXRC is not set +# CONFIG_HID_RAZER is not set +# CONFIG_HID_PRIMAX is not set +# CONFIG_HID_SAITEK is not set +# CONFIG_HID_SEMITEK is not set +# CONFIG_HID_SPEEDLINK is not set +# CONFIG_HID_STEAM is not set +# CONFIG_HID_STEELSERIES is not set +# CONFIG_HID_SUNPLUS is not set +# CONFIG_HID_RMI is not set +# CONFIG_HID_GREENASIA is not set +# CONFIG_HID_SMARTJOYPLUS is not set +# CONFIG_HID_TIVO is not set +# CONFIG_HID_TOPSEED is not set +# CONFIG_HID_TOPRE is not set +# CONFIG_HID_UDRAW_PS3 is not set +# CONFIG_HID_XINMO is not set +# CONFIG_HID_ZEROPLUS is not set +# CONFIG_HID_ZYDACRON is not set +# CONFIG_HID_SENSOR_HUB is not set +# CONFIG_HID_ALPS is not set +# end of Special HID drivers + +# +# Intel ISH HID support +# +# CONFIG_INTEL_ISH_HID is not set +# end of Intel ISH HID support + +# +# AMD SFH HID Support +# +# CONFIG_AMD_SFH_HID is not set +# end of AMD SFH HID Support +# end of HID support + +CONFIG_USB_OHCI_LITTLE_ENDIAN=y +# CONFIG_USB_SUPPORT is not set +# CONFIG_MMC is not set +# CONFIG_MEMSTICK is not set +# CONFIG_NEW_LEDS is not set +# CONFIG_ACCESSIBILITY is not set +# CONFIG_INFINIBAND is not set +CONFIG_EDAC_ATOMIC_SCRUB=y +CONFIG_EDAC_SUPPORT=y +# CONFIG_EDAC is not set +CONFIG_RTC_LIB=y +CONFIG_RTC_MC146818_LIB=y +CONFIG_RTC_CLASS=y +CONFIG_RTC_HCTOSYS=y +CONFIG_RTC_HCTOSYS_DEVICE="rtc0" +CONFIG_RTC_SYSTOHC=y +CONFIG_RTC_SYSTOHC_DEVICE="rtc0" +# CONFIG_RTC_DEBUG is not set +CONFIG_RTC_NVMEM=y + +# +# RTC interfaces +# +CONFIG_RTC_INTF_SYSFS=y +CONFIG_RTC_INTF_PROC=y +CONFIG_RTC_INTF_DEV=y +# CONFIG_RTC_INTF_DEV_UIE_EMUL is not set +# CONFIG_RTC_DRV_TEST is not set +CONFIG_RTC_I2C_AND_SPI=y +CONFIG_DMADEVICES=y +# CONFIG_DMADEVICES_DEBUG is not set + +# +# DMA Devices +# +CONFIG_DMA_ENGINE=y +CONFIG_DMA_VIRTUAL_CHANNELS=y +CONFIG_DMA_ACPI=y +# CONFIG_ALTERA_MSGDMA is not set +# CONFIG_INTEL_IDMA64 is not set +# CONFIG_INTEL_IDXD_COMPAT is not set +# CONFIG_INTEL_IOATDMA is not set +# CONFIG_PLX_DMA is not set +# CONFIG_AMD_PTDMA is not set +# CONFIG_QCOM_HIDMA_MGMT is not set +# CONFIG_QCOM_HIDMA is not set +CONFIG_DW_DMAC_CORE=y +# CONFIG_DW_DMAC is not set +CONFIG_DW_DMAC_PCI=y +# CONFIG_DW_EDMA is not set +# CONFIG_DW_EDMA_PCIE is not set +CONFIG_HSU_DMA=y +# CONFIG_SF_PDMA is not set +# CONFIG_INTEL_LDMA is not set + +# +# DMA Clients +# +# CONFIG_ASYNC_TX_DMA is not set +# CONFIG_DMATEST is not set + +# +# DMABUF options +# +CONFIG_SYNC_FILE=y +# CONFIG_SW_SYNC is not set +# CONFIG_UDMABUF is not set +# CONFIG_DMABUF_MOVE_NOTIFY is not set +# CONFIG_DMABUF_DEBUG is not set +# CONFIG_DMABUF_SELFTESTS is not set +# CONFIG_DMABUF_HEAPS is not set +# CONFIG_DMABUF_SYSFS_STATS is not set +# end of DMABUF options + +# CONFIG_AUXDISPLAY is not set +CONFIG_UIO=y +# CONFIG_UIO_CIF is not set +CONFIG_UIO_PDRV_GENIRQ=y +CONFIG_UIO_DMEM_GENIRQ=y +# CONFIG_UIO_AEC is not set +# CONFIG_UIO_SERCOS3 is not set +# CONFIG_UIO_PCI_GENERIC is not set +# CONFIG_UIO_NETX is not set +# CONFIG_UIO_PRUSS is not set +# CONFIG_UIO_MF624 is not set +CONFIG_VFIO=y +CONFIG_VFIO_IOMMU_TYPE1=y +CONFIG_VFIO_VIRQFD=y +# CONFIG_VFIO_NOIOMMU is not set +CONFIG_VFIO_PCI_CORE=y +CONFIG_VFIO_PCI_MMAP=y +CONFIG_VFIO_PCI_INTX=y +CONFIG_VFIO_PCI=y +# CONFIG_VFIO_PCI_VGA is not set +# CONFIG_VFIO_PCI_IGD is not set +# CONFIG_VFIO_MDEV is not set +CONFIG_IRQ_BYPASS_MANAGER=y +# CONFIG_VIRT_DRIVERS is not set +CONFIG_VIRTIO_ANCHOR=y +CONFIG_VIRTIO=y +CONFIG_VIRTIO_PCI_LIB=y +CONFIG_VIRTIO_PCI_LIB_LEGACY=y +CONFIG_VIRTIO_MENU=y +CONFIG_VIRTIO_PCI=y +CONFIG_VIRTIO_PCI_LEGACY=y +CONFIG_VIRTIO_PMEM=y +CONFIG_VIRTIO_BALLOON=y +CONFIG_VIRTIO_MEM=y +CONFIG_VIRTIO_INPUT=y +CONFIG_VIRTIO_MMIO=y +CONFIG_VIRTIO_MMIO_CMDLINE_DEVICES=y +CONFIG_VIRTIO_DMA_SHARED_BUFFER=y +# CONFIG_VDPA is not set +CONFIG_VHOST_MENU=y +# CONFIG_VHOST_NET is not set +# CONFIG_VHOST_VSOCK is not set +# CONFIG_VHOST_CROSS_ENDIAN_LEGACY is not set + +# +# Microsoft Hyper-V guest support +# +# end of Microsoft Hyper-V guest support + +# CONFIG_GREYBUS is not set +# CONFIG_COMEDI is not set +# CONFIG_STAGING is not set +# CONFIG_CHROME_PLATFORMS is not set +# CONFIG_MELLANOX_PLATFORM is not set +# CONFIG_SURFACE_PLATFORMS is not set +# CONFIG_X86_PLATFORM_DEVICES is not set +# CONFIG_P2SB is not set +CONFIG_HAVE_CLK=y +CONFIG_HAVE_CLK_PREPARE=y +CONFIG_COMMON_CLK=y +# CONFIG_XILINX_VCU is not set +# CONFIG_HWSPINLOCK is not set + +# +# Clock Source drivers +# +CONFIG_CLKEVT_I8253=y +CONFIG_I8253_LOCK=y +CONFIG_CLKBLD_I8253=y +# end of Clock Source drivers + +CONFIG_MAILBOX=y +CONFIG_PCC=y +# CONFIG_ALTERA_MBOX is not set +CONFIG_IOMMU_IOVA=y +CONFIG_IOMMU_API=y +CONFIG_IOMMU_SUPPORT=y + +# +# Generic IOMMU Pagetable Support +# +# end of Generic IOMMU Pagetable Support + +# CONFIG_IOMMU_DEBUGFS is not set +# CONFIG_IOMMU_DEFAULT_DMA_STRICT is not set +CONFIG_IOMMU_DEFAULT_DMA_LAZY=y +# CONFIG_IOMMU_DEFAULT_PASSTHROUGH is not set +CONFIG_IOMMU_DMA=y +# CONFIG_AMD_IOMMU is not set +# CONFIG_INTEL_IOMMU is not set +# CONFIG_IRQ_REMAP is not set +CONFIG_VIRTIO_IOMMU=y + +# +# Remoteproc drivers +# +# CONFIG_REMOTEPROC is not set +# end of Remoteproc drivers + +# +# Rpmsg drivers +# +# CONFIG_RPMSG_QCOM_GLINK_RPM is not set +# CONFIG_RPMSG_VIRTIO is not set +# end of Rpmsg drivers + +# CONFIG_SOUNDWIRE is not set + +# +# SOC (System On Chip) specific Drivers +# + +# +# Amlogic SoC drivers +# +# end of Amlogic SoC drivers + +# +# Broadcom SoC drivers +# +# end of Broadcom SoC drivers + +# +# NXP/Freescale QorIQ SoC drivers +# +# end of NXP/Freescale QorIQ SoC drivers + +# +# fujitsu SoC drivers +# +# end of fujitsu SoC drivers + +# +# i.MX SoC drivers +# +# end of i.MX SoC drivers + +# +# Enable LiteX SoC Builder specific drivers +# +# end of Enable LiteX SoC Builder specific drivers + +# +# Qualcomm SoC drivers +# +# end of Qualcomm SoC drivers + +# CONFIG_SOC_TI is not set + +# +# Xilinx SoC drivers +# +# end of Xilinx SoC drivers +# end of SOC (System On Chip) specific Drivers + +# CONFIG_PM_DEVFREQ is not set +# CONFIG_EXTCON is not set +# CONFIG_MEMORY is not set +# CONFIG_IIO is not set +# CONFIG_NTB is not set +# CONFIG_PWM is not set + +# +# IRQ chip support +# +# end of IRQ chip support + +# CONFIG_IPACK_BUS is not set +# CONFIG_RESET_CONTROLLER is not set + +# +# PHY Subsystem +# +# CONFIG_GENERIC_PHY is not set +# CONFIG_PHY_CAN_TRANSCEIVER is not set + +# +# PHY drivers for Broadcom platforms +# +# CONFIG_BCM_KONA_USB2_PHY is not set +# end of PHY drivers for Broadcom platforms + +# CONFIG_PHY_PXA_28NM_HSIC is not set +# CONFIG_PHY_PXA_28NM_USB2 is not set +# CONFIG_PHY_INTEL_LGM_EMMC is not set +# end of PHY Subsystem + +# CONFIG_POWERCAP is not set +# CONFIG_MCB is not set + +# +# Performance monitor support +# +# end of Performance monitor support + +CONFIG_RAS=y +# CONFIG_USB4 is not set + +# +# Android +# +# CONFIG_ANDROID_BINDER_IPC is not set +# end of Android + +CONFIG_LIBNVDIMM=y +CONFIG_BLK_DEV_PMEM=y +CONFIG_ND_CLAIM=y +CONFIG_ND_BTT=y +CONFIG_BTT=y +CONFIG_ND_PFN=y +CONFIG_NVDIMM_PFN=y +CONFIG_NVDIMM_DAX=y +CONFIG_DAX=y +CONFIG_DEV_DAX=y +CONFIG_DEV_DAX_PMEM=y +CONFIG_DEV_DAX_KMEM=y +CONFIG_NVMEM=y +CONFIG_NVMEM_SYSFS=y +# CONFIG_NVMEM_RMEM is not set + +# +# HW tracing support +# +# CONFIG_STM is not set +# CONFIG_INTEL_TH is not set +# end of HW tracing support + +# CONFIG_FPGA is not set +# CONFIG_SIOX is not set +# CONFIG_SLIMBUS is not set +# CONFIG_INTERCONNECT is not set +# CONFIG_COUNTER is not set +# CONFIG_MOST is not set +# CONFIG_PECI is not set +# CONFIG_HTE is not set +# end of Device Drivers + +# +# File systems +# +CONFIG_DCACHE_WORD_ACCESS=y +# CONFIG_VALIDATE_FS_PARSER is not set +CONFIG_FS_IOMAP=y +# CONFIG_EXT2_FS is not set +# CONFIG_EXT3_FS is not set +CONFIG_EXT4_FS=y +CONFIG_EXT4_USE_FOR_EXT2=y +CONFIG_EXT4_FS_POSIX_ACL=y +CONFIG_EXT4_FS_SECURITY=y +CONFIG_EXT4_DEBUG=y +CONFIG_JBD2=y +CONFIG_JBD2_DEBUG=y +CONFIG_FS_MBCACHE=y +# CONFIG_REISERFS_FS is not set +# CONFIG_JFS_FS is not set +# CONFIG_XFS_FS is not set +# CONFIG_GFS2_FS is not set +# CONFIG_OCFS2_FS is not set +# CONFIG_BTRFS_FS is not set +# CONFIG_NILFS2_FS is not set +# CONFIG_F2FS_FS is not set +CONFIG_FS_DAX=y +CONFIG_FS_DAX_PMD=y +CONFIG_FS_POSIX_ACL=y +CONFIG_EXPORTFS=y +# CONFIG_EXPORTFS_BLOCK_OPS is not set +CONFIG_FILE_LOCKING=y +CONFIG_FS_ENCRYPTION=y +CONFIG_FS_ENCRYPTION_ALGS=y +# CONFIG_FS_VERITY is not set +CONFIG_FSNOTIFY=y +CONFIG_DNOTIFY=y +CONFIG_INOTIFY_USER=y +CONFIG_FANOTIFY=y +# CONFIG_QUOTA is not set +CONFIG_AUTOFS4_FS=y +CONFIG_AUTOFS_FS=y +CONFIG_FUSE_FS=y +CONFIG_CUSE=y +CONFIG_VIRTIO_FS=y +CONFIG_FUSE_DAX=y +CONFIG_OVERLAY_FS=y +# CONFIG_OVERLAY_FS_REDIRECT_DIR is not set +CONFIG_OVERLAY_FS_REDIRECT_ALWAYS_FOLLOW=y +# CONFIG_OVERLAY_FS_INDEX is not set +# CONFIG_OVERLAY_FS_XINO_AUTO is not set +# CONFIG_OVERLAY_FS_METACOPY is not set + +# +# Caches +# +CONFIG_NETFS_SUPPORT=y +# CONFIG_NETFS_STATS is not set +CONFIG_FSCACHE=y +# CONFIG_FSCACHE_STATS is not set +# CONFIG_FSCACHE_DEBUG is not set +CONFIG_CACHEFILES=y +# CONFIG_CACHEFILES_DEBUG is not set +# CONFIG_CACHEFILES_ERROR_INJECTION is not set +# CONFIG_CACHEFILES_ONDEMAND is not set +# end of Caches + +# +# CD-ROM/DVD Filesystems +# +CONFIG_ISO9660_FS=y +CONFIG_JOLIET=y +CONFIG_ZISOFS=y +CONFIG_UDF_FS=y +# end of CD-ROM/DVD Filesystems + +# +# DOS/FAT/EXFAT/NT Filesystems +# +CONFIG_FAT_FS=y +CONFIG_MSDOS_FS=y +CONFIG_VFAT_FS=y +CONFIG_FAT_DEFAULT_CODEPAGE=437 +CONFIG_FAT_DEFAULT_IOCHARSET="ascii" +# CONFIG_FAT_DEFAULT_UTF8 is not set +# CONFIG_EXFAT_FS is not set +# CONFIG_NTFS_FS is not set +# CONFIG_NTFS3_FS is not set +# end of DOS/FAT/EXFAT/NT Filesystems + +# +# Pseudo filesystems +# +CONFIG_PROC_FS=y +CONFIG_PROC_KCORE=y +CONFIG_PROC_SYSCTL=y +CONFIG_PROC_PAGE_MONITOR=y +CONFIG_PROC_CHILDREN=y +CONFIG_PROC_PID_ARCH_STATUS=y +CONFIG_KERNFS=y +CONFIG_SYSFS=y +CONFIG_TMPFS=y +CONFIG_TMPFS_POSIX_ACL=y +CONFIG_TMPFS_XATTR=y +# CONFIG_TMPFS_INODE64 is not set +CONFIG_HUGETLBFS=y +CONFIG_HUGETLB_PAGE=y +CONFIG_ARCH_WANT_HUGETLB_PAGE_OPTIMIZE_VMEMMAP=y +CONFIG_HUGETLB_PAGE_OPTIMIZE_VMEMMAP=y +# CONFIG_HUGETLB_PAGE_OPTIMIZE_VMEMMAP_DEFAULT_ON is not set +CONFIG_MEMFD_CREATE=y +CONFIG_ARCH_HAS_GIGANTIC_PAGE=y +CONFIG_CONFIGFS_FS=y +CONFIG_EFIVAR_FS=y +# end of Pseudo filesystems + +CONFIG_MISC_FILESYSTEMS=y +# CONFIG_ORANGEFS_FS is not set +# CONFIG_ADFS_FS is not set +# CONFIG_AFFS_FS is not set +# CONFIG_ECRYPT_FS is not set +# CONFIG_HFS_FS is not set +# CONFIG_HFSPLUS_FS is not set +# CONFIG_BEFS_FS is not set +# CONFIG_BFS_FS is not set +# CONFIG_EFS_FS is not set +# CONFIG_CRAMFS is not set +CONFIG_SQUASHFS=y +CONFIG_SQUASHFS_FILE_CACHE=y +# CONFIG_SQUASHFS_FILE_DIRECT is not set +CONFIG_SQUASHFS_DECOMP_SINGLE=y +# CONFIG_SQUASHFS_DECOMP_MULTI is not set +# CONFIG_SQUASHFS_DECOMP_MULTI_PERCPU is not set +# CONFIG_SQUASHFS_XATTR is not set +CONFIG_SQUASHFS_ZLIB=y +# CONFIG_SQUASHFS_LZ4 is not set +# CONFIG_SQUASHFS_LZO is not set +CONFIG_SQUASHFS_XZ=y +# CONFIG_SQUASHFS_ZSTD is not set +# CONFIG_SQUASHFS_4K_DEVBLK_SIZE is not set +# CONFIG_SQUASHFS_EMBEDDED is not set +CONFIG_SQUASHFS_FRAGMENT_CACHE_SIZE=3 +# CONFIG_VXFS_FS is not set +# CONFIG_MINIX_FS is not set +# CONFIG_OMFS_FS is not set +# CONFIG_HPFS_FS is not set +# CONFIG_QNX4FS_FS is not set +# CONFIG_QNX6FS_FS is not set +# CONFIG_ROMFS_FS is not set +# CONFIG_PSTORE is not set +# CONFIG_SYSV_FS is not set +# CONFIG_UFS_FS is not set +# CONFIG_EROFS_FS is not set +CONFIG_NETWORK_FILESYSTEMS=y +CONFIG_NFS_FS=y +CONFIG_NFS_V2=y +CONFIG_NFS_V3=y +# CONFIG_NFS_V3_ACL is not set +CONFIG_NFS_V4=y +# CONFIG_NFS_SWAP is not set +# CONFIG_NFS_V4_1 is not set +# CONFIG_ROOT_NFS is not set +# CONFIG_NFS_FSCACHE is not set +# CONFIG_NFS_USE_LEGACY_DNS is not set +CONFIG_NFS_USE_KERNEL_DNS=y +CONFIG_NFS_DISABLE_UDP_SUPPORT=y +CONFIG_NFSD=y +# CONFIG_NFSD_V3_ACL is not set +CONFIG_NFSD_V4=y +# CONFIG_NFSD_BLOCKLAYOUT is not set +# CONFIG_NFSD_SCSILAYOUT is not set +# CONFIG_NFSD_FLEXFILELAYOUT is not set +CONFIG_GRACE_PERIOD=y +CONFIG_LOCKD=y +CONFIG_LOCKD_V4=y +CONFIG_NFS_COMMON=y +CONFIG_SUNRPC=y +CONFIG_SUNRPC_GSS=y +CONFIG_RPCSEC_GSS_KRB5=y +# CONFIG_SUNRPC_DISABLE_INSECURE_ENCTYPES is not set +# CONFIG_SUNRPC_DEBUG is not set +# CONFIG_CEPH_FS is not set +CONFIG_CIFS=y +# CONFIG_CIFS_STATS2 is not set +CONFIG_CIFS_ALLOW_INSECURE_LEGACY=y +CONFIG_CIFS_UPCALL=y +CONFIG_CIFS_XATTR=y +CONFIG_CIFS_POSIX=y +# CONFIG_CIFS_DEBUG is not set +CONFIG_CIFS_DFS_UPCALL=y +# CONFIG_CIFS_SMB_DIRECT is not set +# CONFIG_SMB_SERVER is not set +# CONFIG_CODA_FS is not set +# CONFIG_AFS_FS is not set +CONFIG_NLS=y +CONFIG_NLS_DEFAULT="utf8" +CONFIG_NLS_CODEPAGE_437=y +CONFIG_NLS_CODEPAGE_737=y +CONFIG_NLS_CODEPAGE_775=y +CONFIG_NLS_CODEPAGE_850=y +CONFIG_NLS_CODEPAGE_852=y +CONFIG_NLS_CODEPAGE_855=y +CONFIG_NLS_CODEPAGE_857=y +CONFIG_NLS_CODEPAGE_860=y +CONFIG_NLS_CODEPAGE_861=y +CONFIG_NLS_CODEPAGE_862=y +CONFIG_NLS_CODEPAGE_863=y +CONFIG_NLS_CODEPAGE_864=y +CONFIG_NLS_CODEPAGE_865=y +CONFIG_NLS_CODEPAGE_866=y +CONFIG_NLS_CODEPAGE_869=y +CONFIG_NLS_CODEPAGE_936=y +CONFIG_NLS_CODEPAGE_950=y +CONFIG_NLS_CODEPAGE_932=y +CONFIG_NLS_CODEPAGE_949=y +CONFIG_NLS_CODEPAGE_874=y +CONFIG_NLS_ISO8859_8=y +CONFIG_NLS_CODEPAGE_1250=y +CONFIG_NLS_CODEPAGE_1251=y +CONFIG_NLS_ASCII=y +CONFIG_NLS_ISO8859_1=y +CONFIG_NLS_ISO8859_2=y +CONFIG_NLS_ISO8859_3=y +CONFIG_NLS_ISO8859_4=y +CONFIG_NLS_ISO8859_5=y +CONFIG_NLS_ISO8859_6=y +CONFIG_NLS_ISO8859_7=y +CONFIG_NLS_ISO8859_9=y +CONFIG_NLS_ISO8859_13=y +CONFIG_NLS_ISO8859_14=y +CONFIG_NLS_ISO8859_15=y +CONFIG_NLS_KOI8_R=y +CONFIG_NLS_KOI8_U=y +CONFIG_NLS_MAC_ROMAN=y +CONFIG_NLS_MAC_CELTIC=y +CONFIG_NLS_MAC_CENTEURO=y +CONFIG_NLS_MAC_CROATIAN=y +CONFIG_NLS_MAC_CYRILLIC=y +CONFIG_NLS_MAC_GAELIC=y +CONFIG_NLS_MAC_GREEK=y +CONFIG_NLS_MAC_ICELAND=y +CONFIG_NLS_MAC_INUIT=y +CONFIG_NLS_MAC_ROMANIAN=y +CONFIG_NLS_MAC_TURKISH=y +CONFIG_NLS_UTF8=y +# CONFIG_DLM is not set +# CONFIG_UNICODE is not set +CONFIG_IO_WQ=y +# end of File systems + +# +# Security options +# +CONFIG_KEYS=y +# CONFIG_KEYS_REQUEST_CACHE is not set +CONFIG_PERSISTENT_KEYRINGS=y +# CONFIG_BIG_KEYS is not set +# CONFIG_TRUSTED_KEYS is not set +# CONFIG_ENCRYPTED_KEYS is not set +# CONFIG_KEY_DH_OPERATIONS is not set +# CONFIG_SECURITY_DMESG_RESTRICT is not set +# CONFIG_SECURITY is not set +CONFIG_SECURITYFS=y +CONFIG_HAVE_HARDENED_USERCOPY_ALLOCATOR=y +# CONFIG_HARDENED_USERCOPY is not set +CONFIG_FORTIFY_SOURCE=y +# CONFIG_STATIC_USERMODEHELPER is not set +# CONFIG_IMA_SECURE_AND_OR_TRUSTED_BOOT is not set +CONFIG_DEFAULT_SECURITY_DAC=y +CONFIG_LSM="yama,loadpin,safesetid,integrity" + +# +# Kernel hardening options +# + +# +# Memory initialization +# +CONFIG_INIT_STACK_NONE=y +# CONFIG_INIT_ON_ALLOC_DEFAULT_ON is not set +# CONFIG_INIT_ON_FREE_DEFAULT_ON is not set +# end of Memory initialization + +CONFIG_RANDSTRUCT_NONE=y +# end of Kernel hardening options +# end of Security options + +CONFIG_CRYPTO=y + +# +# Crypto core or helper +# +# CONFIG_CRYPTO_FIPS is not set +CONFIG_CRYPTO_ALGAPI=y +CONFIG_CRYPTO_ALGAPI2=y +CONFIG_CRYPTO_AEAD=y +CONFIG_CRYPTO_AEAD2=y +CONFIG_CRYPTO_SKCIPHER=y +CONFIG_CRYPTO_SKCIPHER2=y +CONFIG_CRYPTO_HASH=y +CONFIG_CRYPTO_HASH2=y +CONFIG_CRYPTO_RNG=y +CONFIG_CRYPTO_RNG2=y +CONFIG_CRYPTO_RNG_DEFAULT=y +CONFIG_CRYPTO_AKCIPHER2=y +CONFIG_CRYPTO_AKCIPHER=y +CONFIG_CRYPTO_KPP2=y +CONFIG_CRYPTO_ACOMP2=y +CONFIG_CRYPTO_MANAGER=y +CONFIG_CRYPTO_MANAGER2=y +# CONFIG_CRYPTO_USER is not set +# CONFIG_CRYPTO_MANAGER_DISABLE_TESTS is not set +# CONFIG_CRYPTO_MANAGER_EXTRA_TESTS is not set +CONFIG_CRYPTO_GF128MUL=y +CONFIG_CRYPTO_NULL=y +CONFIG_CRYPTO_NULL2=y +# CONFIG_CRYPTO_PCRYPT is not set +CONFIG_CRYPTO_CRYPTD=y +CONFIG_CRYPTO_AUTHENC=y +# CONFIG_CRYPTO_TEST is not set +CONFIG_CRYPTO_SIMD=y +# end of Crypto core or helper + +# +# Public-key cryptography +# +# CONFIG_CRYPTO_RSA is not set +# CONFIG_CRYPTO_DH is not set +CONFIG_CRYPTO_ECC=y +# CONFIG_CRYPTO_ECDH is not set +CONFIG_CRYPTO_ECDSA=y +# CONFIG_CRYPTO_ECRDSA is not set +# CONFIG_CRYPTO_SM2 is not set +# CONFIG_CRYPTO_CURVE25519 is not set +# end of Public-key cryptography + +# +# Block ciphers +# +CONFIG_CRYPTO_AES=y +# CONFIG_CRYPTO_AES_TI is not set +# CONFIG_CRYPTO_ARIA is not set +# CONFIG_CRYPTO_BLOWFISH is not set +# CONFIG_CRYPTO_CAMELLIA is not set +# CONFIG_CRYPTO_CAST5 is not set +# CONFIG_CRYPTO_CAST6 is not set +CONFIG_CRYPTO_DES=y +# CONFIG_CRYPTO_FCRYPT is not set +# CONFIG_CRYPTO_SERPENT is not set +# CONFIG_CRYPTO_SM4_GENERIC is not set +# CONFIG_CRYPTO_TWOFISH is not set +CONFIG_CRYPTO_TWOFISH_COMMON=y +# end of Block ciphers + +# +# Length-preserving ciphers and modes +# +# CONFIG_CRYPTO_ADIANTUM is not set +# CONFIG_CRYPTO_CHACHA20 is not set +CONFIG_CRYPTO_CBC=y +# CONFIG_CRYPTO_CFB is not set +CONFIG_CRYPTO_CTR=y +CONFIG_CRYPTO_CTS=y +CONFIG_CRYPTO_ECB=y +# CONFIG_CRYPTO_HCTR2 is not set +# CONFIG_CRYPTO_KEYWRAP is not set +CONFIG_CRYPTO_LRW=y +# CONFIG_CRYPTO_OFB is not set +CONFIG_CRYPTO_PCBC=y +CONFIG_CRYPTO_XTS=y +# end of Length-preserving ciphers and modes + +# +# AEAD (authenticated encryption with associated data) ciphers +# +# CONFIG_CRYPTO_AEGIS128 is not set +# CONFIG_CRYPTO_CHACHA20POLY1305 is not set +# CONFIG_CRYPTO_CCM is not set +CONFIG_CRYPTO_GCM=y +CONFIG_CRYPTO_SEQIV=y +CONFIG_CRYPTO_ECHAINIV=y +# CONFIG_CRYPTO_ESSIV is not set +# end of AEAD (authenticated encryption with associated data) ciphers + +# +# Hashes, digests, and MACs +# +# CONFIG_CRYPTO_BLAKE2B is not set +CONFIG_CRYPTO_CMAC=y +CONFIG_CRYPTO_GHASH=y +CONFIG_CRYPTO_HMAC=y +# CONFIG_CRYPTO_MD4 is not set +CONFIG_CRYPTO_MD5=y +# CONFIG_CRYPTO_MICHAEL_MIC is not set +CONFIG_CRYPTO_POLY1305=y +# CONFIG_CRYPTO_RMD160 is not set +CONFIG_CRYPTO_SHA1=y +CONFIG_CRYPTO_SHA256=y +CONFIG_CRYPTO_SHA512=y +# CONFIG_CRYPTO_SHA3 is not set +# CONFIG_CRYPTO_SM3_GENERIC is not set +# CONFIG_CRYPTO_STREEBOG is not set +# CONFIG_CRYPTO_VMAC is not set +# CONFIG_CRYPTO_WP512 is not set +# CONFIG_CRYPTO_XCBC is not set +# CONFIG_CRYPTO_XXHASH is not set +# end of Hashes, digests, and MACs + +# +# CRCs (cyclic redundancy checks) +# +CONFIG_CRYPTO_CRC32C=y +# CONFIG_CRYPTO_CRC32 is not set +CONFIG_CRYPTO_CRCT10DIF=y +# end of CRCs (cyclic redundancy checks) + +# +# Compression +# +CONFIG_CRYPTO_DEFLATE=y +CONFIG_CRYPTO_LZO=y +# CONFIG_CRYPTO_842 is not set +# CONFIG_CRYPTO_LZ4 is not set +# CONFIG_CRYPTO_LZ4HC is not set +# CONFIG_CRYPTO_ZSTD is not set +# end of Compression + +# +# Random number generation +# +# CONFIG_CRYPTO_ANSI_CPRNG is not set +CONFIG_CRYPTO_DRBG_MENU=y +CONFIG_CRYPTO_DRBG_HMAC=y +CONFIG_CRYPTO_DRBG_HASH=y +CONFIG_CRYPTO_DRBG_CTR=y +CONFIG_CRYPTO_DRBG=y +CONFIG_CRYPTO_JITTERENTROPY=y +# end of Random number generation + +# +# Userspace interface +# +CONFIG_CRYPTO_USER_API=y +# CONFIG_CRYPTO_USER_API_HASH is not set +# CONFIG_CRYPTO_USER_API_SKCIPHER is not set +CONFIG_CRYPTO_USER_API_RNG=y +# CONFIG_CRYPTO_USER_API_RNG_CAVP is not set +# CONFIG_CRYPTO_USER_API_AEAD is not set +# CONFIG_CRYPTO_USER_API_ENABLE_OBSOLETE is not set +# end of Userspace interface + +# +# Accelerated Cryptographic Algorithms for CPU (x86) +# +CONFIG_CRYPTO_CURVE25519_X86=y +CONFIG_CRYPTO_AES_NI_INTEL=y +# CONFIG_CRYPTO_BLOWFISH_X86_64 is not set +# CONFIG_CRYPTO_CAMELLIA_X86_64 is not set +# CONFIG_CRYPTO_CAMELLIA_AESNI_AVX_X86_64 is not set +# CONFIG_CRYPTO_CAMELLIA_AESNI_AVX2_X86_64 is not set +# CONFIG_CRYPTO_CAST5_AVX_X86_64 is not set +# CONFIG_CRYPTO_CAST6_AVX_X86_64 is not set +# CONFIG_CRYPTO_DES3_EDE_X86_64 is not set +# CONFIG_CRYPTO_SERPENT_SSE2_X86_64 is not set +# CONFIG_CRYPTO_SERPENT_AVX_X86_64 is not set +# CONFIG_CRYPTO_SERPENT_AVX2_X86_64 is not set +# CONFIG_CRYPTO_SM4_AESNI_AVX_X86_64 is not set +# CONFIG_CRYPTO_SM4_AESNI_AVX2_X86_64 is not set +CONFIG_CRYPTO_TWOFISH_X86_64=y +CONFIG_CRYPTO_TWOFISH_X86_64_3WAY=y +CONFIG_CRYPTO_TWOFISH_AVX_X86_64=y +# CONFIG_CRYPTO_ARIA_AESNI_AVX_X86_64 is not set +CONFIG_CRYPTO_CHACHA20_X86_64=y +# CONFIG_CRYPTO_AEGIS128_AESNI_SSE2 is not set +# CONFIG_CRYPTO_NHPOLY1305_SSE2 is not set +# CONFIG_CRYPTO_NHPOLY1305_AVX2 is not set +CONFIG_CRYPTO_BLAKE2S_X86=y +# CONFIG_CRYPTO_POLYVAL_CLMUL_NI is not set +CONFIG_CRYPTO_POLY1305_X86_64=y +# CONFIG_CRYPTO_SHA1_SSSE3 is not set +# CONFIG_CRYPTO_SHA256_SSSE3 is not set +# CONFIG_CRYPTO_SHA512_SSSE3 is not set +# CONFIG_CRYPTO_SM3_AVX_X86_64 is not set +# CONFIG_CRYPTO_GHASH_CLMUL_NI_INTEL is not set +CONFIG_CRYPTO_CRC32C_INTEL=y +# CONFIG_CRYPTO_CRC32_PCLMUL is not set +# CONFIG_CRYPTO_CRCT10DIF_PCLMUL is not set +# end of Accelerated Cryptographic Algorithms for CPU (x86) + +# CONFIG_CRYPTO_HW is not set +# CONFIG_ASYMMETRIC_KEY_TYPE is not set + +# +# Certificates for signature checking +# +CONFIG_SYSTEM_BLACKLIST_KEYRING=y +CONFIG_SYSTEM_BLACKLIST_HASH_LIST="" +# end of Certificates for signature checking + +CONFIG_BINARY_PRINTF=y + +# +# Library routines +# +# CONFIG_PACKING is not set +CONFIG_BITREVERSE=y +CONFIG_GENERIC_STRNCPY_FROM_USER=y +CONFIG_GENERIC_STRNLEN_USER=y +CONFIG_GENERIC_NET_UTILS=y +# CONFIG_CORDIC is not set +# CONFIG_PRIME_NUMBERS is not set +CONFIG_RATIONAL=y +CONFIG_GENERIC_PCI_IOMAP=y +CONFIG_GENERIC_IOMAP=y +CONFIG_ARCH_USE_CMPXCHG_LOCKREF=y +CONFIG_ARCH_HAS_FAST_MULTIPLIER=y +CONFIG_ARCH_USE_SYM_ANNOTATIONS=y + +# +# Crypto library routines +# +CONFIG_CRYPTO_LIB_UTILS=y +CONFIG_CRYPTO_LIB_AES=y +CONFIG_CRYPTO_ARCH_HAVE_LIB_BLAKE2S=y +CONFIG_CRYPTO_LIB_BLAKE2S_GENERIC=y +CONFIG_CRYPTO_ARCH_HAVE_LIB_CHACHA=y +CONFIG_CRYPTO_LIB_CHACHA_GENERIC=y +CONFIG_CRYPTO_LIB_CHACHA=y +CONFIG_CRYPTO_ARCH_HAVE_LIB_CURVE25519=y +CONFIG_CRYPTO_LIB_CURVE25519_GENERIC=y +CONFIG_CRYPTO_LIB_CURVE25519=y +CONFIG_CRYPTO_LIB_DES=y +CONFIG_CRYPTO_LIB_POLY1305_RSIZE=11 +CONFIG_CRYPTO_ARCH_HAVE_LIB_POLY1305=y +CONFIG_CRYPTO_LIB_POLY1305_GENERIC=y +CONFIG_CRYPTO_LIB_POLY1305=y +CONFIG_CRYPTO_LIB_CHACHA20POLY1305=y +CONFIG_CRYPTO_LIB_SHA1=y +CONFIG_CRYPTO_LIB_SHA256=y +# end of Crypto library routines + +CONFIG_CRC_CCITT=y +CONFIG_CRC16=y +CONFIG_CRC_T10DIF=y +# CONFIG_CRC64_ROCKSOFT is not set +CONFIG_CRC_ITU_T=y +CONFIG_CRC32=y +# CONFIG_CRC32_SELFTEST is not set +CONFIG_CRC32_SLICEBY8=y +# CONFIG_CRC32_SLICEBY4 is not set +# CONFIG_CRC32_SARWATE is not set +# CONFIG_CRC32_BIT is not set +# CONFIG_CRC64 is not set +# CONFIG_CRC4 is not set +# CONFIG_CRC7 is not set +CONFIG_LIBCRC32C=y +# CONFIG_CRC8 is not set +CONFIG_XXHASH=y +# CONFIG_RANDOM32_SELFTEST is not set +CONFIG_ZLIB_INFLATE=y +CONFIG_ZLIB_DEFLATE=y +CONFIG_LZO_COMPRESS=y +CONFIG_LZO_DECOMPRESS=y +CONFIG_LZ4_DECOMPRESS=y +CONFIG_ZSTD_COMMON=y +CONFIG_ZSTD_DECOMPRESS=y +CONFIG_XZ_DEC=y +CONFIG_XZ_DEC_X86=y +CONFIG_XZ_DEC_POWERPC=y +CONFIG_XZ_DEC_IA64=y +CONFIG_XZ_DEC_ARM=y +CONFIG_XZ_DEC_ARMTHUMB=y +CONFIG_XZ_DEC_SPARC=y +# CONFIG_XZ_DEC_MICROLZMA is not set +CONFIG_XZ_DEC_BCJ=y +# CONFIG_XZ_DEC_TEST is not set +CONFIG_DECOMPRESS_GZIP=y +CONFIG_DECOMPRESS_BZIP2=y +CONFIG_DECOMPRESS_LZMA=y +CONFIG_DECOMPRESS_XZ=y +CONFIG_DECOMPRESS_LZO=y +CONFIG_DECOMPRESS_LZ4=y +CONFIG_DECOMPRESS_ZSTD=y +CONFIG_TEXTSEARCH=y +CONFIG_TEXTSEARCH_KMP=y +CONFIG_TEXTSEARCH_BM=y +CONFIG_TEXTSEARCH_FSM=y +CONFIG_INTERVAL_TREE=y +CONFIG_XARRAY_MULTI=y +CONFIG_ASSOCIATIVE_ARRAY=y +CONFIG_HAS_IOMEM=y +CONFIG_HAS_IOPORT_MAP=y +CONFIG_HAS_DMA=y +CONFIG_DMA_OPS=y +CONFIG_NEED_SG_DMA_LENGTH=y +CONFIG_NEED_DMA_MAP_STATE=y +CONFIG_ARCH_DMA_ADDR_T_64BIT=y +CONFIG_SWIOTLB=y +# CONFIG_DMA_API_DEBUG is not set +# CONFIG_DMA_MAP_BENCHMARK is not set +CONFIG_SGL_ALLOC=y +# CONFIG_FORCE_NR_CPUS is not set +CONFIG_CPU_RMAP=y +CONFIG_DQL=y +CONFIG_NLATTR=y +CONFIG_IRQ_POLL=y +CONFIG_OID_REGISTRY=y +CONFIG_UCS2_STRING=y +CONFIG_HAVE_GENERIC_VDSO=y +CONFIG_GENERIC_GETTIMEOFDAY=y +CONFIG_GENERIC_VDSO_TIME_NS=y +CONFIG_FONT_SUPPORT=y +# CONFIG_FONTS is not set +CONFIG_FONT_8x8=y +CONFIG_FONT_8x16=y +CONFIG_SG_POOL=y +CONFIG_ARCH_HAS_PMEM_API=y +CONFIG_MEMREGION=y +CONFIG_ARCH_HAS_UACCESS_FLUSHCACHE=y +CONFIG_ARCH_HAS_COPY_MC=y +CONFIG_ARCH_STACKWALK=y +CONFIG_STACKDEPOT=y +CONFIG_SBITMAP=y +# end of Library routines + +# +# Kernel hacking +# + +# +# printk and dmesg options +# +CONFIG_PRINTK_TIME=y +# CONFIG_PRINTK_CALLER is not set +# CONFIG_STACKTRACE_BUILD_ID is not set +CONFIG_CONSOLE_LOGLEVEL_DEFAULT=7 +CONFIG_CONSOLE_LOGLEVEL_QUIET=4 +CONFIG_MESSAGE_LOGLEVEL_DEFAULT=4 +# CONFIG_BOOT_PRINTK_DELAY is not set +CONFIG_DYNAMIC_DEBUG=y +CONFIG_DYNAMIC_DEBUG_CORE=y +CONFIG_SYMBOLIC_ERRNAME=y +# CONFIG_DEBUG_BUGVERBOSE is not set +# end of printk and dmesg options + +CONFIG_DEBUG_KERNEL=y +CONFIG_DEBUG_MISC=y + +# +# Compile-time checks and compiler options +# +CONFIG_AS_HAS_NON_CONST_LEB128=y +CONFIG_DEBUG_INFO_NONE=y +# CONFIG_DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT is not set +# CONFIG_DEBUG_INFO_DWARF4 is not set +# CONFIG_DEBUG_INFO_DWARF5 is not set +CONFIG_FRAME_WARN=2048 +CONFIG_STRIP_ASM_SYMS=y +# CONFIG_READABLE_ASM is not set +# CONFIG_HEADERS_INSTALL is not set +CONFIG_DEBUG_SECTION_MISMATCH=y +CONFIG_SECTION_MISMATCH_WARN_ONLY=y +# CONFIG_DEBUG_FORCE_FUNCTION_ALIGN_64B is not set +CONFIG_FRAME_POINTER=y +CONFIG_OBJTOOL=y +CONFIG_STACK_VALIDATION=y +CONFIG_VMLINUX_MAP=y +# CONFIG_DEBUG_FORCE_WEAK_PER_CPU is not set +# end of Compile-time checks and compiler options + +# +# Generic Kernel Debugging Instruments +# +CONFIG_MAGIC_SYSRQ=y +CONFIG_MAGIC_SYSRQ_DEFAULT_ENABLE=0x1 +CONFIG_MAGIC_SYSRQ_SERIAL=y +CONFIG_MAGIC_SYSRQ_SERIAL_SEQUENCE="" +CONFIG_DEBUG_FS=y +CONFIG_DEBUG_FS_ALLOW_ALL=y +# CONFIG_DEBUG_FS_DISALLOW_MOUNT is not set +# CONFIG_DEBUG_FS_ALLOW_NONE is not set +CONFIG_HAVE_ARCH_KGDB=y +# CONFIG_KGDB is not set +CONFIG_ARCH_HAS_UBSAN_SANITIZE_ALL=y +# CONFIG_UBSAN is not set +CONFIG_HAVE_ARCH_KCSAN=y +# end of Generic Kernel Debugging Instruments + +# +# Networking Debugging +# +# CONFIG_NET_DEV_REFCNT_TRACKER is not set +# CONFIG_NET_NS_REFCNT_TRACKER is not set +# CONFIG_DEBUG_NET is not set +# end of Networking Debugging + +# +# Memory Debugging +# +# CONFIG_PAGE_EXTENSION is not set +# CONFIG_DEBUG_PAGEALLOC is not set +CONFIG_SLUB_DEBUG=y +# CONFIG_SLUB_DEBUG_ON is not set +# CONFIG_PAGE_OWNER is not set +# CONFIG_PAGE_TABLE_CHECK is not set +# CONFIG_PAGE_POISONING is not set +# CONFIG_DEBUG_RODATA_TEST is not set +CONFIG_ARCH_HAS_DEBUG_WX=y +# CONFIG_DEBUG_WX is not set +CONFIG_GENERIC_PTDUMP=y +# CONFIG_PTDUMP_DEBUGFS is not set +# CONFIG_DEBUG_OBJECTS is not set +# CONFIG_SHRINKER_DEBUG is not set +CONFIG_HAVE_DEBUG_KMEMLEAK=y +# CONFIG_DEBUG_KMEMLEAK is not set +# CONFIG_DEBUG_STACK_USAGE is not set +# CONFIG_SCHED_STACK_END_CHECK is not set +CONFIG_ARCH_HAS_DEBUG_VM_PGTABLE=y +# CONFIG_DEBUG_VM is not set +# CONFIG_DEBUG_VM_PGTABLE is not set +CONFIG_ARCH_HAS_DEBUG_VIRTUAL=y +# CONFIG_DEBUG_VIRTUAL is not set +CONFIG_DEBUG_MEMORY_INIT=y +# CONFIG_DEBUG_PER_CPU_MAPS is not set +CONFIG_ARCH_SUPPORTS_KMAP_LOCAL_FORCE_MAP=y +# CONFIG_DEBUG_KMAP_LOCAL_FORCE_MAP is not set +CONFIG_HAVE_ARCH_KASAN=y +CONFIG_HAVE_ARCH_KASAN_VMALLOC=y +CONFIG_CC_HAS_KASAN_GENERIC=y +CONFIG_CC_HAS_WORKING_NOSANITIZE_ADDRESS=y +# CONFIG_KASAN is not set +CONFIG_HAVE_ARCH_KFENCE=y +# CONFIG_KFENCE is not set +CONFIG_HAVE_ARCH_KMSAN=y +# end of Memory Debugging + +# CONFIG_DEBUG_SHIRQ is not set + +# +# Debug Oops, Lockups and Hangs +# +# CONFIG_PANIC_ON_OOPS is not set +CONFIG_PANIC_ON_OOPS_VALUE=0 +CONFIG_PANIC_TIMEOUT=0 +# CONFIG_SOFTLOCKUP_DETECTOR is not set +CONFIG_HARDLOCKUP_CHECK_TIMESTAMP=y +# CONFIG_HARDLOCKUP_DETECTOR is not set +# CONFIG_DETECT_HUNG_TASK is not set +# CONFIG_WQ_WATCHDOG is not set +# end of Debug Oops, Lockups and Hangs + +# +# Scheduler Debugging +# +# CONFIG_SCHED_DEBUG is not set +CONFIG_SCHED_INFO=y +# CONFIG_SCHEDSTATS is not set +# end of Scheduler Debugging + +# CONFIG_DEBUG_TIMEKEEPING is not set +# CONFIG_DEBUG_PREEMPT is not set + +# +# Lock Debugging (spinlocks, mutexes, etc...) +# +CONFIG_LOCK_DEBUGGING_SUPPORT=y +# CONFIG_PROVE_LOCKING is not set +# CONFIG_LOCK_STAT is not set +# CONFIG_DEBUG_RT_MUTEXES is not set +# CONFIG_DEBUG_SPINLOCK is not set +# CONFIG_DEBUG_MUTEXES is not set +# CONFIG_DEBUG_WW_MUTEX_SLOWPATH is not set +# CONFIG_DEBUG_RWSEMS is not set +# CONFIG_DEBUG_LOCK_ALLOC is not set +# CONFIG_DEBUG_ATOMIC_SLEEP is not set +# CONFIG_DEBUG_LOCKING_API_SELFTESTS is not set +# CONFIG_LOCK_TORTURE_TEST is not set +# CONFIG_WW_MUTEX_SELFTEST is not set +# CONFIG_SCF_TORTURE_TEST is not set +# CONFIG_CSD_LOCK_WAIT_DEBUG is not set +# end of Lock Debugging (spinlocks, mutexes, etc...) + +# CONFIG_DEBUG_IRQFLAGS is not set +CONFIG_STACKTRACE=y +# CONFIG_WARN_ALL_UNSEEDED_RANDOM is not set +# CONFIG_DEBUG_KOBJECT is not set + +# +# Debug kernel data structures +# +CONFIG_DEBUG_LIST=y +# CONFIG_DEBUG_PLIST is not set +# CONFIG_DEBUG_SG is not set +# CONFIG_DEBUG_NOTIFIERS is not set +CONFIG_BUG_ON_DATA_CORRUPTION=y +# CONFIG_DEBUG_MAPLE_TREE is not set +# end of Debug kernel data structures + +# CONFIG_DEBUG_CREDENTIALS is not set + +# +# RCU Debugging +# +# CONFIG_RCU_SCALE_TEST is not set +# CONFIG_RCU_TORTURE_TEST is not set +# CONFIG_RCU_REF_SCALE_TEST is not set +CONFIG_RCU_CPU_STALL_TIMEOUT=59 +CONFIG_RCU_EXP_CPU_STALL_TIMEOUT=0 +# CONFIG_RCU_TRACE is not set +# CONFIG_RCU_EQS_DEBUG is not set +# end of RCU Debugging + +# CONFIG_DEBUG_WQ_FORCE_RR_CPU is not set +# CONFIG_CPU_HOTPLUG_STATE_CONTROL is not set +# CONFIG_LATENCYTOP is not set +CONFIG_USER_STACKTRACE_SUPPORT=y +CONFIG_HAVE_RETHOOK=y +CONFIG_HAVE_FUNCTION_TRACER=y +CONFIG_HAVE_DYNAMIC_FTRACE=y +CONFIG_HAVE_DYNAMIC_FTRACE_WITH_REGS=y +CONFIG_HAVE_DYNAMIC_FTRACE_WITH_DIRECT_CALLS=y +CONFIG_HAVE_DYNAMIC_FTRACE_WITH_ARGS=y +CONFIG_HAVE_DYNAMIC_FTRACE_NO_PATCHABLE=y +CONFIG_HAVE_FTRACE_MCOUNT_RECORD=y +CONFIG_HAVE_SYSCALL_TRACEPOINTS=y +CONFIG_HAVE_FENTRY=y +CONFIG_HAVE_OBJTOOL_MCOUNT=y +CONFIG_HAVE_C_RECORDMCOUNT=y +CONFIG_HAVE_BUILDTIME_MCOUNT_SORT=y +CONFIG_TRACING_SUPPORT=y +# CONFIG_FTRACE is not set +# CONFIG_PROVIDE_OHCI1394_DMA_INIT is not set +# CONFIG_SAMPLES is not set +CONFIG_HAVE_SAMPLE_FTRACE_DIRECT=y +CONFIG_HAVE_SAMPLE_FTRACE_DIRECT_MULTI=y +CONFIG_ARCH_HAS_DEVMEM_IS_ALLOWED=y +CONFIG_STRICT_DEVMEM=y +# CONFIG_IO_STRICT_DEVMEM is not set + +# +# x86 Debugging +# +CONFIG_X86_VERBOSE_BOOTUP=y +CONFIG_EARLY_PRINTK=y +# CONFIG_EARLY_PRINTK_DBGP is not set +# CONFIG_EARLY_PRINTK_USB_XDBC is not set +# CONFIG_EFI_PGT_DUMP is not set +# CONFIG_DEBUG_TLBFLUSH is not set +CONFIG_HAVE_MMIOTRACE_SUPPORT=y +# CONFIG_X86_DECODER_SELFTEST is not set +CONFIG_IO_DELAY_0X80=y +# CONFIG_IO_DELAY_0XED is not set +# CONFIG_IO_DELAY_UDELAY is not set +# CONFIG_IO_DELAY_NONE is not set +# CONFIG_DEBUG_BOOT_PARAMS is not set +# CONFIG_CPA_DEBUG is not set +# CONFIG_DEBUG_ENTRY is not set +# CONFIG_DEBUG_NMI_SELFTEST is not set +# CONFIG_X86_DEBUG_FPU is not set +# CONFIG_PUNIT_ATOM_DEBUG is not set +# CONFIG_UNWINDER_ORC is not set +CONFIG_UNWINDER_FRAME_POINTER=y +# end of x86 Debugging + +# +# Kernel Testing and Coverage +# +# CONFIG_KUNIT is not set +# CONFIG_NOTIFIER_ERROR_INJECTION is not set +# CONFIG_FAULT_INJECTION is not set +CONFIG_ARCH_HAS_KCOV=y +CONFIG_CC_HAS_SANCOV_TRACE_PC=y +# CONFIG_KCOV is not set +CONFIG_RUNTIME_TESTING_MENU=y +# CONFIG_LKDTM is not set +# CONFIG_TEST_MIN_HEAP is not set +# CONFIG_TEST_DIV64 is not set +# CONFIG_BACKTRACE_SELF_TEST is not set +# CONFIG_TEST_REF_TRACKER is not set +# CONFIG_RBTREE_TEST is not set +# CONFIG_REED_SOLOMON_TEST is not set +# CONFIG_INTERVAL_TREE_TEST is not set +# CONFIG_ATOMIC64_SELFTEST is not set +# CONFIG_TEST_HEXDUMP is not set +# CONFIG_STRING_SELFTEST is not set +# CONFIG_TEST_STRING_HELPERS is not set +# CONFIG_TEST_STRSCPY is not set +# CONFIG_TEST_KSTRTOX is not set +# CONFIG_TEST_PRINTF is not set +# CONFIG_TEST_SCANF is not set +# CONFIG_TEST_BITMAP is not set +# CONFIG_TEST_UUID is not set +# CONFIG_TEST_XARRAY is not set +# CONFIG_TEST_MAPLE_TREE is not set +# CONFIG_TEST_RHASHTABLE is not set +# CONFIG_TEST_SIPHASH is not set +# CONFIG_TEST_IDA is not set +# CONFIG_FIND_BIT_BENCHMARK is not set +# CONFIG_TEST_FIRMWARE is not set +# CONFIG_TEST_SYSCTL is not set +# CONFIG_TEST_UDELAY is not set +# CONFIG_TEST_DYNAMIC_DEBUG is not set +# CONFIG_TEST_MEMCAT_P is not set +# CONFIG_TEST_MEMINIT is not set +# CONFIG_TEST_FREE_PAGES is not set +# CONFIG_TEST_FPU is not set +# CONFIG_TEST_CLOCKSOURCE_WATCHDOG is not set +CONFIG_ARCH_USE_MEMTEST=y +# CONFIG_MEMTEST is not set +# end of Kernel Testing and Coverage + +# +# Rust hacking +# +# end of Rust hacking +# end of Kernel hacking diff --git a/kernel/image/Dockerfile b/kernel/image/Dockerfile index 654ee9785..99a985734 100644 --- a/kernel/image/Dockerfile +++ b/kernel/image/Dockerfile @@ -5,11 +5,13 @@ RUN apt-get update && apt-get install -y \ bc \ binutils-multiarch \ binutils-aarch64-linux-gnu \ + binutils-x86-64-linux-gnu \ bison \ flex \ gcc \ xz-utils \ gcc-aarch64-linux-gnu \ + gcc-x86-64-linux-gnu \ git \ libncurses-dev \ make \ @@ -22,6 +24,7 @@ COPY sources.list /etc/apt/sources.list RUN apt-get update \ && dpkg --add-architecture arm64 \ -&& apt-get install -y libelf-dev:arm64 \ +&& dpkg --add-architecture amd64 \ +&& apt-get install -y libelf-dev:arm64 libelf-dev:amd64 \ && apt-get clean \ && rm -rf /var/lib/apt/lists/* \ No newline at end of file diff --git a/scripts/build-dist-x86_64.sh b/scripts/build-dist-x86_64.sh new file mode 100755 index 000000000..765a8fa27 --- /dev/null +++ b/scripts/build-dist-x86_64.sh @@ -0,0 +1,247 @@ +#!/bin/bash +# 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. + +# Builds the x86_64 deployment tarball. +# +# Runs INSIDE the aarch64 Linux dev container (invoked by +# `make dist-x86_64` via the linux_run macro). Cross-compiles all four +# host-side binaries — cctl, vminitd, cloud-hypervisor, virtiofsd — +# to x86_64-linux-musl, packs an initfs.ext4 with the x86_64 guest +# binaries inside, and emits bin/containerization-x86_64-.tar.gz. +# +# See docs/x86_64-build.md for full documentation: prerequisites, +# pipeline stages, toolchain rationale, and troubleshooting. +# +# Force-rebuild env vars (default = skip stages whose outputs are +# up-to-date): +# REBUILD_VMINITD=1 vminitd + vmexec +# REBUILD_INITFS=1 initfs.ext4 +# REBUILD_CH=1 cloud-hypervisor +# REBUILD_VIRTIOFSD=1 virtiofsd +# cctl x86 cross always rebuilds (Swift incremental handles no-ops). + +set -euo pipefail + +cd /workspace + +GIT_SHA=$(git rev-parse --short HEAD 2>/dev/null || echo unknown) +DIST_NAME="containerization-x86_64-${GIT_SHA}" +DIST_DIR="bin/dist-x86_64" +STAGE="${DIST_DIR}/${DIST_NAME}" +TGZ="bin/${DIST_NAME}.tar.gz" + +CROSS_PREFIX=/opt/cross-x86_64-musl +GNU_PREFIX=/opt/cross-x86_64-gnu +PATCH=/workspace/scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch + +# Cargo cross env for the musl stages (cctl, vminitd, cloud-hypervisor). +# CC/CXX/AR are used by cc-rs (Rust build scripts that compile C, e.g. +# zstd-sys, libseccomp-sys, capng) — points them at the Zig-backed +# wrappers. Linker is intentionally NOT set here: cargo-zigbuild +# installs its own linker wrapper that strips Rust's self-contained +# musl crt files (which would otherwise collide with Zig's musl crt). +# Setting CARGO_TARGET_*_LINKER ourselves would override that and +# cause duplicate-symbol link errors. +# pkg-config (used by libseccomp-sys and libcap-ng's capng-sys) points +# at the static-musl prefix, not the aarch64 host. +# PKG_CONFIG_ALL_STATIC=1 makes pkg-config-rs emit +# `rustc-link-lib=static=...` for resolved libs — required because +# the C libs at $CROSS_PREFIX are static-only (.a, no .so), so the +# default dynamic link would fail to find the .so. +# +# virtiofsd has its own glibc-dynamic env block below; it overrides +# PKG_CONFIG_LIBDIR / SYSROOT_DIR in a subshell so the musl values +# stay correct for cloud-hypervisor. +. /root/.cargo/env +export CC_x86_64_unknown_linux_musl=x86_64-linux-musl-gcc +export CXX_x86_64_unknown_linux_musl=x86_64-linux-musl-g++ +export AR_x86_64_unknown_linux_musl=x86_64-linux-musl-ar +export PKG_CONFIG_LIBDIR="${CROSS_PREFIX}/lib/pkgconfig" +export PKG_CONFIG_SYSROOT_DIR="${CROSS_PREFIX}" +export PKG_CONFIG_ALLOW_CROSS=1 +# Add the static-musl cross prefix to rustc's native library search +# path so the linker finds the libseccomp.so / libcap-ng.so linker +# scripts that build-musl-x86_64-deps.sh installs alongside the .a +# files. The scripts redirect resolution to the static archives. +export CARGO_TARGET_X86_64_UNKNOWN_LINUX_MUSL_RUSTFLAGS="-L native=${CROSS_PREFIX}/lib" + +# Pre-flight checks +[ -f .local/cloud-hypervisor/Cargo.toml ] || { + echo "ERROR: missing .local/cloud-hypervisor source checkout." >&2 + echo " git clone -b v52.0 https://github.com/cloud-hypervisor/cloud-hypervisor .local/cloud-hypervisor" >&2 + exit 1 +} +[ -f .local/virtiofsd/Cargo.toml ] || { + echo "ERROR: missing .local/virtiofsd source checkout." >&2 + echo " git clone https://gitlab.com/virtio-fs/virtiofsd .local/virtiofsd" >&2 + exit 1 +} + +# Kernel candidates (prefer the compressed bzImage produced by +# `make -C kernel TARGET_ARCH=x86_64`, fall back to an uncompressed +# vmlinux). The kernel is required — a tarball without one isn't +# usable, so fail hard rather than silently producing one. +KERNEL_SRC= +for candidate in kernel/vmlinuz-x86_64 kernel/vmlinux-x86_64; do + if [ -f "$candidate" ]; then + if file "$candidate" | grep -qE 'x86 boot|x86-64'; then + KERNEL_SRC=$candidate + break + else + echo "ERROR: $candidate exists but is not x86_64." >&2 + file "$candidate" >&2 + exit 1 + fi + fi +done +if [ -z "${KERNEL_SRC}" ]; then + echo "ERROR: no x86_64 kernel found at kernel/vmlinuz-x86_64 or kernel/vmlinux-x86_64." >&2 + echo " build one with 'make -C kernel TARGET_ARCH=x86_64'." >&2 + exit 1 +fi + +mkdir -p "${DIST_DIR}" + +# Decide which steps need to run before doing any work, so log messages +# match what's actually happening. + +NEED_VMINITD=1 +if [ "${REBUILD_VMINITD:-0}" != "1" ] \ + && [ -x "${DIST_DIR}/vminitd" ] && [ -x "${DIST_DIR}/vmexec" ] \ + && [ -z "$(find vminitd/Sources vminitd/Package.swift Sources/Containerization/SandboxContext \ + -newer "${DIST_DIR}/vminitd" -print -quit 2>/dev/null)" ] \ + && [ -z "$(find vminitd/Sources vminitd/Package.swift Sources/Containerization/SandboxContext \ + -newer "${DIST_DIR}/vmexec" -print -quit 2>/dev/null)" ]; then + NEED_VMINITD=0 +fi + +NEED_INITFS=1 +if [ "${REBUILD_INITFS:-0}" != "1" ] \ + && [ "${NEED_VMINITD}" = "0" ] \ + && [ -f "${DIST_DIR}/initfs.ext4" ] \ + && [ "${DIST_DIR}/initfs.ext4" -nt "${DIST_DIR}/vminitd" ] \ + && [ "${DIST_DIR}/initfs.ext4" -nt "${DIST_DIR}/vmexec" ]; then + NEED_INITFS=0 +fi + +NEED_CH=1 +if [ "${REBUILD_CH:-0}" != "1" ] && [ -x "${DIST_DIR}/cloud-hypervisor" ]; then + NEED_CH=0 +fi + +NEED_VIRTIOFSD=1 +if [ "${REBUILD_VIRTIOFSD:-0}" != "1" ] && [ -x "${DIST_DIR}/virtiofsd" ]; then + NEED_VIRTIOFSD=0 +fi + +SCRATCH_FLAGS=() +if [ -n "${SCRATCH_ROOT:-}" ]; then + SCRATCH_FLAGS=(--scratch-path "${SCRATCH_ROOT}/build-containerization") +fi + +echo "==> Cross-compiling cctl to x86_64-linux-musl" +swift build -c release \ + --swift-sdk x86_64-swift-linux-musl \ + --product cctl \ + -Xswiftc -warnings-as-errors \ + -Xlinker -L"${CROSS_PREFIX}/lib" \ + --disable-automatic-resolution \ + "${SCRATCH_FLAGS[@]}" +CCTL_X86_64_BIN="$(swift build -c release --swift-sdk x86_64-swift-linux-musl "${SCRATCH_FLAGS[@]}" --show-bin-path)/cctl" +install -m 755 "${CCTL_X86_64_BIN}" "${DIST_DIR}/cctl" + +if [ "${NEED_VMINITD}" = "1" ]; then + echo "==> Cross-compiling vminitd + vmexec to x86_64-linux-musl" + make -C vminitd \ + LIBC=musl \ + MUSL_ARCH=x86_64 \ + BUILD_CONFIGURATION=release \ + INSTALL_DIR="$(pwd)/${DIST_DIR}" +else + echo "==> Reusing staged vminitd + vmexec (sources unchanged; set REBUILD_VMINITD=1 to force)" +fi + +if [ "${NEED_CH}" = "1" ]; then + echo "==> Cross-compiling cloud-hypervisor to x86_64-unknown-linux-musl" + ( + cd .local/cloud-hypervisor + cargo zigbuild --release --target x86_64-unknown-linux-musl --bin cloud-hypervisor + ) + install -m 755 \ + ".local/cloud-hypervisor/target/x86_64-unknown-linux-musl/release/cloud-hypervisor" \ + "${DIST_DIR}/cloud-hypervisor" +else + echo "==> Reusing staged cloud-hypervisor (set REBUILD_CH=1 to force)" +fi + +if [ "${NEED_VIRTIOFSD}" = "1" ]; then + echo "==> Cross-compiling virtiofsd to x86_64-unknown-linux-gnu.2.35 (glibc-dynamic, with cap-drop patch)" + # virtiofsd ships glibc-dynamic: the deployment host provides + # libseccomp.so.2 and libcap-ng.so.0 at runtime. Subshell scopes + # the gnu env so it doesn't bleed into other stages. + ( + export CC_x86_64_unknown_linux_gnu=x86_64-linux-gnu-gcc + export CXX_x86_64_unknown_linux_gnu=x86_64-linux-gnu-g++ + export AR_x86_64_unknown_linux_gnu=x86_64-linux-gnu-ar + export PKG_CONFIG_LIBDIR="${GNU_PREFIX}/lib/pkgconfig" + export PKG_CONFIG_SYSROOT_DIR="${GNU_PREFIX}" + export CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-L native=${GNU_PREFIX}/lib" + cd .local/virtiofsd + if git apply --check "${PATCH}" 2>/dev/null; then + git apply "${PATCH}" + echo "applied virtiofsd cap-drop patch" + elif git apply --reverse --check "${PATCH}" 2>/dev/null; then + echo "virtiofsd cap-drop patch already applied" + else + echo "ERROR: virtiofsd cap-drop patch does not apply cleanly" >&2 + exit 1 + fi + cargo zigbuild --release --target x86_64-unknown-linux-gnu.2.35 + ) + install -m 755 \ + ".local/virtiofsd/target/x86_64-unknown-linux-gnu/release/virtiofsd" \ + "${DIST_DIR}/virtiofsd" +else + echo "==> Reusing staged virtiofsd (set REBUILD_VIRTIOFSD=1 to force)" +fi + +if [ "${NEED_INITFS}" = "1" ]; then + echo "==> Building initfs.ext4 with x86_64 guest binaries" + # The x86_64 tarball ships the raw initfs.ext4 (booted via `cctl run + # --initfs`); it does not ship a vminit OCI image, so no cctl step is + # needed here. build-initfs.sh builds the ext4 in-container (loop mount, + # or mke2fs -d fallback) and is arch-agnostic — the x86_64 guest binaries + # are just packed files. + rm -f "${DIST_DIR}/init.rootfs.tar.gz" "${DIST_DIR}/initfs.ext4" + ./scripts/build-initfs.sh \ + --vminitd "${DIST_DIR}/vminitd" \ + --vmexec "${DIST_DIR}/vmexec" \ + --ext4 "${DIST_DIR}/initfs.ext4" +else + echo "==> Reusing staged initfs.ext4 (vminitd/vmexec unchanged; set REBUILD_INITFS=1 to force)" +fi + +echo "==> Staging tree at ${STAGE} and packaging" +rm -rf "${STAGE}" +mkdir -p "${STAGE}/bin" +install -m 755 "${DIST_DIR}/cctl" "${STAGE}/bin/cctl" +install -m 755 "${DIST_DIR}/cloud-hypervisor" "${STAGE}/bin/cloud-hypervisor" +install -m 755 "${DIST_DIR}/virtiofsd" "${STAGE}/bin/virtiofsd" +mkdir -p "${STAGE}/kernel" +cp "${KERNEL_SRC}" "${STAGE}/kernel/$(basename "${KERNEL_SRC}")" +cp "${DIST_DIR}/initfs.ext4" "${STAGE}/initfs.ext4" +rm -f "${TGZ}" +tar -czf "${TGZ}" -C "${DIST_DIR}" "${DIST_NAME}" +echo "wrote ${TGZ}" diff --git a/scripts/build-glibc-x86_64-deps.sh b/scripts/build-glibc-x86_64-deps.sh new file mode 100755 index 000000000..751815c4b --- /dev/null +++ b/scripts/build-glibc-x86_64-deps.sh @@ -0,0 +1,138 @@ +#!/bin/bash +# 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. + +# Builds x86_64-linux-gnu shared-library versions of libseccomp and +# libcap-ng (the two C libs virtiofsd links against) and installs them +# at /opt/cross-x86_64-gnu. Sibling of build-musl-x86_64-deps.sh; the +# Dockerfile invokes both at image build time. +# +# Why a separate prefix: virtiofsd ships glibc-dynamic in the x86_64 +# tarball (see docs/x86_64-build.md) so deployment hosts can use their +# system libseccomp.so.2 + libcap-ng.so.0; everything else in the tarball +# stays musl-static. Mixing static-musl .a archives and dynamic-gnu .so +# files at one prefix confuses pkg-config and the linker, so they live +# apart. +# +# The glibc baseline is pinned at 2.35 (Ubuntu 22.04 / Debian 12 / RHEL 9 +# era) via the Zig wrapper scripts at /usr/local/bin/x86_64-linux-gnu-*, +# which dispatch to `zig cc -target x86_64-linux-gnu.2.35`. Bump the +# wrapper triple if the baseline moves. +# +# Only two libraries here, both shared: virtiofsd's other build-script +# deps (zstd, etc.) come from cargo crates that vendor C sources, and +# cctl's libarchive lives on the musl side. + +set -euo pipefail + +HOST=x86_64-linux-gnu +PREFIX=/opt/cross-x86_64-gnu + +mkdir -p "${PREFIX}/lib" "${PREFIX}/include" + +export CC="${HOST}-gcc" +export CXX="${HOST}-g++" +export AR="${HOST}-ar" +export RANLIB="${HOST}-ranlib" +export STRIP="${HOST}-strip" +export PKG_CONFIG_PATH="${PREFIX}/lib/pkgconfig" + +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT +cd "${WORK}" + +JOBS="$(nproc)" + +# fetch_extract URL ARCHIVE +# +# Downloads URL to ARCHIVE then extracts. Relies on HTTPS for transport +# integrity; no SHA pinning. The other build-time deps (apt packages, +# Zig, Rust toolchain) trust the same. +fetch_extract() { + local url=$1 archive=$2 + curl -fsSL -o "${archive}" "${url}" + tar -xf "${archive}" +} + +# Sanity check: cross compiler produces clean output for a trivial +# program. autotools / libtool turn unexpected compiler chatter into +# baffling configure errors; surfacing it here gives a real message. +echo "==> cross compiler sanity check" +"${CC}" --version +cat > "${WORK}/sanity.c" <<'EOF' +void foo(void) {} +EOF +out=$("${CC}" -c "${WORK}/sanity.c" -o "${WORK}/sanity.o" 2>&1) || { + echo "ERROR: cross compiler failed on trivial test.c:" >&2 + echo "${out}" >&2 + exit 1 +} +if [ -n "${out}" ]; then + echo "WARNING: cross compiler emitted output on a clean compile:" >&2 + echo "${out}" >&2 +fi + +# libcap-ng — github auto-archive (release artifacts for older tags +# aren't always uploaded). +LIBCAP_NG_VERSION=0.8.5 +fetch_extract "https://github.com/stevegrubb/libcap-ng/archive/refs/tags/v${LIBCAP_NG_VERSION}.tar.gz" libcap-ng.tar.gz +( + cd "libcap-ng-${LIBCAP_NG_VERSION}" + # GNU automake's strict mode requires these standard files to exist; + # the auto-archive tarball doesn't ship NEWS. Cheaper than + # configure.ac surgery. + touch NEWS README AUTHORS ChangeLog + autoreconf -i + ./configure --host="${HOST}" --prefix="${PREFIX}" \ + --disable-static --enable-shared \ + --without-python --without-python3 + make -j"${JOBS}" + make install +) + +# libseccomp — needs gperf at build time (installed via the Dockerfile +# alongside the musl deps). Built shared here. +LIBSECCOMP_VERSION=2.5.5 +fetch_extract "https://github.com/seccomp/libseccomp/releases/download/v${LIBSECCOMP_VERSION}/libseccomp-${LIBSECCOMP_VERSION}.tar.gz" libseccomp.tar.gz +( + cd "libseccomp-${LIBSECCOMP_VERSION}" + ./configure --host="${HOST}" --prefix="${PREFIX}" \ + --disable-static --enable-shared \ + --disable-python + make -j"${JOBS}" + make install +) + +# Drop libtool .la files — they encode build-host paths and confuse +# downstream consumers; the .so + pkg-config .pc files are sufficient. +rm -f "${PREFIX}/lib"/*.la + +# Force the unversioned dev symlinks (libseccomp.so, libcap-ng.so). +# libtool conservatively omits these when cross-compiling, but rustc's +# link step looks for them by unversioned name; without them the +# link fails with "unable to find dynamic system library 'seccomp'". +# Idempotent — `ln -sf` overwrites any existing symlink, and the loop +# picks up whatever versioned files libtool actually installed. +for stem in libseccomp libcap-ng; do + versioned=$(ls "${PREFIX}/lib/${stem}.so."* 2>/dev/null | sort -V | head -n1) + if [ -n "${versioned}" ]; then + ln -sf "$(basename "${versioned}")" "${PREFIX}/lib/${stem}.so" + else + echo "ERROR: no ${stem}.so.* found in ${PREFIX}/lib after install" >&2 + ls -la "${PREFIX}/lib" >&2 + exit 1 + fi +done + +echo "glibc-dynamic x86_64 C deps installed under ${PREFIX}" diff --git a/scripts/build-initfs.sh b/scripts/build-initfs.sh new file mode 100755 index 000000000..d2c56fdfb --- /dev/null +++ b/scripts/build-initfs.sh @@ -0,0 +1,106 @@ +#!/bin/bash +# 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. + +# Builds the guest init filesystem (initfs.ext4) — and optionally a rootfs +# tar.gz for OCI image creation — from the compiled vminitd/vmexec binaries. +# +# Runs INSIDE the Linux dev container (invoked by the root Makefile's `init` +# target via linux_run on macOS, or directly on Linux). A real loop mount is +# preferred; when loop devices aren't available (an unprivileged CI container, +# or a dev VM without loop support) it falls back to `mke2fs -d`, which +# populates the filesystem without mounting. Both paths yield an equivalent +# ext4, so the build works whether or not the container is privileged. +# +# The rootfs layout below MUST stay in sync with cctl's InitImage rootfs +# (Sources/cctl/RootfsCommand.swift): directories bin/ sbin/ dev/ sys/ +# proc/self/ run/ tmp/ mnt/ var/, sbin/vminitd + sbin/vmexec at mode 0755, and +# a proc/self/exe -> sbin/vminitd symlink ("hack for swift init's booting"). + +set -euo pipefail + +usage() { + echo "usage: $0 --vminitd PATH --vmexec PATH --ext4 OUT.ext4 [--tar OUT.tar.gz] [--size 512M]" >&2 + exit 2 +} + +VMINITD= +VMEXEC= +EXT4= +TAR= +SIZE=512M +while [ $# -gt 0 ]; do + case "$1" in + --vminitd) VMINITD=$2; shift 2 ;; + --vmexec) VMEXEC=$2; shift 2 ;; + --ext4) EXT4=$2; shift 2 ;; + --tar) TAR=$2; shift 2 ;; + --size) SIZE=$2; shift 2 ;; + *) usage ;; + esac +done + +[ -n "$VMINITD" ] && [ -n "$VMEXEC" ] && [ -n "$EXT4" ] || usage +[ -f "$VMINITD" ] || { echo "ERROR: vminitd not found: $VMINITD" >&2; exit 1; } +[ -f "$VMEXEC" ] || { echo "ERROR: vmexec not found: $VMEXEC" >&2; exit 1; } + +umask 022 +STAGING=$(mktemp -d) +MNT= +cleanup() { + if [ -n "$MNT" ]; then + mountpoint -q "$MNT" 2>/dev/null && umount "$MNT" 2>/dev/null || true + rmdir "$MNT" 2>/dev/null || true + fi + rm -rf "$STAGING" +} +trap cleanup EXIT + +# Directory structure — keep in sync with RootfsCommand.directories. +for d in bin sbin dev sys proc/self run tmp mnt var; do + mkdir -p "$STAGING/$d" +done +install -m 0755 "$VMINITD" "$STAGING/sbin/vminitd" +install -m 0755 "$VMEXEC" "$STAGING/sbin/vmexec" +ln -sf sbin/vminitd "$STAGING/proc/self/exe" + +mkdir -p "$(dirname "$EXT4")" +rm -f "$EXT4" +truncate -s "$SIZE" "$EXT4" + +# Prefer a real loop mount; fall back to `mke2fs -d` when it isn't available. +if mkfs.ext4 -F -q "$EXT4" \ + && MNT=$(mktemp -d) \ + && mount -o loop "$EXT4" "$MNT" 2>/dev/null; then + echo "==> populating $EXT4 via loop mount" + cp -a "$STAGING"/. "$MNT"/ + sync + umount "$MNT" + rmdir "$MNT" + MNT= +else + echo "==> loop mount unavailable; populating $EXT4 via mke2fs -d" + if [ -n "$MNT" ]; then rmdir "$MNT" 2>/dev/null || true; MNT=; fi + rm -f "$EXT4" + truncate -s "$SIZE" "$EXT4" + mkfs.ext4 -F -q -d "$STAGING" "$EXT4" +fi +echo "==> wrote initfs $EXT4" + +if [ -n "$TAR" ]; then + mkdir -p "$(dirname "$TAR")" + rm -f "$TAR" + tar -czf "$TAR" -C "$STAGING" . + echo "==> wrote rootfs tar $TAR" +fi diff --git a/scripts/build-musl-x86_64-deps.sh b/scripts/build-musl-x86_64-deps.sh new file mode 100755 index 000000000..a0a44ab29 --- /dev/null +++ b/scripts/build-musl-x86_64-deps.sh @@ -0,0 +1,188 @@ +#!/bin/bash +# 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. + +# Builds static-musl x86_64 versions of the C libraries that cctl and +# virtiofsd link against, and installs them at /opt/cross-x86_64-musl +# (a standalone prefix — the Zig-based cross compiler has no +# traditional sysroot, so the build-dist-x86_64.sh script and the +# cargo cross flow add explicit -L / -I flags pointing here). +# +# Invoked once at dev-image build time; the resulting layer is cached +# until this script changes. Adding/removing a library here is the only +# reason to invalidate it. + +set -euo pipefail + +HOST=x86_64-linux-musl +PREFIX=/opt/cross-x86_64-musl + +mkdir -p "${PREFIX}/lib" "${PREFIX}/include" + +export CC="${HOST}-gcc" +export CXX="${HOST}-g++" +export AR="${HOST}-ar" +export RANLIB="${HOST}-ranlib" +export STRIP="${HOST}-strip" +export PKG_CONFIG_PATH="${PREFIX}/lib/pkgconfig" + +WORK=$(mktemp -d) +trap 'rm -rf "${WORK}"' EXIT +cd "${WORK}" + +JOBS="$(nproc)" + +# fetch_extract URL ARCHIVE +# +# Downloads URL to ARCHIVE then extracts. Relies on HTTPS for transport +# integrity; no SHA pinning. The other build-time deps (apt packages, +# Zig, Rust toolchain) trust the same. +fetch_extract() { + local url=$1 archive=$2 + curl -fsSL -o "${archive}" "${url}" + tar -xf "${archive}" +} + +# Sanity check: cross compiler is on PATH and produces clean output +# for a trivial program. If it doesn't, zlib's configure script (which +# treats any stderr/stdout from a test compile as evidence of -Werror) +# will fail with a misleading "Compiler error reporting is too harsh" +# error. Surfacing this here gives us a real error message instead. +echo "==> cross compiler sanity check" +"${CC}" --version +cat > "${WORK}/sanity.c" <<'EOF' +void foo(void) {} +EOF +out=$("${CC}" -c "${WORK}/sanity.c" -o "${WORK}/sanity.o" 2>&1) || { + echo "ERROR: cross compiler failed on trivial test.c:" >&2 + echo "${out}" >&2 + exit 1 +} +if [ -n "${out}" ]; then + echo "WARNING: cross compiler emitted output on a clean compile:" >&2 + echo "${out}" >&2 + echo "(this will trip up zlib's configure script — see fix below)" >&2 +fi + +# zlib — provides libz.a. Its configure does not take --host, so the +# CC env var is what selects the cross compiler. +ZLIB_VERSION=1.3.1 +fetch_extract "https://zlib.net/fossils/zlib-${ZLIB_VERSION}.tar.gz" zlib.tar.gz +( + cd "zlib-${ZLIB_VERSION}" + if ! ./configure --static --prefix="${PREFIX}"; then + echo "==================== zlib configure.log ====================" >&2 + [ -f configure.log ] && cat configure.log >&2 + echo "============================================================" >&2 + exit 1 + fi + make -j"${JOBS}" + make install +) + +# xz — provides liblzma.a. +XZ_VERSION=5.6.4 +fetch_extract "https://github.com/tukaani-project/xz/releases/download/v${XZ_VERSION}/xz-${XZ_VERSION}.tar.gz" xz.tar.gz +( + cd "xz-${XZ_VERSION}" + ./configure --host="${HOST}" --prefix="${PREFIX}" \ + --enable-static --disable-shared \ + --disable-doc --disable-scripts \ + --disable-xz --disable-xzdec --disable-lzmadec --disable-lzmainfo \ + --disable-lzma-links + make -j"${JOBS}" + make install +) + +# bzip2 — no autotools, drives a plain Makefile. Build only the static +# library; the bzip2 CLI tools are not needed. +BZIP2_VERSION=1.0.8 +fetch_extract "https://sourceware.org/pub/bzip2/bzip2-${BZIP2_VERSION}.tar.gz" bzip2.tar.gz +( + cd "bzip2-${BZIP2_VERSION}" + make CC="${CC}" AR="${AR}" RANLIB="${RANLIB}" libbz2.a -j"${JOBS}" + install -m 644 libbz2.a "${PREFIX}/lib/" + install -m 644 bzlib.h "${PREFIX}/include/" +) + +# libarchive — needs zlib + lzma + bz2 (built above). Disable optional +# deps that pull in extra toolchain weight (xml2, iconv, zstd, lz4, +# openssl, libb2). cctl uses libarchive for tar/ext layouts; the +# disabled formats are not used at runtime. +LIBARCHIVE_VERSION=3.7.7 +fetch_extract "https://github.com/libarchive/libarchive/releases/download/v${LIBARCHIVE_VERSION}/libarchive-${LIBARCHIVE_VERSION}.tar.gz" libarchive.tar.gz +( + cd "libarchive-${LIBARCHIVE_VERSION}" + ./configure --host="${HOST}" --prefix="${PREFIX}" \ + --enable-static --disable-shared \ + --disable-bsdtar --disable-bsdcat --disable-bsdcpio --disable-bsdunzip \ + --without-xml2 --without-iconv --without-zstd --without-lz4 \ + --without-openssl --without-libb2 \ + CPPFLAGS="-I${PREFIX}/include" \ + LDFLAGS="-L${PREFIX}/lib" + make -j"${JOBS}" + make install +) + +# libcap-ng — github auto-archive (release artifacts for older tags +# aren't always uploaded; the auto-archive URL is always available +# for any tag, but doesn't ship a pre-generated configure script, so +# we autoreconf it ourselves). +LIBCAP_NG_VERSION=0.8.5 +fetch_extract "https://github.com/stevegrubb/libcap-ng/archive/refs/tags/v${LIBCAP_NG_VERSION}.tar.gz" libcap-ng.tar.gz +( + cd "libcap-ng-${LIBCAP_NG_VERSION}" + # GNU automake's default (strict) mode requires these standard + # files to exist; the auto-archive tarball doesn't ship NEWS. + # Cheaper than configure.ac surgery. + touch NEWS README AUTHORS ChangeLog + autoreconf -i + ./configure --host="${HOST}" --prefix="${PREFIX}" \ + --enable-static --disable-shared \ + --without-python --without-python3 + make -j"${JOBS}" + make install +) + +# libseccomp — needs gperf at build time (installed via apt above). +LIBSECCOMP_VERSION=2.5.5 +fetch_extract "https://github.com/seccomp/libseccomp/releases/download/v${LIBSECCOMP_VERSION}/libseccomp-${LIBSECCOMP_VERSION}.tar.gz" libseccomp.tar.gz +( + cd "libseccomp-${LIBSECCOMP_VERSION}" + ./configure --host="${HOST}" --prefix="${PREFIX}" \ + --enable-static --disable-shared \ + --disable-python + make -j"${JOBS}" + make install +) + +# Linker-script `.so` shims for libseccomp + libcap-ng. The Rust +# `-sys` crates emit plain `cargo:rustc-link-lib=seccomp` (no +# static= prefix), and they don't declare `links = "..."` in their +# Cargo.toml, so cargo's build-script override can't match them. +# Instead we hand the linker fake `.so` files that are actually GNU +# ld linker scripts pointing at the static archive — when ld resolves +# `-lseccomp` to libseccomp.so it reads the script and pulls in +# libseccomp.a as if statically linked. Works regardless of -Bstatic +# vs -Bdynamic state and avoids needing to patch the -sys crates. +cat > "${PREFIX}/lib/libseccomp.so" < "${PREFIX}/lib/libcap-ng.so" </dev/null 2>&1; then echo "hawkeye already installed" -else - echo "Installing hawkeye" - export VERSION=v6.5.1 - curl --proto '=https' --tlsv1.2 -LsSf https://github.com/korandoru/hawkeye/releases/download/${VERSION}/hawkeye-installer.sh | CARGO_HOME=.local sh -s -- --no-modify-path + exit 0 +fi + +# This installer supports Apple silicon (arm64 macOS) only. +if [ "$(uname -s)" != "Darwin" ] || [ "$(uname -m)" != "arm64" ]; then + echo "error: install-hawkeye.sh supports Apple silicon (arm64 macOS) only" >&2 + exit 1 fi + +VERSION=v6.5.1 +ARTIFACT="hawkeye-aarch64-apple-darwin.tar.xz" +ARTIFACT_URL="https://github.com/korandoru/hawkeye/releases/download/${VERSION}/${ARTIFACT}" +# Pinned SHA-256 of ${ARTIFACT} for ${VERSION}; update when bumping VERSION. +EXPECTED_SHA256="99777f21e4e56c9946ed93621885532c6a0476377f497565c583f5911f2cbb1f" + +echo "Installing hawkeye ${VERSION}" +workdir="$(mktemp -d)" +trap 'rm -rf "${workdir}"' EXIT +tarball="${workdir}/${ARTIFACT}" + +# Download the tarball, verify it against the pinned checksum (aborts on +# mismatch), then extract just the hawkeye binary into .local/bin. +curl --proto '=https' --tlsv1.2 -LsSf "${ARTIFACT_URL}" -o "${tarball}" +echo "${EXPECTED_SHA256} ${tarball}" | shasum -a 256 -c - + +tar -xf "${tarball}" --strip-components 1 -C "${workdir}" +mkdir -p .local/bin +mv "${workdir}/hawkeye" .local/bin/hawkeye +chmod +x .local/bin/hawkeye + +echo "hawkeye ${VERSION} installed to .local/bin/hawkeye" diff --git a/scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch b/scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch new file mode 100644 index 000000000..8ec079bd8 --- /dev/null +++ b/scripts/patches/virtiofsd-skip-cap-drop-with-sandbox-none.patch @@ -0,0 +1,41 @@ +# virtiofsd patch — skip capability drop when --sandbox=none. +# +# virtiofsd 1.13.3 unconditionally calls capng::apply (capset(2)) at +# startup when running as root. Inside apple/container's --virtualization +# dev container the default seccomp profile blocks capset(2), so virtiofsd +# fails with "failed to sync capabilities with the kernel" and +# process::exit(1)s before binding its socket. +# +# We already pass --sandbox=none to virtiofsd from VirtiofsdProcess (same +# rationale as why cloud-hypervisor runs with --seccomp false in the same +# environment). This patch extends the existing upstream gate ("We don't +# modify the capabilities if the user call us without any sandbox") so +# that --sandbox=none also skips cap drop at uid==0, matching the user's +# explicit opt-out from the sandbox. +# +# Applied automatically by `make build-virtiofsd` before `cargo build`. +# Targets virtiofsd 1.13.3 (.local/virtiofsd). + +diff --git a/src/main.rs b/src/main.rs +index 176e178..27dce55 100644 +--- a/src/main.rs ++++ b/src/main.rs +@@ -858,8 +858,17 @@ fn main() { + + // We don't modify the capabilities if the user call us without + // any sandbox (i.e. --sandbox=none) as unprivileged user ++ // ++ // CONTAINERIZATION-PATCH: also skip cap drop with --sandbox=none even ++ // when running as root. Inside apple/container's --virtualization dev ++ // container the seccomp profile blocks capset(2), so capng::apply ++ // fails with "failed to sync capabilities with the kernel" and ++ // virtiofsd process::exit(1)s before binding its socket. The ++ // user explicitly opted out of the sandbox; respect that and don't ++ // touch caps either. Same class of issue as why CH runs with ++ // --seccomp false in the same env. + let uid = unsafe { libc::geteuid() }; +- if uid == 0 { ++ if uid == 0 && !matches!(opt.sandbox, SandboxMode::None) { + drop_capabilities(fs_cfg.inode_file_handles, opt.modcaps); + } + diff --git a/vminitd/Makefile b/vminitd/Makefile index 0227667c4..a2f43c48e 100644 --- a/vminitd/Makefile +++ b/vminitd/Makefile @@ -18,6 +18,11 @@ export GIT_COMMIT := $(shell git rev-parse HEAD) export GIT_TAG := $(shell git describe --tags --exact-match 2>/dev/null || echo "") export BUILD_TIME := $(shell date -u +%Y-%m-%dT%H:%M:%SZ) SWIFT_WARNING_CONFIG := $(if $(filter-out false,$(WARNINGS_AS_ERRORS)),-Xswiftc -warnings-as-errors) +# MUSL_ARCH selects which Static Linux SDK triple to build against +# ($(MUSL_ARCH)-swift-linux-musl). Defaults to the host architecture +# so the in-tree aarch64 flow works unchanged, but callers can override +# (e.g. `make MUSL_ARCH=x86_64` for the dist-x86_64 cross-build path). +ifndef MUSL_ARCH UNAME_M := $(shell uname -m) ifeq ($(UNAME_M),arm64) MUSL_ARCH := aarch64 @@ -26,6 +31,7 @@ MUSL_ARCH := aarch64 else MUSL_ARCH := x86_64 endif +endif LIBC ?= musl ifeq ($(LIBC),musl) @@ -34,21 +40,26 @@ endif SWIFT_CONFIGURATION := $(SWIFT_SDK_FLAGS) $(SWIFT_WARNING_CONFIG) -Xlinker -s --disable-automatic-resolution +SCRATCH_ROOT ?= +SCRATCH_PATH ?= $(if $(SCRATCH_ROOT),$(SCRATCH_ROOT)/build-vminitd-$(LIBC)) +SWIFT_CONFIGURATION += $(if $(SCRATCH_PATH),--scratch-path $(SCRATCH_PATH)) + SWIFT_VERSION := 6.3.0 SWIFT_SDK_URL := https://download.swift.org/swift-6.3-release/static-sdk/swift-6.3-RELEASE/swift-6.3-RELEASE_static-linux-0.1.0.artifactbundle.tar.gz SWIFT_SDK_CHECKSUM := d2078b69bdeb5c31202c10e9d8a11d6f66f82938b51a4b75f032ccb35c4c286c SWIFT_SDK_PATH := /tmp/$(notdir $(SWIFT_SDK_URL)) +# vminitd is built inside the Linux dev container (see the root Makefile's +# `vminitd` target, which routes through `linux_run` on macOS), so `swift` +# is always the toolchain on PATH inside that container. The host no longer +# needs Swiftly or a locally-installed Static Linux SDK. SYSTEM_TYPE := $(shell uname -s) -ifeq ($(SYSTEM_TYPE),Darwin) -SWIFTLY_URL := https://download.swift.org/swiftly/darwin/swiftly.pkg -SWIFTLY_FILENAME := $(notdir $(SWIFTLY_URL)) -SWIFTLY_BIN_DIR ?= ~/.swiftly/bin -SWIFT := $(SWIFTLY_BIN_DIR)/swift -else SWIFT ?= swift -endif -BUILD_BIN_DIR := $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --show-bin-path) +# Lazily evaluated (`=`, not `:=`) so `swift build --swift-sdk ... --show-bin-path` +# only runs for the `all` target — which runs inside the Linux dev container +# where the Static Linux SDK exists. Evaluating it at parse time would fail on a +# macOS host (which no longer installs the SDK) for targets like `clean`. +BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --show-bin-path) ifeq ($(SYSTEM_TYPE),Darwin) MACOS_VERSION := $(shell sw_vers -productVersion) @@ -58,38 +69,28 @@ endif .DEFAULT_GOAL := all +# INSTALL_DIR is where built binaries land. Defaults to ./bin so the +# in-tree aarch64 flow is unchanged. The dist-x86_64 cross-build +# overrides this to keep its artifacts out of vminitd/bin/. +INSTALL_DIR ?= ./bin + .PHONY: all -all: +all: @echo Building vminitd and vmexec... - @mkdir -p ./bin/ - @rm -f ./bin/vminitd - @rm -f ./bin/vmexec + @mkdir -p $(INSTALL_DIR) + @rm -f $(INSTALL_DIR)/vminitd + @rm -f $(INSTALL_DIR)/vmexec @$(SWIFT) --version @$(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) - @install "$(BUILD_BIN_DIR)/vminitd" ./bin/ - @install "$(BUILD_BIN_DIR)/vmexec" ./bin/ - -.PHONY: cross-prep -cross-prep: swift linux-sdk - -.PHONY: swiftly -swiftly: - @if ! command -v ${SWIFTLY_BIN_DIR}/swiftly > /dev/null 2>&1; then \ - echo "Installing Swiftly..."; \ - curl -o /var/tmp/$(SWIFTLY_FILENAME) $(SWIFTLY_URL) && \ - installer -pkg /var/tmp/$(SWIFTLY_FILENAME) -target CurrentUserHomeDirectory && \ - ${SWIFTLY_BIN_DIR}/swiftly init --quiet-shell-followup --skip-install && \ - . ~/.swiftly/env.sh && \ - hash -r && \ - rm /var/tmp/$(SWIFTLY_FILENAME); \ - fi - -.PHONY: swift -swift: swiftly - @echo Installing Swift $(SWIFT_VERSION)... - @${SWIFTLY_BIN_DIR}/swiftly install $(SWIFT_VERSION) + @install "$(BUILD_BIN_DIR)/vminitd" $(INSTALL_DIR)/ + @install "$(BUILD_BIN_DIR)/vmexec" $(INSTALL_DIR)/ .PHONY: linux-sdk +# Installs the Static Linux SDK into the active Swift toolchain. Used inside +# plain Swift Linux containers that don't already bake in the SDK (e.g. the +# `linux-build.yml` compile-check and the CI `buildGuest` job). The dev image +# built by `make linux-image` installs the same SDK at image-build time, so it +# does not need this target. linux-sdk: @echo Installing Static Linux SDK... @curl -L -o $(SWIFT_SDK_PATH) $(SWIFT_SDK_URL) @@ -101,4 +102,4 @@ clean: @echo Cleaning the vminitd build files... @rm -f ./bin/vminitd @rm -f ./bin/vmexec - @rm -rf .build + @rm -rf .build $(SCRATCH_PATH) diff --git a/vminitd/Package.resolved b/vminitd/Package.resolved index e37bc3ade..e111b5e0d 100644 --- a/vminitd/Package.resolved +++ b/vminitd/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "6ccceb47b6a402e9ac07d23204ec7f4792823b22b96275cd67f8531787a60c04", + "originHash" : "264b211a5ea74fa24ced86faade5901700722c925484a26379cc4a6b40083c6c", "pins" : [ { "identity" : "async-http-client", "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/async-http-client.git", "state" : { - "revision" : "4b99975677236d13f0754339864e5360142ff5a1", - "version" : "1.30.3" + "revision" : "4603a8036d921ea999fadb742931546c341f4bd7", + "version" : "1.35.0" } }, { @@ -15,8 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift-2.git", "state" : { - "revision" : "f28854bc760a116e053fdfc4a48a9428c34625c0", - "version" : "2.3.0" + "revision" : "28cdd63ef88583ddc67d7bb179eab46fab465ce9", + "version" : "2.4.2" } }, { @@ -24,8 +24,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift-nio-transport.git", "state" : { - "revision" : "f37e0c2d293cea668b11e10e1fb1c24cb40781ff", - "version" : "2.4.4" + "revision" : "2ca31f06658ed288a2560e23ad649acbb3d6b3a3", + "version" : "2.9.0" } }, { @@ -33,8 +33,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/grpc/grpc-swift-protobuf.git", "state" : { - "revision" : "19153231a03c2fda1f4ea60da1b92a2cb9c011d8", - "version" : "2.2.0" + "revision" : "176c5a434fd76f6f479848d1a8f7d44967534168", + "version" : "2.4.1" } }, { @@ -51,8 +51,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser.git", "state" : { - "revision" : "c5d11a805e765f52ba34ec7284bd4fcd6ba68615", - "version" : "1.7.0" + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" } }, { @@ -60,8 +60,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-asn1.git", "state" : { - "revision" : "a54383ada6cecde007d374f58f864e29370ba5c3", - "version" : "1.3.2" + "revision" : "a9a5efd40eaf558a2bcd48d64b1d1646be686008", + "version" : "1.7.1" } }, { @@ -69,8 +69,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-async-algorithms.git", "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" } }, { @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" + "revision" : "0442cb5a3f98ab802acb777929fdb446bda11a34", + "version" : "1.3.1" } }, { @@ -87,8 +87,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-certificates.git", "state" : { - "revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a", - "version" : "1.15.0" + "revision" : "89fbc3714264cce8db8e4ec51b64e01c3e28c6c5", + "version" : "1.19.3" } }, { @@ -96,7 +96,16 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-collections.git", "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "revision" : "fea17c02d767f46b23070fdfdacc28a03a39232a", + "version" : "1.5.1" + } + }, + { + "identity" : "swift-configuration", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-configuration.git", + "state" : { + "revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9", "version" : "1.2.0" } }, @@ -105,8 +114,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-crypto.git", "state" : { - "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", - "version" : "3.12.3" + "revision" : "95ba0316a9b733e92bb6b071255ff46263bbe7dc", + "version" : "3.15.1" } }, { @@ -123,8 +132,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-structured-headers.git", "state" : { - "revision" : "db6eea3692638a65e2124990155cd220c2915903", - "version" : "1.3.0" + "revision" : "933538faa42c432d385f02e07df0ace7c5ecfc47", + "version" : "1.7.0" } }, { @@ -132,8 +141,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-http-types.git", "state" : { - "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", - "version" : "1.4.0" + "revision" : "db774a277f60063a32d854f2980299caf06da041", + "version" : "1.6.0" } }, { @@ -141,8 +150,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-log.git", "state" : { - "revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523", - "version" : "1.10.1" + "revision" : "a878e7f8f46cfc0e1125e565b5c08e7d5272dc9a", + "version" : "1.14.0" } }, { @@ -150,8 +159,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio.git", "state" : { - "revision" : "4e8f4b1c9adaa59315c523540c1ff2b38adc20a9", - "version" : "2.87.0" + "revision" : "0b18836bd8b0162e7e17a995a3fbee20ed8f3b2b", + "version" : "2.101.3" } }, { @@ -159,8 +168,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-extras.git", "state" : { - "revision" : "145db1962f4f33a4ea07a32e751d5217602eea29", - "version" : "1.28.0" + "revision" : "88a51340f59cf181ebde888bd1b749296b3ec029", + "version" : "1.34.3" } }, { @@ -168,8 +177,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-http2.git", "state" : { - "revision" : "5e9e99ec96c53bc2c18ddd10c1e25a3cd97c55e5", - "version" : "1.38.0" + "revision" : "61d1b44f6e4e118792be1cff88ee2bc0267c6f9a", + "version" : "1.44.0" } }, { @@ -177,8 +186,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-ssl.git", "state" : { - "revision" : "173cc69a058623525a58ae6710e2f5727c663793", - "version" : "2.36.0" + "revision" : "d930168b86f46ca51a4bc09c5ca45c1833db8067", + "version" : "2.37.2" } }, { @@ -186,8 +195,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-nio-transport-services.git", "state" : { - "revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56", - "version" : "1.24.0" + "revision" : "67787bb645a5e67d2edcdfbe48a216cc549222d5", + "version" : "1.28.0" } }, { @@ -195,8 +204,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-numerics.git", "state" : { - "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", - "version" : "1.0.3" + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" } }, { @@ -204,8 +213,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-protobuf.git", "state" : { - "revision" : "86970144a0b86068c81ff48ee29b3f97cae0b879", - "version" : "1.36.0" + "revision" : "55d7a1cc5666b85c13464aea1c4b4a90feccb4c8", + "version" : "1.38.1" } }, { @@ -222,8 +231,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swift-server/swift-service-lifecycle.git", "state" : { - "revision" : "e7187309187695115033536e8fc9b2eb87fd956d", - "version" : "2.8.0" + "revision" : "9829955b385e5bb88128b73f1b8389e9b9c3191a", + "version" : "2.11.0" } }, { @@ -231,8 +240,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-system.git", "state" : { - "revision" : "7c6ad0fc39d0763e0b699210e4124afd5041c5df", - "version" : "1.6.4" + "revision" : "50688cacbd41d547e9eb9f7a213542340b7c442b", + "version" : "1.7.5" } }, { diff --git a/vminitd/Sources/VminitdCore/Logging.swift b/vminitd/Sources/VminitdCore/Logging.swift index 6c56da091..024f25b24 100644 --- a/vminitd/Sources/VminitdCore/Logging.swift +++ b/vminitd/Sources/VminitdCore/Logging.swift @@ -23,10 +23,14 @@ import Synchronization public struct LogLevelOption: ParsableArguments { @Option(name: .long, help: "Set the log level (trace, debug, info, notice, warning, error, critical)") - var logLevel: String = "info" + public var logLevel: String = "info" public init() {} + public init(logLevel: String) { + self.logLevel = logLevel + } + public func resolvedLogLevel() -> Logger.Level { switch logLevel.lowercased() { case "trace": @@ -83,15 +87,12 @@ private struct StderrLogHandler: LogHandler { set { metadata[key] = newValue } } - func log( - level: Logger.Level, message: Logger.Message, metadata: Logger.Metadata?, - source: String, file: String, function: String, line: UInt - ) { + func log(event: LogEvent) { var merged = self.metadata - metadata?.forEach { merged[$0] = $1 } + event.metadata?.forEach { merged[$0] = $1 } let metaStr = merged.isEmpty ? "" : " \(merged.map { "\($0): \($1)" }.sorted().joined(separator: ", "))" let ts = isoTimestamp() - let data = "\(ts) \(level) \(label):\(metaStr) \(message)\n".data(using: .utf8) ?? Data() + let data = "\(ts) \(event.level) \(label):\(metaStr) \(event.message)\n".data(using: .utf8) ?? Data() FileHandle.standardError.write(data) } diff --git a/vminitd/Sources/VminitdCore/Server+GRPC.swift b/vminitd/Sources/VminitdCore/Server+GRPC.swift index c87cd5213..dd07ef54f 100644 --- a/vminitd/Sources/VminitdCore/Server+GRPC.swift +++ b/vminitd/Sources/VminitdCore/Server+GRPC.swift @@ -31,6 +31,7 @@ import Logging import NIOCore import NIOPosix import SwiftProtobuf +import SystemPackage private let _setenv = Foundation.setenv @@ -40,14 +41,16 @@ private let _mount = Musl.mount private let _umount = Musl.umount2 private let _kill = Musl.kill private let _sync = Musl.sync -private let _stat: @Sendable (UnsafePointer, UnsafeMutablePointer) -> Int32 = stat +typealias _stat_struct = Musl.stat +private let _stat: @Sendable (UnsafePointer, UnsafeMutablePointer<_stat_struct>) -> Int32 = stat #elseif canImport(Glibc) import Glibc private let _mount = Glibc.mount private let _umount = Glibc.umount2 private let _kill = Glibc.kill private let _sync = Glibc.sync -private let _stat: @Sendable (UnsafePointer, UnsafeMutablePointer) -> Int32 = stat +typealias _stat_struct = Glibc.stat +private let _stat: @Sendable (UnsafePointer, UnsafeMutablePointer<_stat_struct>) -> Int32 = stat #endif extension ContainerizationError { @@ -378,11 +381,7 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ ) #if os(Linux) - #if canImport(Musl) - var s = Musl.stat() - #elseif canImport(Glibc) - var s = Glibc.stat() - #endif + var s = _stat_struct() let result = _stat(request.path, &s) if result == -1 { let error = swiftErrno("stat") @@ -664,7 +663,47 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ ) #if os(Linux) - try mnt.mount(createWithPerms: 0o755) + do { + try mnt.mount(createWithPerms: 0o755) + } catch { + // A hot-plugged virtio device (virtio-blk / virtio-fs) may not + // be enumerated by the guest yet when the host issues this + // mount immediately after vm.add-disk / vm.add-fs: cloud- + // hypervisor places the device on the PCI bus but the guest + // does not always auto-probe it. Force a PCI rescan and retry + // with a bounded wait. Scoped to hot-plug-candidate sources so + // an ordinary mount failure isn't delayed. + let hotplugCandidate = request.type == "virtiofs" || request.source.hasPrefix("/dev/vd") + guard hotplugCandidate else { throw error } + + if let rescan = FileHandle(forWritingAtPath: "/sys/bus/pci/rescan") { + defer { try? rescan.close() } + do { + try rescan.write(contentsOf: Data("1".utf8)) + log.info("mount: triggered PCI bus rescan for hot-plugged device") + } catch { + log.error("mount: PCI rescan write failed", metadata: ["error": "\(error)"]) + } + } else { + log.error("mount: cannot open /sys/bus/pci/rescan") + } + + var mounted = false + for attempt in 1...20 { // up to ~2s for the device to enumerate + try? await Task.sleep(for: .milliseconds(100)) + do { + try mnt.mount(createWithPerms: 0o755) + log.info("mount: succeeded after PCI rescan", metadata: ["attempt": "\(attempt)"]) + mounted = true + break + } catch { + continue + } + } + if !mounted { + throw error + } + } return .init() #else fatalError("mount not supported on platform") @@ -679,6 +718,102 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ } } + public func filesystemOperation(request: Com_Apple_Containerization_Sandbox_V3_FilesystemOperationRequest, context: GRPCCore.ServerContext) + async throws -> Com_Apple_Containerization_Sandbox_V3_FilesystemOperationResponse + { + let path = FilePath(request.path) + + log.debug( + "filesystemOperation", + metadata: [ + "operation": "\(String(describing: request.operation))", + "path": "\(path)", + ]) + + if !path.isAbsolute { + throw RPCError(code: .invalidArgument, message: "path must be absolute") + } + + var finfo = _stat_struct() + let rc = _stat(path.string, &finfo) + if rc != 0 { + let error = swiftErrno("stat") + throw RPCError(code: .notFound, message: "failed to stat path", cause: error) + } + + let fd = open(path.string, O_RDONLY | O_NOFOLLOW) + if fd < 0 { + if errno == ELOOP { + throw RPCError(code: .internalError, message: "path cannot be a symlink") + } + let error = swiftErrno("open") + throw RPCError(code: .internalError, message: "failed to open path", cause: error) + } + + defer { close(fd) } + + do { + switch request.operation { + case .freeze: + try freezeFilesystem(fd: fd) + case .thaw: + try thawFilesystem(fd: fd) + case .trim(let params): + switch params.schedule { + case .oneShot: + try trimFilesystem(fd: fd) + case .none: + throw RPCError(code: .invalidArgument, message: "trim schedule must be specified") + } + case .none: + throw RPCError(code: .invalidArgument, message: "invalid operation") + } + } catch { + log.error( + "filesystemOperation", + metadata: [ + "error": "\(error)" + ]) + throw RPCError(code: .internalError, message: "filesystemOperation", cause: error) + } + + return .init() + } + + private func freezeFilesystem(fd: Int32) throws { + let FIFREEZE: UInt = 0xC004_5877 + let rc: CInt = ioctl(fd, FIFREEZE, 0) + if rc != 0 { + let error = swiftErrno("ioctl(FIFREEZE)") + throw RPCError(code: .internalError, message: "freeze failed", cause: error) + } + } + + private func thawFilesystem(fd: Int32) throws { + let FITHAW: UInt = 0xC004_5878 + let rc: CInt = ioctl(fd, FITHAW, 0) + if rc != 0 { + let error = swiftErrno("ioctl(FITHAW)") + throw RPCError(code: .internalError, message: "thaw failed", cause: error) + } + } + + private struct fitrim_range { + var start: UInt64 + var len: UInt64 + var min_len: UInt64 + } + + private func trimFilesystem(fd: Int32) throws { + let FITRIM: UInt = 0xC018_5879 + var trange = fitrim_range(start: 0, len: UInt64.max, min_len: 0) + let rc: CInt = ioctl(fd, FITRIM, &trange) + if rc != 0 { + let error = swiftErrno("ioctl(FITRIM)") + throw RPCError(code: .internalError, message: "trim failed", cause: error) + } + } + public func umount(request: Com_Apple_Containerization_Sandbox_V3_UmountRequest, context: GRPCCore.ServerContext) async throws -> Com_Apple_Containerization_Sandbox_V3_UmountResponse { @@ -856,7 +991,7 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ if error is RPCError { throw error } - throw RPCError(code: .internalError, message: "createProcess", cause: error) + throw RPCError(code: .internalError, message: "createProcess: \(error)", cause: error) } } @@ -1192,6 +1327,7 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ metadata: [ "interface": "\(request.interface)", "ipv4Address": "\(request.ipv4Address)", + "ipv6Address": "\(request.hasIpv6Address ? request.ipv6Address : "")", ]) do { @@ -1199,6 +1335,30 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ let session = NetlinkSession(socket: socket, log: log) let ipv4Address = try CIDRv4(request.ipv4Address) try session.addressAdd(interface: request.interface, ipv4Address: ipv4Address) + if request.hasIpv6Address { + // Suppress SLAAC on this interface before adding the static + // address: the host would provide a static IPv6 config, this + // auto-derived IPv6 config would compete with the static one. + let confPath = URL(fileURLWithPath: "/proc/sys/net/ipv6/conf/\(request.interface)") + for key in ["accept_ra", "autoconf"] { + let setting = confPath.appendingPathComponent(key) + do { + let fh = try FileHandle(forWritingTo: setting) + defer { try? fh.close() } + try fh.write(contentsOf: Data("0".utf8)) + } catch { + log.warning( + "ipAddrAdd: failed to disable IPv6 auto-configuration", + metadata: [ + "path": "\(setting.path)", + "error": "\(error)", + ]) + } + } + + let ipv6Address = try CIDRv6(request.ipv6Address) + try session.addressAdd(interface: request.interface, ipv6Address: ipv6Address) + } } catch { log.error( "ipAddrAdd", @@ -1220,18 +1380,38 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ "interface": "\(request.interface)", "dstIpv4Addr": "\(request.dstIpv4Addr)", "srcIpv4Addr": "\(request.srcIpv4Addr)", + "dstIpv6Addr": "\(request.hasDstIpv6Addr ? request.dstIpv6Addr : "")", + "srcIpv6Addr": "\(request.hasSrcIpv6Addr ? request.srcIpv6Addr : "")", ]) + guard !request.dstIpv4Addr.isEmpty || request.hasDstIpv6Addr else { + throw RPCError( + code: .invalidArgument, + message: "ipRouteAddLink requires at least one of dstIpv4Addr or dstIpv6Addr" + ) + } + do { let socket = try DefaultNetlinkSocket() let session = NetlinkSession(socket: socket, log: log) - let dstIpv4Addr = try CIDRv4(request.dstIpv4Addr) - let srcIpv4Addr = request.srcIpv4Addr.isEmpty ? nil : try IPv4Address(request.srcIpv4Addr) - try session.routeAdd( - interface: request.interface, - dstIpv4Addr: dstIpv4Addr, - srcIpv4Addr: srcIpv4Addr - ) + if !request.dstIpv4Addr.isEmpty { + let dstIpv4Addr = try CIDRv4(request.dstIpv4Addr) + let srcIpv4Addr = request.srcIpv4Addr.isEmpty ? nil : try IPv4Address(request.srcIpv4Addr) + try session.routeAdd( + interface: request.interface, + dstIpv4Addr: dstIpv4Addr, + srcIpv4Addr: srcIpv4Addr + ) + } + if request.hasDstIpv6Addr { + let dstIpv6Addr = try CIDRv6(request.dstIpv6Addr) + let srcIpv6Addr = request.hasSrcIpv6Addr ? try IPv6Address(request.srcIpv6Addr) : nil + try session.routeAdd( + interface: request.interface, + dstIpv6Addr: dstIpv6Addr, + srcIpv6Addr: srcIpv6Addr + ) + } } catch { log.error( "ipRouteAddLink", @@ -1253,13 +1433,24 @@ extension Initd: Com_Apple_Containerization_Sandbox_V3_SandboxContext.SimpleServ metadata: [ "interface": "\(request.interface)", "ipv4Gateway": "\(request.ipv4Gateway)", + "ipv6Gateway": "\(request.hasIpv6Gateway ? request.ipv6Gateway : "")", ]) do { let socket = try DefaultNetlinkSocket() let session = NetlinkSession(socket: socket, log: log) - let ipv4Gateway = !request.ipv4Gateway.isEmpty ? try IPv4Address(request.ipv4Gateway) : nil - try session.routeAddDefault(interface: request.interface, ipv4Gateway: ipv4Gateway) + if !request.ipv4Gateway.isEmpty { + let ipv4Gateway = try IPv4Address(request.ipv4Gateway) + try session.routeAddDefault(interface: request.interface, ipv4Gateway: ipv4Gateway) + } else if !request.hasIpv6Gateway { + // No v4 gateway and no v6 either: install a v4 default route + // with no gateway (preserves pre-IPv6 behavior). + try session.routeAddDefault(interface: request.interface, ipv4Gateway: nil) + } + if request.hasIpv6Gateway { + let ipv6Gateway = try IPv6Address(request.ipv6Gateway) + try session.routeAddDefault(interface: request.interface, ipv6Gateway: ipv6Gateway) + } } catch { log.error( "ipRouteAddDefault", diff --git a/vminitd/Sources/vmexec/ExecCommand.swift b/vminitd/Sources/vmexec/ExecCommand.swift index b5a87f8e6..aaba76afe 100644 --- a/vminitd/Sources/vmexec/ExecCommand.swift +++ b/vminitd/Sources/vmexec/ExecCommand.swift @@ -69,9 +69,28 @@ struct ExecCommand: ParsableCommand { guard pidFd > 0 else { throw App.Errno(stage: "pidfd_open(\(parentPid))") } + // Join every namespace the container's init process could have been + // placed in by `RunCommand.setupNamespaces` (its `nsTypeToFlag` map). + // An exec that lands in a different namespace than the init process + // silently observes guest-root state instead of the container's — e.g. + // SysV IPC objects, POSIX message queues, and IPC-namespaced sysctls + // (`kernel.shm*`, `kernel.msg*`, `kernel.sem`, `fs.mqueue.*`) all read + // as the guest default without CLONE_NEWIPC here. + // + // setns(2) into a namespace the caller is already in is a no-op, so + // naming a flag the init process never unshared is harmless. Keeping + // this mask a superset of what `setupNamespaces` can unshare is what + // stops the two paths from drifting apart; add to it whenever that map + // grows. + // + // CLONE_NEWUSER is intentionally absent: nothing puts a container in a + // user namespace today, and entering one carries extra constraints + // (single-threaded caller, capabilities re-derived against the target + // namespace) that deserve their own change rather than riding along + // here. try Self.enterNS( pidFd: pidFd, - nsType: CLONE_NEWCGROUP | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS + nsType: CLONE_NEWCGROUP | CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS | CLONE_NEWIPC ) let processID = fork() diff --git a/vminitd/Sources/vmexec/RunCommand.swift b/vminitd/Sources/vmexec/RunCommand.swift index 4c9607fb5..e20b86ad5 100644 --- a/vminitd/Sources/vmexec/RunCommand.swift +++ b/vminitd/Sources/vmexec/RunCommand.swift @@ -53,7 +53,10 @@ struct RunCommand: ParsableCommand { } } - private func childRootSetup(rootfs: ContainerizationOCI.Root, mounts: [ContainerizationOCI.Mount]) throws { + private func childRootSetup( + rootfs: ContainerizationOCI.Root, + mounts: [ContainerizationOCI.Mount] + ) throws { // setup rootfs try prepareRoot(rootfs: rootfs.path) try mountRootfs(rootfs: rootfs.path, mounts: mounts) @@ -69,6 +72,71 @@ struct RunCommand: ParsableCommand { try reOpenDevNull() } + /// Mask paths per OCI `linux.maskedPaths`. Files (and any non-directory) + /// get `/dev/null` bind-mounted on top; directories get an empty read-only + /// tmpfs. Missing paths are skipped silently — matches runc's `maskPath`. + private func applyMaskedPaths(_ paths: [String]) throws { + for path in paths { + var st = stat() + if stat(path, &st) != 0 { + if errno == ENOENT { + continue + } + throw App.Errno(stage: "stat(\(path)) for mask") + } + + if (st.st_mode & S_IFMT) == S_IFDIR { + // Match runc: mask directories with a read-only tmpfs. MS_RDONLY + // is what actually prevents writes into the masked dir; a + // `size=0k` option would be a no-op (the kernel treats tmpfs + // size=0 as "no limit", not an empty filesystem). + guard mount("tmpfs", path, "tmpfs", UInt(MS_RDONLY | MS_NOSUID | MS_NODEV | MS_NOEXEC), nil) == 0 else { + throw App.Errno(stage: "mount(tmpfs mask \(path))") + } + } else { + guard mount("/dev/null", path, "bind", UInt(MS_BIND), nil) == 0 else { + throw App.Errno(stage: "mount(bind /dev/null -> \(path))") + } + } + } + } + + /// Make paths read-only per OCI `linux.readonlyPaths` by bind-mounting + /// each onto itself and remounting with `MS_RDONLY`. Missing paths are + /// skipped silently — matches runc's `readonlyPath`. The statfs fallback + /// mirrors `remountRootfsReadOnly()` for filesystems whose existing flags + /// (e.g. nosuid, nodev) must be preserved on the remount. + private func applyReadonlyPaths(_ paths: [String]) throws { + for path in paths { + var st = stat() + if stat(path, &st) != 0 { + if errno == ENOENT { + continue + } + throw App.Errno(stage: "stat(\(path)) for readonly") + } + + guard mount(path, path, "", UInt(MS_BIND | MS_REC), nil) == 0 else { + throw App.Errno(stage: "mount(bind \(path))") + } + + var flags = UInt(MS_BIND | MS_REMOUNT | MS_RDONLY) + if mount("", path, "", flags, "") == 0 { + continue + } + + var s = statfs() + guard statfs(path, &s) == 0 else { + throw App.Errno(stage: "statfs(\(path))") + } + flags |= UInt(s.f_flags) + + guard mount("", path, "", flags, "") == 0 else { + throw App.Errno(stage: "mount remount-ro \(path)") + } + } + } + private func remountRootfsReadOnly() throws { var flags = UInt(MS_BIND | MS_REMOUNT | MS_RDONLY) @@ -181,6 +249,14 @@ struct RunCommand: ParsableCommand { } } + // Apply OCI maskedPaths/readonlyPaths AFTER sysctls (writes to + // /proc/sys/* would otherwise fail once /proc/sys is remounted ro) + // and BEFORE the user/capability change (mount() requires + // CAP_SYS_ADMIN, which we still have here as root). Mask runs first + // so a path appearing in both lists is hidden, not just locked. + try self.applyMaskedPaths(spec.linux?.maskedPaths ?? []) + try self.applyReadonlyPaths(spec.linux?.readonlyPaths ?? []) + // Apply O_CLOEXEC to all file descriptors except stdio. // This ensures that all unwanted fds we may have accidentally // inherited are marked close-on-exec so they stay out of the diff --git a/vminitd/Sources/vmexec/vmexec.swift b/vminitd/Sources/vmexec/vmexec.swift index 8141be9df..956680cb8 100644 --- a/vminitd/Sources/vmexec/vmexec.swift +++ b/vminitd/Sources/vmexec/vmexec.swift @@ -15,9 +15,9 @@ //===----------------------------------------------------------------------===// /// NOTE: This binary implements a very small subset of the OCI runtime spec, mostly just -/// the process configurations. Mounts are somewhat functional, but masked and read only paths -/// aren't checked today. Today the namespaces are also ignored, and we always spawn a new pid -/// and mount namespace. +/// the process configurations. Mounts, masked paths, and read-only paths are enforced. +/// The `network` namespace is currently ignored and we always spawn a new pid and mount +/// namespace. import ArgumentParser import ContainerizationError diff --git a/vminitd/Sources/vminitd/Application.swift b/vminitd/Sources/vminitd/Application.swift index 210090627..866f6b330 100644 --- a/vminitd/Sources/vminitd/Application.swift +++ b/vminitd/Sources/vminitd/Application.swift @@ -51,7 +51,9 @@ struct Application: AsyncParsableCommand { // so we do this synchronously before any async code runs. try mountProc() - var command = try parseAsRoot() + // When running as PID 1 with a Musl-static build, Swift's runtime + // captures argc/argv as empty. Recover argv from /proc/self/cmdline. + var command = try parseAsRoot(Self.procSelfArgv()) if let asyncCommand = command as? AsyncParsableCommand { nonisolated(unsafe) var unsafeCommand = asyncCommand try await unsafeCommand.run() @@ -85,6 +87,17 @@ struct Application: AsyncParsableCommand { try mnt.mount(createWithPerms: 0o755) } + // /proc/self/cmdline holds argv as NUL-separated bytes. Read it after + // mountProc(). Returns argv minus argv[0], suitable for parseAsRoot(_:). + private static func procSelfArgv() -> [String] { + guard let data = try? Data(contentsOf: URL(fileURLWithPath: "/proc/self/cmdline")) else { + return [] + } + let parts = data.split(separator: 0, omittingEmptySubsequences: true) + .map { String(decoding: $0, as: UTF8.self) } + return Array(parts.dropFirst()) + } + private static func isProcMounted() -> Bool { guard let data = try? String(contentsOfFile: "/proc/mounts", encoding: .utf8) else { return false