chore: import upstream snapshot with attribution
Build containerization / Verify commit signatures (push) Has been skipped
Build containerization / containerization (push) Successful in 0s
Linux build / Linux compile check (push) Has been cancelled
Linux build / Determine Swift version (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:25:30 +08:00
commit 680845cb1c
445 changed files with 103779 additions and 0 deletions
+71
View File
@@ -0,0 +1,71 @@
name: Bug report
description: File a bug report.
title: "[Bug]: "
type: "Bug"
body:
- type: markdown
attributes:
value: |
Thanks for taking the time to fill out this bug report!
- type: checkboxes
id: prereqs
attributes:
label: I have done the following
description: Select that you have completed the following prerequisites.
options:
- label: I have searched the existing issues
required: true
- label: If possible, I've reproduced the issue using the 'main' branch of this project
required: false
- type: textarea
id: reproduce
attributes:
label: Steps to reproduce
description: Explain how to reproduce the incorrect behavior.
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: Current behavior
description: A concise description of what you're experiencing.
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected behavior
description: A concise description of what you expected to happen.
validations:
required: true
- type: textarea
attributes:
label: Environment
description: |
Examples:
- **OS**: macOS 26.0 (25A354)
- **Xcode**: Version 26.0 (17A324)
- **Swift**: Apple Swift version 6.2 (swift-6.2-RELEASE)
value: |
- OS:
- Xcode:
- Swift:
render: markdown
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant log output
description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks.
value: |
N/A
render: shell
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/apple/.github/blob/main/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow this project's Code of Conduct
required: true
+24
View File
@@ -0,0 +1,24 @@
name: Feature or enhancement request
description: File a request for a feature or enhancement
title: "[Request]: "
type: "Feature"
body:
- type: markdown
attributes:
value: |
Thanks for contributing to the containerization project!
- type: textarea
id: request
attributes:
label: Feature or enhancement request details
description: Describe your proposed feature or enhancement. Code samples that show what's missing, or what new capabilities will be possible, are very helpful! Provide links to existing issues or external references/discussions, if appropriate.
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/apple/.github/blob/main/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow this project's Code of Conduct
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Containerization community support
url: https://github.com/apple/container/discussions
about: Please ask and answer questions here.
+77
View File
@@ -0,0 +1,77 @@
name: Build and publish containerization test images
permissions:
contents: read
on:
workflow_dispatch:
inputs:
publish:
type: boolean
description: "Publish the built image"
default: false
version:
type: string
description: "Version of the image to create"
default: "test"
image:
type: choice
description: Test image to build
options:
- dockermanifestimage
- emptyimage
default: 'dockermanifestimage'
useBuildx:
type: boolean
description: "Use docker buildx to build the image"
default: false
jobs:
image:
name: Build test images
timeout-minutes: 30
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- name: Check branch
env:
GH_REF: ${{ github.ref }}
PUBLISH: ${{ inputs.publish }}
run: |
if [[ "${GH_REF}" != "refs/heads/main" ]] && [[ "${GH_REF}" != refs/heads/release* ]] && [[ "${PUBLISH}" == "true" ]]; then
echo "❌ Cannot publish an image if we are not on main or a release branch."
exit 1
fi
- name: Check inputs
env:
IMAGE: ${{ inputs.image }}
USE_BUILDX: ${{ inputs.useBuildx }}
run: |
if [[ "${IMAGE}" == "dockermanifestimage" ]] && [[ "${USE_BUILDX}" == "true" ]]; then
echo "❌ dockermanifestimage cannot be built with buildx"
exit 1
fi
if [[ "${IMAGE}" == "emptyimage" ]] && [[ "${USE_BUILDX}" != "true" ]]; then
echo "❌ emptyimage should be built with buildx"
exit 1
fi
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- name: Login to GitHub Container Registry
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@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Build dockerfile and push image
uses: docker/build-push-action@10e90e3645eae34f1e60eeb005ba3a3d33f178e8 # v6
with:
push: ${{ inputs.publish }}
context: Tests/TestImages/${{ inputs.image }}
tags: ghcr.io/apple/containerization/${{ inputs.image }}:${{ inputs.version }}
@@ -0,0 +1,130 @@
name: Build containerization template
permissions:
contents: read
on:
workflow_call:
inputs:
release:
type: boolean
description: "Create a release"
default: false
version:
type: string
description: Version of containerization
default: test
jobs:
buildAndTest:
name: Build and Test repo
if: github.repository == 'apple/containerization'
timeout-minutes: 60
runs-on: [self-hosted, macos, tahoe, ARM64]
permissions:
contents: read
packages: write
env:
DEVELOPER_DIR: "/Applications/Xcode_swift_6.3.app/Contents/Developer"
steps:
- name: Checkout repository
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
with:
fetch-depth: 0
- name: Activate Swiftly
run: |
source ~/.swiftly/env.sh
cat ~/.swiftly/env.sh
- name: Check formatting
run: |
./scripts/install-hawkeye.sh
make fmt
git diff
if ! git diff --quiet ; then echo the following files require formatting or license headers: ; git diff --name-only ; false ; fi
- name: Check protobufs
run: |
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, examples, and docs
run: |
make clean containerization examples docs
tar cfz _site.tgz _site
env:
BUILD_CONFIGURATION: ${{ inputs.release && 'release' || 'debug' }}
- name: Make vminitd image
run: |
source ~/.swiftly/env.sh
make -C vminitd swift linux-sdk
make init
env:
BUILD_CONFIGURATION: ${{ inputs.release && 'release' || 'debug' }}
- name: Test containerization
run: |
make fetch-default-kernel
make test integration
env:
REGISTRY_TOKEN: ${{ github.token }}
REGISTRY_USERNAME: ${{ github.actor }}
- name: Push vminitd image
if: ${{ inputs.release }}
env:
REGISTRY_TOKEN: ${{ github.token }}
REGISTRY_USERNAME: ${{ github.actor }}
REGISTRY_HOST: ghcr.io
VERSION: ${{ inputs.version }}
run: |
bin/cctl images tag vminit:latest "ghcr.io/apple/containerization/vminit:${VERSION}"
bin/cctl images push "ghcr.io/apple/containerization/vminit:${VERSION}"
- name: Create image tar
if: ${{ !inputs.release }}
run: |
bin/cctl images save vminit:latest -o vminit.tar
- name: Save vminit artifact
if: ${{ !inputs.release }}
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: vminit
path: vminit.tar
- name: Save documentation artifact
uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6
with:
name: api-docs
path: "./_site.tgz"
retention-days: 14
uploadPages:
# Separate upload step required because upload-pages-artifact needs
# gtar which is not on the macOS runner.
name: Upload artifact for GitHub Pages
needs: buildAndTest
timeout-minutes: 5
runs-on: ubuntu-latest
steps:
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
- name: Download a single artifact
uses: actions/download-artifact@37930b1c2abaa49bbe596cd826c3c89aef350131 # v7
with:
name: api-docs
- name: Add API docs to documentation
run: |
tar xfz _site.tgz
- name: Upload Artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
path: "./_site"
@@ -0,0 +1,53 @@
name: Build containerization
permissions:
contents: read
on:
pull_request:
types: [opened, reopened, synchronize]
push:
branches:
- main
- release/*
jobs:
verify-signatures:
name: Verify commit signatures
runs-on: ubuntu-latest
if: github.event_name == 'pull_request'
steps:
- name: Check all commits are signed
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
commits=$(gh api "repos/${REPO}/pulls/${PR_NUMBER}/commits" --paginate)
unsigned_commits=""
while IFS='|' read -r sha author verified; do
if [ "$verified" != "true" ]; then
unsigned_commits="$unsigned_commits - $sha by $author\n"
fi
done < <(echo "$commits" | jq -r '.[] | "\(.sha)|\(.commit.author.name)|\(.commit.verification.verified)"')
if [ -n "$unsigned_commits" ]; then
echo "::error::The following commits are not signed:"
echo -e "$unsigned_commits"
echo ""
echo "Please sign your commits. See:"
echo " - https://github.com/apple/containerization/blob/main/CONTRIBUTING.md#pull-requests"
echo " - https://docs.github.com/en/authentication/managing-commit-signature-verification/signing-commits"
exit 1
fi
echo "All commits are signed!"
containerization:
permissions:
contents: read
packages: write
pages: write
uses: ./.github/workflows/containerization-build-template.yml
secrets: inherit
+46
View File
@@ -0,0 +1,46 @@
# Manual workflow for releasing docs ad-hoc. Workflow can only be run for main or release branches.
# Workflow does NOT publish a release of containerization.
name: Deploy application website
permissions:
contents: read
on:
workflow_dispatch:
jobs:
checkBranch:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags') || startsWith(github.ref, 'refs/heads/release')
steps:
- name: Branch validation
env:
REF_NAME: ${{ github.ref_name }}
run: echo "Branch ${REF_NAME} is allowed"
buildSite:
name: Build application website
needs: checkBranch
uses: ./.github/workflows/containerization-build-template.yml
secrets: inherit
permissions:
contents: read
packages: write
pages: write
deployDocs:
runs-on: ubuntu-latest
needs: [checkBranch, buildSite]
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
+60
View File
@@ -0,0 +1,60 @@
name: Linux build
permissions:
contents: read
on:
pull_request:
types: [opened, reopened, synchronize]
push:
branches:
- main
- release/*
jobs:
swift-version:
name: Determine Swift version
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:
name: Linux compile check
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 libarchive-dev libbz2-dev liblzma-dev libssl-dev
- name: Build containerization
run: make containerization
- name: Build vminitd (glibc)
run: make -C vminitd SWIFT_CONFIGURATION="--disable-automatic-resolution -Xswiftc -warnings-as-errors"
- name: Install Static Linux SDK
run: make -C vminitd linux-sdk
- name: Build vminitd (musl)
run: make -C vminitd
- name: Run unit tests
run: swift test --disable-automatic-resolution -Xswiftc -warnings-as-errors
+57
View File
@@ -0,0 +1,57 @@
name: Release containerization
permissions:
contents: read
on:
push:
tags:
- "[0-9]+\\.[0-9]+\\.[0-9]+"
jobs:
containerization:
uses: ./.github/workflows/containerization-build-template.yml
with:
release: true
version: ${{ github.ref_name }}
secrets: inherit
permissions:
contents: read
packages: write
pages: write
deployDocs:
if: startsWith(github.ref, 'refs/tags/')
runs-on: ubuntu-latest
needs: containerization
permissions:
contents: read
pages: write
id-token: write
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
release:
if: startsWith(github.ref, 'refs/tags/')
name: Publish release
timeout-minutes: 30
needs: containerization
runs-on: ubuntu-latest
permissions:
contents: write
packages: read
steps:
- name: Create release
uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2
with:
token: ${{ github.token }}
name: ${{ github.ref_name }}-prerelease
draft: true
make_latest: false
prerelease: true
fail_on_unmatched_files: true
+28
View File
@@ -0,0 +1,28 @@
.DS_Store
bin
libexec
.build
.local
xcuserdata/
DerivedData/
.swiftpm/
.netrc
workdir/
installer/
.venv/
test_results/
*.pid
*.log
*.zip
*.o
*.ext4
*.pkg
*.swp
*.tar.gz
*.tar.xz
vmlinux*
# API docs for local preview only.
_site/
_serve/
kernel/vmlinuz-x86_64
+5
View File
@@ -0,0 +1,5 @@
version: 1
builder:
configs:
- documentation_targets: [Containerization, ContainerizationEXT4, ContainerizationOS, ContainerizationOCI, ContainerizationNetlink, ContainerizationIO, ContainerizationExtras, ContainerizationArchive, SendableProperty]
swift_version: '6.2'
+68
View File
@@ -0,0 +1,68 @@
{
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"indentation" : {
"spaces" : 4
},
"indentConditionalCompilationBlocks" : false,
"indentSwitchCaseLabels" : false,
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : false,
"lineBreakBeforeEachGenericRequirement" : false,
"lineLength" : 180,
"maximumBlankLines" : 1,
"multiElementCollectionTrailingCommas" : true,
"noAssignmentInExpressions" : {
"allowedFunctions" : [
"XCTAssertNoThrow"
]
},
"prioritizeKeepingFunctionOutputTogether" : false,
"respectsExistingLineBreaks" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLowerCamelCase" : true,
"AmbiguousTrailingClosureOverload" : false,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : true,
"FileScopedDeclarationPrivacy" : true,
"FullyIndirectEnum" : true,
"GroupNumericLiterals" : true,
"IdentifiersMustBeASCII" : true,
"NeverForceUnwrap" : true,
"NeverUseForceTry" : true,
"NeverUseImplicitlyUnwrappedOptionals" : true,
"NoAccessLevelOnExtensionDeclaration" : true,
"NoAssignmentInExpressions" : true,
"NoBlockComments" : false,
"NoCasesWithOnlyFallthrough" : true,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : true,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoPlaygroundLiterals" : true,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : true,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : true,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : true,
"ReturnVoidInsteadOfEmptyTuple" : true,
"TypeNamesShouldBeCapitalized" : true,
"UseEarlyExits" : true,
"UseLetInEveryBoundCaseVariable" : true,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : true,
"UseSynthesizedInitializer" : true,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : true
},
"spacesAroundRangeFormationOperators" : false,
"tabWidth" : 2,
"version" : 1
}
+68
View File
@@ -0,0 +1,68 @@
{
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"indentation" : {
"spaces" : 4
},
"indentConditionalCompilationBlocks" : false,
"indentSwitchCaseLabels" : false,
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : false,
"lineBreakBeforeEachGenericRequirement" : false,
"lineLength" : 180,
"maximumBlankLines" : 1,
"multiElementCollectionTrailingCommas" : true,
"noAssignmentInExpressions" : {
"allowedFunctions" : [
"XCTAssertNoThrow"
]
},
"prioritizeKeepingFunctionOutputTogether" : false,
"respectsExistingLineBreaks" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLowerCamelCase" : false,
"AmbiguousTrailingClosureOverload" : false,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : false,
"FileScopedDeclarationPrivacy" : false,
"FullyIndirectEnum" : false,
"GroupNumericLiterals" : false,
"IdentifiersMustBeASCII" : false,
"NeverForceUnwrap" : false,
"NeverUseForceTry" : false,
"NeverUseImplicitlyUnwrappedOptionals" : false,
"NoAccessLevelOnExtensionDeclaration" : false,
"NoAssignmentInExpressions" : false,
"NoBlockComments" : false,
"NoCasesWithOnlyFallthrough" : false,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : false,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoPlaygroundLiterals" : false,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : false,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : false,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : false,
"ReturnVoidInsteadOfEmptyTuple" : false,
"TypeNamesShouldBeCapitalized" : false,
"UseEarlyExits" : false,
"UseLetInEveryBoundCaseVariable" : false,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : true,
"UseSynthesizedInitializer" : false,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : false
},
"spacesAroundRangeFormationOperators" : false,
"tabWidth" : 2,
"version" : 1
}
+1
View File
@@ -0,0 +1 @@
6.3.0
+91
View File
@@ -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, cross-compiled with the Static Linux SDK).
- `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. By default uses `LIBC=musl` via the Static Linux SDK; `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-<sha>.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 cross-prep` — installs Swiftly, the pinned Swift toolchain (see `.swift-version`), and the Static Linux SDK. Run once before the first build.
`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. Swift toolchain version is pinned in `.swift-version` (currently `6.3.0`) and installed via Swiftly during `make cross-prep`. Older macOS releases are not supported.
+116
View File
@@ -0,0 +1,116 @@
# 🌈 📦️ Welcome to the Containerization community! 📦️ 🌈
Contributions to Containerization are welcomed and encouraged.
## Index
- [How you can help](#how-you-can-help)
- [Submitting issues and pull requests](#submitting-issues-and-pull-requests)
- [New to open source?](#new-to-open-source)
- [AI contribution guidelines](#ai-contribution-guidelines)
- [Code of conduct](#code-of-conduct)
## How you can help
We would love your contributions in the form of:
🐛 Bug fixes\
⚡️ Performance improvements\
✨ API additions or enhancements\
📝 Documentation\
🧑‍💻 Project advocacy: blogs, conference talks, and more
Anything else that could enhance the project!
## Submitting issues and pull requests
### Issues
To file a bug or feature request, use [GitHub issues](https://github.com/apple/containerization/issues/new).
🚧 For unexpected behavior or usability limitations, detailed instructions on how to reproduce the issue are appreciated. This will greatly help the priority setting and speed at which maintainers can get to your issue.
### Pull requests
We require all commits be signed with any of GitHub's supported methods, such as GPG or SSH. Information on how to set this up can be found on [GitHub's docs](https://docs.github.com/en/authentication/managing-commit-signature-verification/about-commit-signature-verification#about-commit-signature-verification).
To make a pull request, use [GitHub](https://github.com/apple/containerization/compare). Please give the team a few days to review but it's ok to check in on occasion. We appreciate your contribution!
> [!IMPORTANT]
> If you plan to make substantial changes or add new features, we encourage you to first discuss them with the wider containerization developer community.
> You can do this by filing a [GitHub issue](https://github.com/apple/containerization/issues/new).
> This will save time and increases the chance of your pull request being accepted.
We use a "squash and merge" strategy to keep our `main` branch history clean and easy to follow. When your pull request
is merged, all of your commits will be combined into a single commit.
With the "squash and merge" strategy, the *title* and *body* of your pull request is extremely important. It will become the commit message
for the squashed commit. Think of it as the single, definitive description of your contribution.
Before merging, we'll review the pull request title and body to ensure it:
* Clearly and concisely describes the changes.
* Uses the imperative mood (for example, "Add feature," "Fix bug").
* Provides enough context for future developers to understand the purpose of the change.
The pull request description should be concise and accurately describe the *what* and *why* of your changes.
#### .gitignore contributions
We do not currently accept contributions to add editor specific additions to the root .gitignore. We urge contributors to make a global .gitignore file with their rulesets they may want to add instead. A global .gitignore file can be set like so:
```bash
git config --global core.excludesfile ~/.gitignore
```
#### Formatting contributions
Make sure your contributions are consistent with the rest of the project's formatting. You can do this using our Makefile:
```bash
make fmt
```
#### Applying license header to new files
If you submit a contribution that adds a new file, please add the license header. You can do this using our Makefile:
```bash
make update-licenses
```
## New to open source?
### How do I pick something to work on?
Take a look at the `good first issue` label in the [containerization](https://github.com/apple/containerization/contribute) or [container](https://github.com/apple/container/contribute) project.
Before you start working on an issue:
* Check the comments, assignees, and any references to pull requests — make sure nobody else is actively working on it, or awaiting help or review.
* If someone is assigned to the issue or volunteered to work on it, and there are no signs of progress or activity over at least the past month, don't hesitate to check in with them
* Leave a comment that you have started working on it.
### Getting help
Don't be afraid to ask for help! When asking for help, provide as much information as possible, while highlighting anything you think may be important. Refer to the [MAINTAINERS.txt](MAINTAINERS.txt) file for the appropriate people to ping.
### I didn't get a response from someone. What should I do?
It's possible that you ask someone a question in an issue/pull request and you don't get a response as quickly as you'd like. If you don't get a response within a week, it's okay to politely ping them using an `@` mention. If you don't get a response for 2-3 weeks in a row, please ping someone else.
### I can't finish the contribution I started
Sometimes an issue ends up bigger, harder, or more time-consuming than expected — **and thats completely fine.** Be sure to comment on the issue saying youre stepping away, so that someone else is able to pick it up.
## AI contribution guidelines
We welcome thoughtful use of AI tools in your contributions to this repository. We ask that you adhere to these rules in order to preserve the project's integrity, clarity, and quality, and to respect maintainer bandwidth:
* You should be able to explain and justify every line of code or documentation that was generated or assisted by AI. Your submission should reflect your own understanding and intent.
* Use AI to augment, not totally replace, your reasoning or familiarity, especially for non-trivial parts of the system.
* Avoid dumping AI-generated walls of text that you cannot explain. Low-effort, unexplained submissions will be deprioritized to protect maintainer bandwidth.
AI tools should be used to **enhance, not replace** the human elements that make OSS special: learning, collaboration, and community growth.
## Code of conduct
To clarify what is expected of our contributors and community members, the Containerization team has adopted the code of conduct defined by the Contributor Covenant. This document is used across many open source communities and articulates our values well. For more detail, please read the [Code of Conduct](https://github.com/apple/.github/blob/main/CODE_OF_CONDUCT.md "Code of Conduct").
+202
View File
@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
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
http://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.
+26
View File
@@ -0,0 +1,26 @@
This file contains a list of maintainers and past maintainers who have made meaningful changes to this repository.
### Maintainers
Aditya Ramani (adityaramani)
AJ Emory (ajemory)
Danny Canter (dcantah)
Dmitry Kovba (dkovba)
Eric Ernst (egernst)
John Logan (jglogan)
Kathryn Baldauf (katiewasnothere)
Madhu Venugopal (mavenugo)
Michael Crosby (crosbymichael)
Raj Aryan Singh (realrajaryan)
Sidhartha Mani (wlan0)
Yibo Zhuang (yibozhuang)
### Emeritus maintainers
Agam Dua (agamdua)
Evan Hazlett (ehazlett)
Gilbert Song (gilbert88)
Hugh Bussell (hughbussell)
Tanweer Noor (tanweernoor)
Ximena Perez Diaz (ximenanperez)
+448
View File
@@ -0,0 +1,448 @@
# Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# 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
# 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
ifeq ($(UNAME_S),Darwin)
SWIFT ?= /usr/bin/swift
else
SWIFT ?= swift
endif
ROOT_DIR := $(shell git rev-parse --show-toplevel)
BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path)
COV_DATA_DIR = $(shell $(SWIFT) test --show-coverage-path | xargs dirname)
COV_REPORT_FILE = $(ROOT_DIR)/code-coverage-report
# Variables for libarchive integration
LIBARCHIVE_UPSTREAM_REPO := https://github.com/libarchive/libarchive
LIBARCHIVE_UPSTREAM_VERSION := v3.7.7
LIBARCHIVE_LOCAL_DIR := workdir/libarchive
KATA_BINARY_PACKAGE := https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz
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)
# 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-<arch>` 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"; \
exit 1; \
fi
@if ! container image list -q 2>/dev/null | grep -q "$(LINUX_DEV_IMAGE)"; then \
echo "Building Linux dev container image..."; \
$(MAKE) linux-image; \
fi
@mkdir -p $(ROOT_DIR)/.local/integration-cache
@container run --rm $(2) --memory 16gb --cpus 8 --virtualization \
-v $(ROOT_DIR):/workspace \
-v $(ROOT_DIR)/.local/integration-cache:/root/.local/share/com.apple.containerization \
-w /workspace $(LINUX_DEV_IMAGE) \
bash -c "$(1)"
endef
include Protobuf.Makefile
.DEFAULT_GOAL := all
.PHONY: deps
deps:
ifeq ($(UNAME_S),Linux)
sudo apt-get install -y libarchive-dev libbz2-dev liblzma-dev libssl-dev
else
@echo "No additional dependencies required on $(UNAME_S)"
endif
ifeq ($(UNAME_S),Darwin)
.PHONY: linux-image
linux-image:
container build \
--progress plain \
-f images/linux-dev/Dockerfile \
--build-arg SWIFT_VERSION=$(SWIFT_VERSION) \
--build-arg SWIFT_SDK_URL=$(SWIFT_SDK_URL) \
--build-arg SWIFT_SDK_CHECKSUM=$(SWIFT_SDK_CHECKSUM) \
-t $(LINUX_DEV_IMAGE) \
.
.PHONY: linux-build
linux-build: LIBC ?= musl
linux-build:
ifeq ($(LIBC),all)
$(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) && make init)
endif
.PHONY: linux-test
linux-test:
$(call linux_run,swift test $(SWIFT_CONFIGURATION))
.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,--kernel $(LINUX_INTEGRATION_KERNEL))
# 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-<sha>.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
all: containerization
all: init
.PHONY: release
release: BUILD_CONFIGURATION = release
release: all
.PHONY: containerization
containerization:
@echo Building containerization binaries...
@$(SWIFT) --version
@$(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION)
@echo Copying containerization binaries...
@mkdir -p bin
@install "$(BUILD_BIN_DIR)/cctl" ./bin/
@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
.PHONY: init
init: containerization vminitd
@echo Creating init.ext4...
@rm -f bin/init.rootfs.tar.gz bin/init.block bin/initfs.ext4
@./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 \
bin/init.rootfs.tar.gz
.PHONY: cross-prep
cross-prep:
@"$(MAKE)" -C vminitd cross-prep
.PHONY: vminitd
vminitd:
@mkdir -p ./bin
@"$(MAKE)" -C vminitd BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) WARNINGS_AS_ERRORS=$(WARNINGS_AS_ERRORS)
.PHONY: update-libarchive-source
update-libarchive-source:
@echo Updating the libarchive source files...
@git clone $(LIBARCHIVE_UPSTREAM_REPO) --depth 1 --branch $(LIBARCHIVE_UPSTREAM_VERSION) "$(LIBARCHIVE_LOCAL_DIR)"
@cp "$(LIBARCHIVE_LOCAL_DIR)/libarchive/archive_entry.h" Sources/ContainerizationArchive/CArchive/include
@cp "$(LIBARCHIVE_LOCAL_DIR)/libarchive/archive.h" Sources/ContainerizationArchive/CArchive/include
@cp "$(LIBARCHIVE_LOCAL_DIR)/COPYING" Sources/ContainerizationArchive/CArchive/COPYING
@rm -rf "$(LIBARCHIVE_LOCAL_DIR)"
.PHONY: test
test:
@echo Testing all test targets...
@$(SWIFT) test --enable-code-coverage $(SWIFT_CONFIGURATION)
.PHONY: coverage
coverage: test
@echo Generating code coverage report...
@xcrun llvm-cov show --compilation-dir=`pwd` \
-instr-profile=$(COV_DATA_DIR)/default.profdata \
--ignore-filename-regex=".build/" \
--ignore-filename-regex=".pb.swift" \
--ignore-filename-regex=".proto" \
--ignore-filename-regex=".grpc.swift" \
$(BUILD_BIN_DIR)/containerizationPackageTests.xctest/Contents/MacOS/containerizationPackageTests > $(COV_REPORT_FILE)
@echo Code coverage report generated: $(COV_REPORT_FILE)
.PHONY: integration
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:
@mkdir -p .local/ bin/
ifeq (,$(wildcard .local/kata.tar.gz))
@curl -SsL -o .local/kata.tar.gz ${KATA_BINARY_PACKAGE}
endif
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-$(KERNEL_ARCH)
endif
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
.PHONY: fmt
fmt: swift-fmt update-licenses
.PHONY: swift-fmt
SWIFT_SRC = $(shell find . -type f -name '*.swift' -not -path "*/.*" -not -path "*.pb.swift" -not -path "*.grpc.swift" -not -path "*/checkouts/*")
swift-fmt:
@echo Applying the standard code formatting...
@$(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)
.PHONY: update-licenses
update-licenses:
@echo Updating license headers...
@./scripts/ensure-hawkeye-exists.sh
@.local/bin/hawkeye format --fail-if-unknown --fail-if-updated false
.PHONY: check-licenses
check-licenses:
@echo Checking license headers existence in source files...
@./scripts/ensure-hawkeye-exists.sh
@.local/bin/hawkeye check --fail-if-unknown
.PHONY: pre-commit
pre-commit:
cp Scripts/pre-commit.fmt .git/hooks
touch .git/hooks/pre-commit
cat .git/hooks/pre-commit | grep -v 'hooks/pre-commit\.fmt' > /tmp/pre-commit.new || true
echo 'PRECOMMIT_NOFMT=$${PRECOMMIT_NOFMT} $$(git rev-parse --show-toplevel)/.git/hooks/pre-commit.fmt' >> /tmp/pre-commit.new
mv /tmp/pre-commit.new .git/hooks/pre-commit
chmod +x .git/hooks/pre-commit
.PHONY: serve-docs
serve-docs:
@echo 'to browse: open http://127.0.0.1:8000/containerization/documentation/'
@rm -rf _serve
@mkdir -p _serve
@cp -a _site _serve/containerization
@python3 -m http.server --bind 127.0.0.1 --directory ./_serve
.PHONY: docs
docs:
@echo Updating API documentation...
@rm -rf _site
@scripts/make-docs.sh _site containerization
.PHONY: cleancontent
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...
@rm -rf bin/
@rm -rf _site/
@rm -rf _serve/
@rm -f $(COV_REPORT_FILE)
@$(SWIFT) package clean
@"$(MAKE)" -C vminitd clean
+249
View File
@@ -0,0 +1,249 @@
{
"originHash" : "2b73182b4d0f81b61ee24d8e615d350d28301517488d944fafddde15bc854cdc",
"pins" : [
{
"identity" : "async-http-client",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/async-http-client.git",
"state" : {
"revision" : "60235983163d040f343a489f7e2e77c1918a8bd9",
"version" : "1.26.1"
}
},
{
"identity" : "grpc-swift-2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/grpc/grpc-swift-2.git",
"state" : {
"revision" : "f28854bc760a116e053fdfc4a48a9428c34625c0",
"version" : "2.3.0"
}
},
{
"identity" : "grpc-swift-nio-transport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/grpc/grpc-swift-nio-transport.git",
"state" : {
"revision" : "2ca31f06658ed288a2560e23ad649acbb3d6b3a3",
"version" : "2.9.0"
}
},
{
"identity" : "grpc-swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/grpc/grpc-swift-protobuf.git",
"state" : {
"revision" : "19153231a03c2fda1f4ea60da1b92a2cb9c011d8",
"version" : "2.2.0"
}
},
{
"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" : "a54383ada6cecde007d374f58f864e29370ba5c3",
"version" : "1.3.2"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b",
"version" : "1.0.4"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "cd142fd2f64be2100422d658e7411e39489da985",
"version" : "1.2.0"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "f4cd9e78a1ec209b27e426a5f5c693675f95e75a",
"version" : "1.15.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0",
"version" : "1.2.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed",
"version" : "3.12.3"
}
},
{
"identity" : "swift-docc-plugin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-docc-plugin",
"state" : {
"revision" : "d1691545d53581400b1de9b0472d45eb25c19fed",
"version" : "1.4.4"
}
},
{
"identity" : "swift-docc-symbolkit",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-docc-symbolkit",
"state" : {
"revision" : "b45d1f2ed151d057b54504d653e0da5552844e34",
"version" : "1.0.0"
}
},
{
"identity" : "swift-http-structured-headers",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-structured-headers.git",
"state" : {
"revision" : "db6eea3692638a65e2124990155cd220c2915903",
"version" : "1.3.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "a0a57e949a8903563aba4615869310c0ebf14c03",
"version" : "1.4.0"
}
},
{
"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" : "f71c8d2a5e74a2c6d11a0fbe324774b5d6084237",
"version" : "2.99.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "145db1962f4f33a4ea07a32e751d5217602eea29",
"version" : "1.28.0"
}
},
{
"identity" : "swift-nio-http2",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-http2.git",
"state" : {
"revision" : "81cc18264f92cd307ff98430f89372711d4f6fe9",
"version" : "1.43.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" : "cd1e89816d345d2523b11c55654570acd5cd4c56",
"version" : "1.24.0"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8",
"version" : "1.0.3"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "86970144a0b86068c81ff48ee29b3f97cae0b879",
"version" : "1.36.0"
}
},
{
"identity" : "swift-service-lifecycle",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swift-server/swift-service-lifecycle.git",
"state" : {
"revision" : "e7187309187695115033536e8fc9b2eb87fd956d",
"version" : "2.8.0"
}
},
{
"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
}
+337
View File
@@ -0,0 +1,337 @@
// swift-tools-version: 6.2
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
// The swift-tools-version declares the minimum version of Swift required to build this package.
import CompilerPluginSupport
import Foundation
import PackageDescription
let package = Package(
name: "containerization",
platforms: [.macOS("15.0")],
products: [
.library(name: "Containerization", targets: ["Containerization", "ContainerizationError"]),
.library(name: "ContainerizationEXT4", targets: ["ContainerizationEXT4"]),
.library(name: "ContainerizationOCI", targets: ["ContainerizationOCI"]),
.library(name: "ContainerizationNetlink", targets: ["ContainerizationNetlink"]),
.library(name: "ContainerizationIO", targets: ["ContainerizationIO"]),
.library(name: "ContainerizationOS", targets: ["ContainerizationOS"]),
.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-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.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"),
.package(url: "https://github.com/apple/swift-nio-ssl.git", from: "2.36.0"),
.package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"),
.package(url: "https://github.com/apple/swift-system.git", from: "1.6.4"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"),
.package(url: "https://github.com/facebook/zstd.git", exact: "1.5.7"),
],
targets: [
.target(
name: "ContainerizationError"
),
.target(
name: "Containerization",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.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"
]
),
.executableTarget(
name: "cctl",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
"Containerization",
"ContainerizationArchive",
"ContainerizationEXT4",
"ContainerizationExtras",
"ContainerizationOCI",
"ContainerizationOS",
]
),
.testTarget(
name: "ContainerizationUnitTests",
dependencies: ["Containerization", "CloudHypervisor"],
path: "Tests/ContainerizationTests",
resources: [
.copy("ImageTests/Resources/scratch.tar"),
.copy("ImageTests/Resources/scratch_no_annotations.tar"),
]
),
.target(
name: "ContainerizationEXT4",
dependencies: [
"ContainerizationArchive",
.product(name: "SystemPackage", package: "swift-system"),
"ContainerizationOS",
],
path: "Sources/ContainerizationEXT4",
exclude: [
"README.md"
]
),
.testTarget(
name: "ContainerizationEXT4Tests",
dependencies: [
"ContainerizationEXT4",
"ContainerizationArchive",
],
resources: [
.copy(
"Resources/content/blobs/sha256/ad59e9f71edceca7b1ac7c642410858489b743c97233b0a26a5e2098b1443762"), // index
.copy(
"Resources/content/blobs/sha256/48a06049d3738991b011ca8b12473d712b7c40666a1462118dae3c403676afc2"), // manifest
.copy(
"Resources/content/blobs/sha256/8e2eb240a6cd7be1a0d308125afe0060b020e89275ced2e729eda7d4eeff62a2"), // config
.copy(
"Resources/content/blobs/sha256/c6b39de5b33961661dc939b997cc1d30cda01e38005a6c6625fd9c7e748bab44"), // layer 1
.copy(
"Resources/content/blobs/sha256/4f4fb700ef54461cfa02571ae0db9a0dc1e0cdb5577484a6d75e68dc38e8acc1"), // layer 2
]
),
.target(
name: "ContainerizationArchive",
dependencies: [
.product(name: "SystemPackage", package: "swift-system"),
"CArchive",
"ContainerizationExtras",
"ContainerizationOS",
],
exclude: [
"CArchive"
]
),
.testTarget(
name: "ContainerizationArchiveTests",
dependencies: [
"ContainerizationArchive"
],
resources: [
.copy("Resources/test.tar.zst")
]
),
.target(
name: "CArchive",
dependencies: [
.product(name: "libzstd", package: "zstd")
],
path: "Sources/ContainerizationArchive/CArchive",
sources: [
"archive_swift_bridge.c"
],
cSettings: [
.define(
"PLATFORM_CONFIG_H", to: "\"config_darwin.h\"",
.when(platforms: [.iOS, .macOS, .macCatalyst, .watchOS, .driverKit, .tvOS])),
.define("PLATFORM_CONFIG_H", to: "\"config_linux.h\"", .when(platforms: [.linux])),
.unsafeFlags(["-fno-modules"]),
],
linkerSettings: [
.linkedLibrary("z"),
.linkedLibrary("bz2"),
.linkedLibrary("lzma"),
.linkedLibrary("archive"),
.linkedLibrary("iconv", .when(platforms: [.macOS])),
.linkedLibrary("crypto", .when(platforms: [.linux])),
]
),
.target(
name: "ContainerizationOCI",
dependencies: [
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "Crypto", package: "swift-crypto"),
.product(name: "Logging", package: "swift-log"),
.product(name: "_NIOFileSystem", package: "swift-nio"),
"ContainerizationError",
"ContainerizationOS",
"ContainerizationExtras",
]
),
.testTarget(
name: "ContainerizationOCITests",
dependencies: [
"ContainerizationOCI",
"Containerization",
"ContainerizationIO",
.product(name: "NIO", package: "swift-nio"),
.product(name: "Crypto", package: "swift-crypto"),
]
),
.target(
name: "ContainerizationNetlink",
dependencies: [
.product(name: "Logging", package: "swift-log"),
"ContainerizationOS",
"ContainerizationExtras",
]
),
.testTarget(
name: "ContainerizationNetlinkTests",
dependencies: [
"ContainerizationNetlink"
]
),
.target(
name: "ContainerizationOS",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"CShim",
"ContainerizationError",
],
exclude: [
"../ContainerizationOS/README.md"
]
),
.testTarget(
name: "ContainerizationOSTests",
dependencies: [
.product(name: "SystemPackage", package: "swift-system"),
"ContainerizationOS",
"ContainerizationExtras",
]
),
.target(
name: "ContainerizationIO",
dependencies: [
"ContainerizationOS",
.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOFoundationCompat", package: "swift-nio"),
]
),
.target(
name: "ContainerizationExtras",
dependencies: [
"ContainerizationError",
.product(name: "Collections", package: "swift-collections"),
.product(name: "Logging", package: "swift-log"),
.product(name: "NIOSSL", package: "swift-nio-ssl"),
]
),
.testTarget(
name: "ContainerizationExtrasTests",
dependencies: [
"ContainerizationExtras",
"CShim",
]
),
.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"),
]
),
.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"
),
.target(
name: "Cgroup",
dependencies: [
.product(name: "Logging", package: "swift-log"),
"ContainerizationOCI",
"ContainerizationOS",
.product(name: "SystemPackage", package: "swift-system"),
"LCShim",
],
path: "vminitd/Sources/Cgroup"
),
.target(
name: "VminitdCore",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
"Containerization",
"ContainerizationArchive",
"ContainerizationNetlink",
"ContainerizationIO",
"ContainerizationOS",
.product(name: "SystemPackage", package: "swift-system"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
.product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"),
"LCShim",
"Cgroup",
],
path: "vminitd/Sources/VminitdCore"
),
]
)
package.targets.append(
.executableTarget(
name: "containerization-integration",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
"Containerization",
],
path: "Sources/Integration"
)
)
+55
View File
@@ -0,0 +1,55 @@
# Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
LOCAL_DIR := $(ROOT_DIR)/.local
LOCAL_BIN_DIR := $(LOCAL_DIR)/bin
# Versions
PROTOC_VERSION := 26.1
# Protoc binary installation
PROTOC_ZIP := protoc-$(PROTOC_VERSION)-osx-universal_binary.zip
PROTOC := $(LOCAL_BIN_DIR)/protoc@$(PROTOC_VERSION)/protoc
$(PROTOC):
@echo Downloading protocol buffers...
@mkdir -p $(LOCAL_DIR)
@curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v$(PROTOC_VERSION)/$(PROTOC_ZIP)
@mkdir -p $(dir $@)
@unzip -jo $(PROTOC_ZIP) bin/protoc -d $(dir $@)
@unzip -o $(PROTOC_ZIP) 'include/*' -d $(dir $@)
@rm -f $(PROTOC_ZIP)
.PHONY: protoc-gen-swift
protoc-gen-swift:
@$(SWIFT) build --product protoc-gen-swift
@$(SWIFT) build --product protoc-gen-grpc-swift-2
.PHONY: protos
protos: $(PROTOC) protoc-gen-swift
@echo Generating protocol buffers source code...
@$(PROTOC) Sources/Containerization/SandboxContext/SandboxContext.proto \
--plugin=protoc-gen-grpc-swift=$(BUILD_BIN_DIR)/protoc-gen-grpc-swift-2 \
--plugin=protoc-gen-swift=$(BUILD_BIN_DIR)/protoc-gen-swift \
--proto_path=Sources/Containerization/SandboxContext \
--grpc-swift_out="Sources/Containerization/SandboxContext" \
--grpc-swift_opt=Visibility=Public \
--swift_out="Sources/Containerization/SandboxContext" \
--swift_opt=Visibility=Public \
-I.
@"$(MAKE)" update-licenses
.PHONY: clean-proto-tools
clean-proto-tools:
@echo Cleaning proto tools...
@rm -rf $(LOCAL_DIR)/bin/protoc*
+196
View File
@@ -0,0 +1,196 @@
<h1>
<img alt="Containerization logo" src="./assets/Containerization-Logo.png" width="70" valign="middle">
&nbsp;Containerization
</h1>
The Containerization package allows applications to use Linux containers.
Containerization is written in [Swift](https://www.swift.org) and uses [Virtualization.framework](https://developer.apple.com/documentation/virtualization) on Apple silicon.
> **Looking for command line binaries for running containers?**\
> They are available in the dedicated [apple/container](https://github.com/apple/container) repository.
Containerization provides APIs to:
- [Manage OCI images](./Sources/ContainerizationOCI/).
- [Interact with remote registries](./Sources/ContainerizationOCI/Client/).
- [Create and populate ext4 file systems](./Sources/ContainerizationEXT4/).
- [Interact with the Netlink socket family](./Sources/ContainerizationNetlink/).
- [Create an optimized Linux kernel for fast boot times](./kernel/).
- [Spawn lightweight virtual machines and manage the runtime environment](./Sources/Containerization/LinuxContainer.swift).
- [Spawn and interact with containerized processes](./Sources/Containerization/LinuxProcess.swift).
- Use Rosetta 2 for running linux/amd64 containers on Apple silicon.
Please view the [API documentation](https://apple.github.io/containerization/documentation/) for information on the Swift packages that Containerization provides.
## Design
Containerization executes each Linux container inside of its own lightweight virtual machine. Clients can create dedicated IP addresses for every container to remove the need for individual port forwarding. Containers achieve sub-second start times using an optimized [Linux kernel configuration](/kernel) and a minimal root filesystem with a lightweight init system.
[vminitd](/vminitd) is a small init system, which is a subproject within Containerization.
`vminitd` is spawned as the initial process inside of the virtual machine and provides a GRPC API over vsock.
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:
- Mac with Apple silicon
- macOS 26
- Xcode 26
Older versions of macOS are not supported.
## Example Usage
For examples of how to use the libraries' API surface, the cctl executable is a good start. This app is a useful playground for exploring the API. It contains commands that exercise some of the core functionality of the various products, such as:
1. [Manipulating OCI images](./Sources/cctl/ImageCommand.swift)
2. [Logging in to container registries](./Sources/cctl/LoginCommand.swift)
3. [Creating root filesystem blocks](./Sources/cctl/RootfsCommand.swift)
4. [Running simple Linux containers](./Sources/cctl/RunCommand.swift)
## Linux kernel
A Linux kernel is required for spawning lightweight virtual machines on macOS.
Containerization provides an optimized kernel configuration located in the [kernel](./kernel) directory.
This directory includes a containerized build environment to easily compile a kernel for use with Containerization.
The kernel configuration is a minimal set of features to support fast start times and a lightweight environment.
While this configuration will work for the majority of workloads we understand that some will need extra features.
To solve this Containerization provides first class APIs to use different kernel configurations and versions on a per container basis.
This enables containers to be developed and validated across different kernel versions.
See the [README](/kernel/README.md) in the kernel directory for instructions on how to compile the optimized kernel.
### Kernel Support
Containerization allows user provided kernels but tests functionality starting with kernel version `6.14.9`.
### Pre-built Kernel
If you wish to consume a pre-built kernel, make sure it has `VIRTIO` drivers compiled into the kernel (not merely as modules).
The [Kata Containers](https://github.com/kata-containers/kata-containers) project provides a Linux kernel that is optimized for containers, with all required configuration options enabled. The [releases](https://github.com/kata-containers/kata-containers/releases/) page contains downloadable artifacts, and the image itself (`vmlinux.container`) can be found in the `/opt/kata/share/kata-containers/` directory.
## Prepare to build package
Install the recommended version of Xcode.
Set the active developer directory to the installed Xcode (replace `<PATH_TO_XCODE>`):
```bash
sudo xcode-select -s <PATH_TO_XCODE>
```
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):
```bash
make cross-prep
```
If you use a custom terminal application, you may need to move this command from `.zprofile` to `.zshrc` (replace `<USERNAME>`):
```bash
# Added by swiftly
. "/Users/<USERNAME>/.swiftly/env.sh"
```
Restart the terminal application. Ensure this command returns `/Users/<USERNAME>/.swiftly/bin/swift` (replace `<USERNAME>`):
```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 `<SDK-ID>`):
```bash
swift sdk list
swift sdk remove <SDK-ID>
```
## Build the package
Build Containerization from sources:
```bash
make all
```
## Test the package
After building, run basic and integration tests:
```bash
make test integration
```
A kernel is required to run integration tests.
If you do not have a kernel locally, a default kernel can be fetched using the `make fetch-default-kernel` target.
Fetching the default kernel only needs to happen after an initial build or after a `make clean`.
```bash
make fetch-default-kernel
make all test integration
```
## Protobufs
Containerization depends on specific versions of `grpc-swift` and `swift-protobuf`. You can install them and re-generate RPC interfaces with:
```bash
make protos
```
## Building a kernel
If you'd like to build your own kernel please see the instructions in the [kernel directory](./kernel/README.md).
## Pre-commit hook
Run `make pre-commit` to install a pre-commit hook that ensures that your changes have correct formatting and license headers when you run `git commit`.
## Documentation
Generate the API documentation for local viewing with:
```bash
make docs
make serve-docs
```
Preview the documentation by running in another terminal:
```bash
open http://localhost:8000/containerization/documentation/
```
## Contributing
Contributions to Containerization are welcomed and encouraged. Please see [CONTRIBUTING.md](/CONTRIBUTING.md) for more information.
## Project Status
Version 0.1.0 is the first official release of Containerization. Earlier versions have no source stability guarantees.
Because the Containerization library is under active development, source stability is only guaranteed within minor versions (for example, between 0.1.1 and 0.1.2). If you don't want potentially source-breaking package updates, you can specify your package dependency using .upToNextMinorVersion(from: "0.1.0") instead.
Future minor versions of the package may introduce changes to these rules as needed.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`apple/containerization`
- 原始仓库:https://github.com/apple/containerization
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+13
View File
@@ -0,0 +1,13 @@
# Security disclosure process
If you believe that you have discovered a security or privacy vulnerability in our open source software, please report it to us using the [GitHub private vulnerability feature](https://github.com/apple/containerization/security/advisories/new). Reports should include specific product and software version(s) that you believe are affected; a technical description of the behavior that you observed and the behavior that you expected; the steps required to reproduce the issue; and a proof of concept or exploit.
The project team will do their best to acknowledge receiving all security reports within 7 days of submission. This initial acknowledgment is neither acceptance nor rejection of your report. The project team may come back to you with further questions or invite you to collaborate while working through the details of your report.
Keep these additional guidelines in mind when submitting your report:
* Reports concerning known, publicly disclosed CVEs can be submitted as normal issues to this project.
* Output from automated security scans or fuzzers MUST include additional context demonstrating the vulnerability with a proof of concept or working exploit.
* Application crashes due to malformed inputs are typically not treated as security vulnerabilities, unless they are shown to also impact other processes on the system.
While we welcome reports for open source software projects, they are not eligible for Apple Security Bounties.
+32
View File
@@ -0,0 +1,32 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#if defined(__linux__)
#include <sys/syscall.h>
#include <unistd.h>
#include "capability.h"
// Capability syscall wrappers
int CZ_capget(void *header, void *data) {
return syscall(SYS_capget, header, data);
}
int CZ_capset(void *header, void *data) {
return syscall(SYS_capset, header, data);
}
#endif
+78
View File
@@ -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 <errno.h>
#include <fcntl.h>
#include <net/if.h> /* struct ifreq, IFNAMSIZ */
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>
/*
* Avoid <linux/if_tun.h> — the Static Linux SDK (musl) used to cross-compile
* vminitd ships <net/if.h> 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__ */
+393
View File
@@ -0,0 +1,393 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#if defined(__linux__) || defined(__APPLE__)
#include <errno.h>
#include <fcntl.h>
#include <dirent.h>
#include <limits.h>
#include <pthread.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <sys/resource.h>
#include <sys/syscall.h>
#include <sys/time.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#if defined(__linux__)
#include <sys/prctl.h>
#endif
#include "exec_command.h"
#ifndef SYS_close_range
#define SYS_close_range 436
#endif
#ifndef CLOSE_RANGE_CLOEXEC
#define CLOSE_RANGE_CLOEXEC 0x4
#endif
static int mark_cloexec(int fd) {
int flags = fcntl(fd, F_GETFD);
if (flags == -1) return flags;
if (flags & FD_CLOEXEC) return 0;
return fcntl(fd, F_SETFD, flags | FD_CLOEXEC);
}
static int cloexec_from(int min_fd) {
#if defined(__linux__)
// First try close_range.
long ret = syscall(SYS_close_range, min_fd, ~0U, CLOSE_RANGE_CLOEXEC);
if (ret == 0) {
return 0;
}
const char* dirpath = "/proc/self/fd";
#elif defined(__APPLE__)
const char* dirpath = "/dev/fd";
#endif
DIR *dp = opendir(dirpath);
if (!dp) return -1;
int dp_fd = dirfd(dp);
struct dirent *de;
while ((de = readdir(dp))) {
if (de->d_name[0] == '.') continue;
char *end;
long val = strtol(de->d_name, &end, 10);
if (*end || val < 0 || val > INT_MAX) continue;
int fd = (int)val;
if (fd < min_fd || fd == dp_fd) continue;
int ret = mark_cloexec(fd);
if (ret != 0) {
return ret;
}
}
close(dp_fd);
closedir(dp);
return 0;
}
void exec_command_attrs_init(struct exec_command_attrs *attrs) {
attrs->setpgid = 0;
attrs->pgid = 0;
attrs->setsid = 0;
attrs->setctty = 0;
attrs->ctty = 0;
attrs->mask = 0;
attrs->uid = -1;
attrs->gid = -1;
attrs->pdeathSignal = 0;
attrs->setfgpgrp = 0;
}
static void child_handler(const int sync_pipes[2], const char *executable,
char *const args[], char *const environment[],
const int file_handles[], const int file_handle_count,
const char *cwd, const sigset_t old_mask,
const struct exec_command_attrs attrs) {
int i = 0;
int err = 0;
int fd_index = 0;
int fd_table[file_handle_count];
struct rlimit limits = {0};
int syncfd = sync_pipes[1];
struct sigaction action = {0};
// Closing our parent's side of the pipe
if (close(sync_pipes[0]) < 0) {
goto fail;
}
// Setup process group and foreground before clearing signal mask.
if (attrs.setpgid) {
if (setpgid(0, attrs.pgid) < 0) {
goto fail;
}
}
// Make the new process group the foreground process group so it can read from the TTY.
if (attrs.setfgpgrp) {
if (tcsetpgrp(STDIN_FILENO, getpgrp()) < 0) {
if (errno != ENOTTY && errno != ENXIO) {
goto fail;
}
}
}
// clear sighandlers
action.sa_flags = 0;
action.sa_handler = SIG_DFL;
sigemptyset(&action.sa_mask);
for (i = 0; i < NSIG; i++) {
sigaction(i, &action, 0);
}
sigset_t local_mask;
sigemptyset(&local_mask);
if (pthread_sigmask(SIG_SETMASK, &local_mask, NULL) < 0) {
goto fail;
}
// start shuffling fds.
// look at all the file handles and find the highest one,
// use that for our pipe,
//
// Then, we need to start dup2 the fds starting for the final process
// at 0-n.
// as an example we have this list of FDs that should be passed to the
// process:
//
/*
The index of this list is the final result that the new process expects.
The values are open fds provided from the parent process.
[0] == 12
[1] == 7
[2] == 9
[3] == 0
We also have a pipe to sync the child and parent so that adds an additional
parameter to consider.
So we start by finding the highest open fd in the list, then move our pipe to
the next.
i.e. fd12 is highest so move our pipe to fd13
Now start moving all the fds above our pipe as we will need to start placing
the fds in the child process into the right order. Make sure they are all
marked cloexec.
pipe == 13
[0] == 12 dup2 14
[1] == 7 dup2 15
[2] == 9 dup2 16
[3] == 0 dup2 17
Now overwrite the fd table for the child with the current index.
Make index == fd.
pipe == 13
[0] == 14 dup2 0
[1] == 15 dup2 1
[2] == 16 dup2 2
[3] == 17 dup2 3
Clear cloexec on this new fds.
*/
// find the highest fd value in our list.
for (i = 0; i < file_handle_count; i++) {
if (file_handles[i] > fd_index) {
fd_index = file_handles[i];
}
fd_table[i] = file_handles[i];
}
// now fd_index is == to the highest fd in our list of handles.
// Increment it and set our pipe to it.
fd_index++;
if (syncfd != fd_index) {
if (dup2(syncfd, fd_index) < 0) {
goto fail;
}
if (close(syncfd) < 0) {
goto fail;
}
syncfd = fd_index;
}
fd_index++;
// make sure our syncfd retains its cloexec
if (fcntl(syncfd, F_SETFD, FD_CLOEXEC) == -1) {
goto fail;
}
// move the rest of the fds up above our index if they don't match the index.
for (i = 0; i < file_handle_count; i++) {
if (fd_table[i] == i) {
continue;
}
if (dup2(fd_table[i], fd_index) < 0) {
goto fail;
}
if (fcntl(fd_index, F_SETFD, FD_CLOEXEC) == -1) {
goto fail;
}
fd_table[i] = fd_index;
fd_index++;
}
// now create the child process's final fd table. where i == i
for (i = 0; i < file_handle_count; i++) {
if (fd_table[i] != i) {
if (dup2(fd_table[i], i) < 0) {
goto fail;
}
}
// now fd[i] should == i
// clear cloexec as this fd is where we want it.
if (fcntl(i, F_SETFD, 0) == -1) {
goto fail;
}
}
if (attrs.setsid) {
if (setsid() == -1) {
goto fail;
}
}
if (attrs.setctty) {
if (ioctl(attrs.ctty, TIOCSCTTY, 0)) {
goto fail;
}
}
#if defined(__linux__)
// Set parent death signal if specified
if (attrs.pdeathSignal != 0) {
if (prctl(PR_SET_PDEATHSIG, attrs.pdeathSignal) != 0) {
goto fail;
}
}
#endif
// close exec everything outside of our child's fd_table.
if (cloexec_from(file_handle_count) != 0) {
goto fail;
}
// set gid
if (attrs.gid != -1) {
if (setgid(attrs.gid) != 0) {
goto fail;
}
}
// set uid
if (attrs.uid != -1) {
if (setreuid(attrs.uid, attrs.uid) != 0) {
goto fail;
}
}
if (cwd != NULL) {
if (chdir(cwd)) {
goto fail;
}
}
execve(executable, args, environment);
fail:
err = errno;
if (err) {
// send our error to the parent
while (write(syncfd, &err, sizeof(err)) < 0)
;
}
exit(127);
}
int exec_command(pid_t *result, const char *executable, char *const args[],
char *const envp[], const int file_handles[],
const int file_handle_count, const char *working_directory,
struct exec_command_attrs *attrs) {
pid_t pid = 0;
int err = 0;
int sync_pipe[2];
sigset_t old_mask;
sigset_t all;
sigfillset(&all);
if (pipe(sync_pipe)) {
goto fail;
}
if (pthread_sigmask(SIG_SETMASK, &all, &old_mask) < 0) {
goto fail;
}
pid = fork();
if (pid == -1) {
close(sync_pipe[0]);
close(sync_pipe[1]);
goto fail;
}
if (pid == 0) {
// hand off to child
child_handler(sync_pipe, executable, args, envp, file_handles,
file_handle_count, working_directory, old_mask, *attrs);
exit(EXIT_FAILURE);
}
// handle parent operations
if (close(sync_pipe[1]) < 0) {
goto fail;
}
// sync with our child process
err = 0;
ssize_t size = read(sync_pipe[0], &err, sizeof(err));
// -- we didn't get an errno back
if (size != sizeof(err)) {
// will be used as return result
err = 0;
} else {
// we did get an errno back from the child process and our
// err var is set to that errno
// lets set our errno and then reap the process
errno = err;
int status = 0;
waitpid(pid, &status, 0);
// lets continue our journey below
}
if (close(sync_pipe[0]) < 0) {
goto fail;
}
if (err) {
goto fail;
}
(*result) = pid;
err = 0;
fail:
if (pthread_sigmask(SIG_SETMASK, &old_mask, 0) < 0) {
printf("restoring signal mask: %s\n", strerror(errno));
}
if (err) {
printf("exec_command execve: %s\n", strerror(err));
return -1;
}
return 0;
}
#endif
+28
View File
@@ -0,0 +1,28 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#ifndef __CAPABILITY_H
#define __CAPABILITY_H
#if defined(__linux__)
// Capability syscall wrappers
int CZ_capget(void *header, void *data);
int CZ_capset(void *header, void *data);
#endif
#endif
+39
View File
@@ -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 <stddef.h>
/*
* 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 */
+56
View File
@@ -0,0 +1,56 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#ifndef exec_command_h
#define exec_command_h
#if defined(__linux__) || defined(__APPLE__)
#include <sys/types.h>
#include <unistd.h>
struct exec_command_attrs {
int setpgid;
/// parent group id
pid_t pgid;
/// set the controlling terminal
int setctty;
/// controlling terminal fd
int ctty;
/// set the process as session leader
int setsid;
/// set the process user id
uid_t uid;
/// set the process group id
gid_t gid;
/// signal mask for the child process
int mask;
/// parent death signal (Linux only, 0 to disable)
int pdeathSignal;
/// make the new process group the foreground process group
int setfgpgrp;
};
void exec_command_attrs_init(struct exec_command_attrs *attrs);
/// spawn a new child process with the provided attrs
int exec_command(pid_t *result, const char *executable, char *const argv[],
char *const envp[], const int file_handles[],
const int file_handle_count, const char *working_directory,
struct exec_command_attrs *attrs);
#endif /* defined(__linux__) || defined(__APPLE__) */
#endif /* exec_command_h */
+32
View File
@@ -0,0 +1,32 @@
/*
* 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.
*/
// The below fall into two main categories:
// 1. Aren't exposed by Swifts glibc modulemap.
// 2. Don't have syscall wrappers/definitions in glibc/musl.
#ifndef __LINUX_SHIM_H
#define __LINUX_SHIM_H
#if defined(__linux__)
#include <sys/epoll.h>
#include <sys/eventfd.h>
#include <sys/vfs.h>
#endif /* __linux__ */
#endif /* __LINUX_SHIM_H */
+37
View File
@@ -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.
*/
#ifndef __OPENAT2_H
#define __OPENAT2_H
#include <sys/types.h>
#ifndef RESOLVE_IN_ROOT
#define RESOLVE_IN_ROOT 0x10
#endif
struct cz_open_how {
unsigned long long flags;
unsigned long long mode;
unsigned long long resolve;
};
/// openat2(2) wrapper. Musl does not provide openat2 so we invoke the syscall
/// directly. Requires Linux 5.6+.
int CZ_openat2(int dirfd, const char *pathname, struct cz_open_how *how,
size_t size);
#endif
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#ifndef __PRCTL_H
#define __PRCTL_H
#if defined(__linux__)
#include <sys/types.h>
// Capability management prctl wrappers
int CZ_prctl_set_keepcaps();
int CZ_prctl_clear_keepcaps();
int CZ_prctl_capbset_drop(unsigned int capability);
int CZ_prctl_cap_ambient_clear_all();
int CZ_prctl_cap_ambient_raise(unsigned int capability);
#endif
#endif
+29
View File
@@ -0,0 +1,29 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#ifndef socket_helpers_h
#define socket_helpers_h
#include <sys/socket.h>
#include <stdint.h>
// Helper functions to access CMSG macros from Swift
struct cmsghdr* CZ_CMSG_FIRSTHDR(struct msghdr *msg);
void* CZ_CMSG_DATA(struct cmsghdr *cmsg);
size_t CZ_CMSG_SPACE(size_t length);
size_t CZ_CMSG_LEN(size_t length);
#endif /* socket_helpers_h */
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
//
#ifndef vsock_h
#define vsock_h
#include <sys/ioctl.h>
#ifdef __APPLE__
#include <sys/vsock.h>
#else
#include <sys/socket.h>
#include <linux/vm_sockets.h>
#endif /* __APPLE__ */
extern const unsigned long VsockLocalCIDIoctl;
#endif /* vsock_h */
+33
View File
@@ -0,0 +1,33 @@
/*
* 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 <sys/syscall.h>
#include <unistd.h>
#include "openat2.h"
#ifndef SYS_openat2
#define SYS_openat2 437
#endif
int CZ_openat2(int dirfd, const char *pathname, struct cz_open_how *how,
size_t size) {
return syscall(SYS_openat2, dirfd, pathname, how, size);
}
#endif
+47
View File
@@ -0,0 +1,47 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#if defined(__linux__)
#include <sys/prctl.h>
#include "prctl.h"
// Set keep caps to preserve capabilities across setuid()
int CZ_prctl_set_keepcaps() {
return prctl(PR_SET_KEEPCAPS, 1, 0, 0, 0);
}
// Clear keep caps after user change
int CZ_prctl_clear_keepcaps() {
return prctl(PR_SET_KEEPCAPS, 0, 0, 0, 0);
}
// Drop capability from bounding set
int CZ_prctl_capbset_drop(unsigned int capability) {
return prctl(PR_CAPBSET_DROP, capability, 0, 0, 0);
}
// Clear all ambient capabilities
int CZ_prctl_cap_ambient_clear_all() {
return prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_CLEAR_ALL, 0, 0, 0);
}
// Raise ambient capability
int CZ_prctl_cap_ambient_raise(unsigned int capability) {
return prctl(PR_CAP_AMBIENT, PR_CAP_AMBIENT_RAISE, capability, 0, 0);
}
#endif
+33
View File
@@ -0,0 +1,33 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "socket_helpers.h"
struct cmsghdr* CZ_CMSG_FIRSTHDR(struct msghdr *msg) {
return CMSG_FIRSTHDR(msg);
}
void* CZ_CMSG_DATA(struct cmsghdr *cmsg) {
return CMSG_DATA(cmsg);
}
size_t CZ_CMSG_SPACE(size_t length) {
return CMSG_SPACE(length);
}
size_t CZ_CMSG_LEN(size_t length) {
return CMSG_LEN(length);
}
+19
View File
@@ -0,0 +1,19 @@
/*
* Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
#include "vsock.h"
const unsigned long VsockLocalCIDIoctl = IOCTL_VM_SOCKETS_GET_LOCAL_CID;
@@ -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<Response: Decodable & Sendable>(_ 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<Body: Encodable & Sendable>(_ 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<Body: Encodable & Sendable, Response: Decodable & Sendable>(
_ 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<Response: Decodable & Sendable>(
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)
}
}
}
}
@@ -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)
}
}
@@ -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 {}
@@ -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))
}
}
@@ -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")
}
}
@@ -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")
}
}
+202
View File
@@ -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<Bool>
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 }
}
}
+95
View File
@@ -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.
@@ -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
}
}
}
@@ -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
}
}
}
+122
View File
@@ -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
}
}
}
@@ -0,0 +1,53 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationExtras
import ContainerizationOCI
/// A filesystem that was attached and able to be mounted inside the runtime environment.
public struct AttachedFilesystem: Sendable {
/// The type of the filesystem.
public var type: String
/// The path to the filesystem within a sandbox.
public var source: String
/// Destination when mounting the filesystem inside a sandbox.
public var destination: String
/// The options to use when mounting the filesystem.
public var options: [String]
public init(mount: Mount, allocator: any AddressAllocator<Character>) throws {
switch mount.runtimeOptions {
case .virtiofs:
let name = try hashFilePath(path: mount.source)
self.source = name
case .virtioblk:
let char = try allocator.allocate()
self.source = "/dev/vd\(char)"
case .shared, .any:
self.source = mount.source
}
self.type = mount.type
self.options = mount.options
self.destination = mount.destination
}
public init(type: String, source: String, destination: String, options: [String]) {
self.type = type
self.source = source
self.destination = destination
self.options = options
}
}
@@ -0,0 +1,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-<name>.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 <bridge> -o <egress>`); 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-<name>.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 <bridge> -o <egress>` 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/<iface>/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<T>(_ 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
@@ -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-<name>.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)
}
}
@@ -0,0 +1,422 @@
//===----------------------------------------------------------------------===//
// 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<Character>
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<Character>,
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<T: Sendable>(
_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T
) rethrows -> T {
try _mounts.withLock(body)
}
// MARK: - HotplugProvider conformance
func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem {
guard case .virtioblk = block.runtimeOptions else {
throw ContainerizationError(.invalidArgument, message: "hotplug requires a virtio-blk mount")
}
let letter = try allocator.allocate()
let chId = "blk-\(id)-\(letter)"
let disk = CloudHypervisor.DiskConfig(
path: block.source,
readonly: block.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: block.type,
source: "/dev/vd\(letter)",
destination: block.destination,
options: block.options
)
_records.withLock {
$0[id, default: []].append(HotplugRecord(chDeviceId: pci.id, kind: .block(letter: letter)))
}
return attached
}
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<letter>" by
// `hotplug(_:)` (or by AttachedFilesystem(mount:allocator:) for an
// additionalMount of type virtio-blk).
_mounts.withLock { state in
guard var perID = state[id] else { return }
perID.removeAll { $0.source.hasPrefix("/dev/vd") }
if perID.isEmpty {
state.removeValue(forKey: id)
} else {
state[id] = perID
}
}
}
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 {
// Hold spawnLock across the existence check and the spawn /
// _tags write so two concurrent calls for the same tag can't
// both decide alreadyRunning=false and double-spawn virtiofsd
// (the second write would clobber the first in `_tags`,
// orphaning that process).
try await spawnLock.withLock { _ in
// Build per-container AttachedFilesystem entries up front.
// These depend only on Mount + allocator and don't need the
// chDeviceId, so surfacing any error here keeps the
// transactional shape: nothing irreversible has happened
// yet, no virtiofsd has spawned, no _tags entry written.
var attached: [AttachedFilesystem] = []
for mount in group {
attached.append(try AttachedFilesystem(mount: mount, allocator: self.allocator))
}
let chDeviceId: String
// Refcount-bump path. If a virtiofsd already serves this
// tag, increment refcount and use the cached deviceId.
let cachedDeviceId: 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 = cachedDeviceId {
chDeviceId = cached
} else {
// First-spawn path. Walk: spawn vmAddFs commit _tags,
// with rollback at every step so a partial failure can't
// leave a virtiofsd running unrecorded.
let socket = chVirtiofsSocketURL(workDir: self.workDir, tag: tag)
let readonly = group.allSatisfy { $0.options.contains("ro") }
guard let source = group.first?.source else { return }
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)
}
chDeviceId = pci.id
}
// Bookkeeping. Both writes are non-throwing closures, and
// `attached` was built up front, so once we reach here
// nothing can fail between the refcount/spawn commit above
// and the per-container record below the orphan window
// (tag committed, record missing) is closed.
self._records.withLock {
$0[id, default: []].append(HotplugRecord(chDeviceId: chDeviceId, kind: .virtiofs(tag: tag)))
}
self._mounts.withLock {
$0[id, default: []].append(contentsOf: attached)
}
}
}
}
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<T: Sendable>(_ 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) ?? "<non-utf8 body>"
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
@@ -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
@@ -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
+211
View File
@@ -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 <path>`, 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<ExitReason, Never>?
}
private let config: Config
private let logger: Logger?
private let state: Mutex<State>
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<ExitReason, Never>.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
@@ -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 D3D5 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<VirtualMachineInstanceState>
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/<tag>` 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<Character>
/// 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_<port>`
/// 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<T: Sendable>(_ body: (inout sending [String: [AttachedFilesystem]]) throws -> sending T) rethrows -> T {
try hotplug.withMountRegistry(body)
}
}
// MARK: - VirtualMachineInstance conformance (stubbed; D3D5 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..<stdioPoolBase+stdioPoolSize` as
/// a listening UDS at `<workDir>/vsock.sock_<port>`. 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..<UInt32(Self.stdioPoolSize) {
let port = Self.stdioPoolBase + offset
let path = chVsockListenSocketPath(baseSocket: base, port: port)
let fd = try chVsockBindListener(at: path)
pool[port] = PreboundListener(port: port, listenFd: fd, path: path)
}
} catch {
// Roll back any successfully-bound listeners so we don't leak
// them on a partial failure.
for entry in pool.values {
_ = close(entry.listenFd)
try? FileManager.default.removeItem(at: entry.path)
}
throw error
}
_stdioPool.withLock { $0 = pool }
logger?.debug("vsock stdio pool prebound \(pool.count) ports starting at \(Self.stdioPoolBase)")
}
public func hotplug(_ block: Mount, id: String) async throws -> 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<Character>
) 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
@@ -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
+27
View File
@@ -0,0 +1,27 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
/// The core protocol container implementations must implement.
public protocol Container {
/// ID for the container.
var id: String { get }
/// The amount of cpus assigned to the container.
var cpus: Int { get }
/// The memory in bytes assigned to the container.
var memoryInBytes: UInt64 { get }
/// The network interfaces assigned to the container.
var interfaces: [any Interface] { get }
}
@@ -0,0 +1,383 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
#if os(macOS)
import ContainerizationError
import ContainerizationEXT4
import ContainerizationOCI
import ContainerizationOS
import Foundation
import ContainerizationExtras
import SystemPackage
import Virtualization
/// A manager for creating and running containers.
/// Supports container networking options.
public struct ContainerManager: Sendable {
public let imageStore: ImageStore
private let vmm: VirtualMachineManager
private var network: Network?
private var containerRoot: URL {
self.imageStore.path.appendingPathComponent("containers")
}
/// Create a new manager with the provided kernel, initfs mount, image store
/// and optional network implementation. This will use a Virtualization.framework
/// backed VMM implicitly.
public init(
kernel: Kernel,
initfs: Mount,
imageStore: ImageStore,
network: Network? = nil,
rosetta: Bool = false,
nestedVirtualization: Bool = false
) throws {
self.imageStore = imageStore
self.network = network
try Self.createRootDirectory(path: self.imageStore.path)
self.vmm = VZVirtualMachineManager(
kernel: kernel,
initialFilesystem: initfs,
rosetta: rosetta,
nestedVirtualization: nestedVirtualization
)
}
/// Create a new manager with the provided kernel, initfs mount, root state
/// directory and optional network implementation. This will use a Virtualization.framework
/// backed VMM implicitly.
public init(
kernel: Kernel,
initfs: Mount,
root: URL? = nil,
network: Network? = nil,
rosetta: Bool = false,
nestedVirtualization: Bool = false
) throws {
if let root {
self.imageStore = try ImageStore(path: root)
} else {
self.imageStore = ImageStore.default
}
self.network = network
try Self.createRootDirectory(path: self.imageStore.path)
self.vmm = VZVirtualMachineManager(
kernel: kernel,
initialFilesystem: initfs,
rosetta: rosetta,
nestedVirtualization: nestedVirtualization
)
}
/// Create a new manager with the provided kernel, initfs reference, image store
/// and optional network implementation. This will use a Virtualization.framework
/// backed VMM implicitly.
public init(
kernel: Kernel,
initfsReference: String,
imageStore: ImageStore,
network: Network? = nil,
rosetta: Bool = false,
nestedVirtualization: Bool = false
) async throws {
self.imageStore = imageStore
self.network = network
try Self.createRootDirectory(path: self.imageStore.path)
let initPath = self.imageStore.path.appendingPathComponent("initfs.ext4")
let initImage = try await self.imageStore.getInitImage(reference: initfsReference)
let initfs = try await {
do {
return try await initImage.initBlock(at: initPath, for: .linuxArm)
} catch let err as ContainerizationError {
guard err.code == .exists else {
throw err
}
return .block(
format: "ext4",
source: initPath.absolutePath(),
destination: "/",
options: ["ro"]
)
}
}()
self.vmm = VZVirtualMachineManager(
kernel: kernel,
initialFilesystem: initfs,
rosetta: rosetta,
nestedVirtualization: nestedVirtualization
)
}
/// Create a new manager with the provided kernel and image reference for the initfs.
/// This will use a Virtualization.framework backed VMM implicitly.
public init(
kernel: Kernel,
initfsReference: String,
root: URL? = nil,
network: Network? = nil,
rosetta: Bool = false,
nestedVirtualization: Bool = false
) async throws {
if let root {
self.imageStore = try ImageStore(path: root)
} else {
self.imageStore = ImageStore.default
}
self.network = network
try Self.createRootDirectory(path: self.imageStore.path)
let initPath = self.imageStore.path.appendingPathComponent("initfs.ext4")
let initImage = try await self.imageStore.getInitImage(reference: initfsReference)
let initfs = try await {
do {
return try await initImage.initBlock(at: initPath, for: .linuxArm)
} catch let err as ContainerizationError {
guard err.code == .exists else {
throw err
}
return .block(
format: "ext4",
source: initPath.absolutePath(),
destination: "/",
options: ["ro"]
)
}
}()
self.vmm = VZVirtualMachineManager(
kernel: kernel,
initialFilesystem: initfs,
rosetta: rosetta,
nestedVirtualization: nestedVirtualization
)
}
/// Create a new manager with the provided vmm and network.
public init(
vmm: any VirtualMachineManager,
network: Network? = nil
) throws {
self.imageStore = ImageStore.default
try Self.createRootDirectory(path: self.imageStore.path)
self.network = network
self.vmm = vmm
}
private static func createRootDirectory(path: URL) throws {
try FileManager.default.createDirectory(
at: path.appendingPathComponent("containers"),
withIntermediateDirectories: true
)
}
/// Returns a new container from the provided image reference.
/// - Parameters:
/// - id: The container ID.
/// - reference: The image reference.
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
/// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided,
/// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size.
/// - readOnly: Whether to mount the root filesystem as read-only.
/// - networking: Whether to create a network interface for this container. Defaults to `true`.
/// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call.
/// - progress: Optional handler for tracking rootfs unpacking progress.
public mutating func create(
_ id: String,
reference: String,
rootfsSizeInBytes: UInt64 = 8.gib(),
writableLayerSizeInBytes: UInt64? = nil,
readOnly: Bool = false,
networking: Bool = true,
progress: ProgressHandler? = nil,
configuration: (inout LinuxContainer.Configuration) throws -> Void
) async throws -> LinuxContainer {
let image = try await imageStore.get(reference: reference, pull: true)
return try await create(
id,
image: image,
rootfsSizeInBytes: rootfsSizeInBytes,
writableLayerSizeInBytes: writableLayerSizeInBytes,
readOnly: readOnly,
networking: networking,
progress: progress,
configuration: configuration
)
}
/// Returns a new container from the provided image.
/// - Parameters:
/// - id: The container ID.
/// - image: The image.
/// - rootfsSizeInBytes: The size of the root filesystem in bytes. Defaults to 8 GiB.
/// - writableLayerSizeInBytes: Optional size for a separate writable layer. When provided,
/// the rootfs becomes read-only and an overlayfs is used with a separate writable layer of this size.
/// - readOnly: Whether to mount the root filesystem as read-only.
/// - networking: Whether to create a network interface for this container. Defaults to `true`.
/// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call.
/// - progress: Optional handler for tracking rootfs unpacking progress.
public mutating func create(
_ id: String,
image: Image,
rootfsSizeInBytes: UInt64 = 8.gib(),
writableLayerSizeInBytes: UInt64? = nil,
readOnly: Bool = false,
networking: Bool = true,
progress: ProgressHandler? = nil,
configuration: (inout LinuxContainer.Configuration) throws -> Void
) async throws -> LinuxContainer {
let path = try createContainerRoot(id)
var rootfs = try await unpack(
image: image,
destination: path.appendingPathComponent("rootfs.ext4"),
size: rootfsSizeInBytes,
progress: progress
)
if readOnly {
rootfs.options.append("ro")
}
// Create writable layer if size is specified.
var writableLayer: Mount? = nil
if let writableLayerSize = writableLayerSizeInBytes {
writableLayer = try createEmptyFilesystem(
at: path.appendingPathComponent("writable.ext4"),
size: writableLayerSize
)
}
return try await create(
id,
image: image,
rootfs: rootfs,
writableLayer: writableLayer,
networking: networking,
configuration: configuration
)
}
/// Returns a new container from the provided image and root filesystem mount.
/// - Parameters:
/// - id: The container ID.
/// - image: The image.
/// - rootfs: The root filesystem mount pointing to an existing block file.
/// The `destination` field is ignored as mounting is handled internally.
/// - writableLayer: Optional writable layer mount. When provided, an overlayfs is used with
/// rootfs as the lower layer and this as the upper layer.
/// The `destination` field is ignored as mounting is handled internally.
/// - networking: Whether to create a network interface for this container. Defaults to `true`.
/// When `false`, no network resources are allocated and `releaseNetwork`/`delete` remain safe to call.
public mutating func create(
_ id: String,
image: Image,
rootfs: Mount,
writableLayer: Mount? = nil,
networking: Bool = true,
configuration: (inout LinuxContainer.Configuration) throws -> Void
) async throws -> LinuxContainer {
let imageConfig = try await image.config(for: .current).config
return try LinuxContainer(
id,
rootfs: rootfs,
writableLayer: writableLayer,
vmm: self.vmm
) { config in
if let imageConfig {
config.process = .init(from: imageConfig)
}
if networking {
if let interface = try self.network?.createInterface(id) {
config.interfaces = [interface]
guard let gateway = interface.ipv4Gateway else {
throw ContainerizationError(
.invalidState,
message: "missing ipv4 gateway for container \(id)"
)
}
config.dns = .init(nameservers: [gateway.description])
}
}
config.bootLog = BootLog.file(path: self.containerRoot.appendingPathComponent(id).appendingPathComponent("bootlog.log"))
try configuration(&config)
}
}
/// Releases network resources for a container.
///
/// - Parameter id: The container ID.
public mutating func releaseNetwork(_ id: String) throws {
try self.network?.releaseInterface(id)
}
/// Releases network resources and removes all files for a container.
/// - Parameter id: The container ID.
public mutating func delete(_ id: String) throws {
try self.releaseNetwork(id)
let path = containerRoot.appendingPathComponent(id)
try FileManager.default.removeItem(at: path)
}
private func createContainerRoot(_ id: String) throws -> URL {
let path = containerRoot.appendingPathComponent(id)
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: false)
return path
}
private func unpack(image: Image, destination: URL, size: UInt64, progress: ProgressHandler? = nil) async throws -> Mount {
do {
let unpacker = EXT4Unpacker(blockSizeInBytes: size)
return try await unpacker.unpack(image, for: .current, at: destination, progress: progress)
} catch let err as ContainerizationError {
if err.code == .exists {
return .block(
format: "ext4",
source: destination.absolutePath(),
destination: "/",
options: []
)
}
throw err
}
}
private func createEmptyFilesystem(at destination: URL, size: UInt64) throws -> Mount {
let path = destination.absolutePath()
guard !FileManager.default.fileExists(atPath: path) else {
throw ContainerizationError(.exists, message: "filesystem already exists at \(path)")
}
let filesystem = try EXT4.Formatter(FilePath(path), minDiskSize: size)
try filesystem.close()
return .block(
format: "ext4",
source: path,
destination: "/",
options: []
)
}
}
extension CIDRv6 {
/// The gateway address of the network.
public var gateway: IPv6Address {
IPv6Address(self.lower.value + 1)
}
}
#endif
@@ -0,0 +1,248 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
/// Statistics for a container.
public struct ContainerStatistics: Sendable {
public var id: String
public var process: ProcessStatistics?
public var memory: MemoryStatistics?
public var cpu: CPUStatistics?
public var blockIO: BlockIOStatistics?
public var networks: [NetworkStatistics]?
public var memoryEvents: MemoryEventStatistics?
public init(
id: String,
process: ProcessStatistics? = nil,
memory: MemoryStatistics? = nil,
cpu: CPUStatistics? = nil,
blockIO: BlockIOStatistics? = nil,
networks: [NetworkStatistics]? = nil,
memoryEvents: MemoryEventStatistics? = nil
) {
self.id = id
self.process = process
self.memory = memory
self.cpu = cpu
self.blockIO = blockIO
self.networks = networks
self.memoryEvents = memoryEvents
}
/// Process statistics for a container.
public struct ProcessStatistics: Sendable {
public var current: UInt64
public var limit: UInt64
public init(current: UInt64, limit: UInt64) {
self.current = current
self.limit = limit
}
}
/// Memory statistics for a container.
public struct MemoryStatistics: Sendable {
public var usageBytes: UInt64
public var limitBytes: UInt64
public var swapUsageBytes: UInt64
public var swapLimitBytes: UInt64
public var cacheBytes: UInt64
public var kernelStackBytes: UInt64
public var slabBytes: UInt64
public var pageFaults: UInt64
public var majorPageFaults: UInt64
public var inactiveFile: UInt64
public var anon: UInt64
public var workingsetRefaultAnon: UInt64
public var workingsetRefaultFile: UInt64
public var pgstealKswapd: UInt64
public var pgstealDirect: UInt64
public var pgstealKhugepaged: UInt64
public init(
usageBytes: UInt64,
limitBytes: UInt64,
swapUsageBytes: UInt64,
swapLimitBytes: UInt64,
cacheBytes: UInt64,
kernelStackBytes: UInt64,
slabBytes: UInt64,
pageFaults: UInt64,
majorPageFaults: UInt64,
inactiveFile: UInt64,
anon: UInt64,
workingsetRefaultAnon: UInt64 = 0,
workingsetRefaultFile: UInt64 = 0,
pgstealKswapd: UInt64 = 0,
pgstealDirect: UInt64 = 0,
pgstealKhugepaged: UInt64 = 0
) {
self.usageBytes = usageBytes
self.limitBytes = limitBytes
self.swapUsageBytes = swapUsageBytes
self.swapLimitBytes = swapLimitBytes
self.cacheBytes = cacheBytes
self.kernelStackBytes = kernelStackBytes
self.slabBytes = slabBytes
self.pageFaults = pageFaults
self.majorPageFaults = majorPageFaults
self.inactiveFile = inactiveFile
self.anon = anon
self.workingsetRefaultAnon = workingsetRefaultAnon
self.workingsetRefaultFile = workingsetRefaultFile
self.pgstealKswapd = pgstealKswapd
self.pgstealDirect = pgstealDirect
self.pgstealKhugepaged = pgstealKhugepaged
}
}
/// CPU statistics for a container.
public struct CPUStatistics: Sendable {
public var usageUsec: UInt64
public var userUsec: UInt64
public var systemUsec: UInt64
public var throttlingPeriods: UInt64
public var throttledPeriods: UInt64
public var throttledTimeUsec: UInt64
public init(
usageUsec: UInt64,
userUsec: UInt64,
systemUsec: UInt64,
throttlingPeriods: UInt64,
throttledPeriods: UInt64,
throttledTimeUsec: UInt64
) {
self.usageUsec = usageUsec
self.userUsec = userUsec
self.systemUsec = systemUsec
self.throttlingPeriods = throttlingPeriods
self.throttledPeriods = throttledPeriods
self.throttledTimeUsec = throttledTimeUsec
}
}
/// Block I/O statistics for a container.
public struct BlockIOStatistics: Sendable {
public var devices: [BlockIODevice]
public init(devices: [BlockIODevice]) {
self.devices = devices
}
}
/// Block I/O statistics for a specific device.
public struct BlockIODevice: Sendable {
public var major: UInt64
public var minor: UInt64
public var readBytes: UInt64
public var writeBytes: UInt64
public var readOperations: UInt64
public var writeOperations: UInt64
public init(
major: UInt64,
minor: UInt64,
readBytes: UInt64,
writeBytes: UInt64,
readOperations: UInt64,
writeOperations: UInt64
) {
self.major = major
self.minor = minor
self.readBytes = readBytes
self.writeBytes = writeBytes
self.readOperations = readOperations
self.writeOperations = writeOperations
}
}
/// Statistics for a network interface.
public struct NetworkStatistics: Sendable {
public var interface: String
public var receivedPackets: UInt64
public var transmittedPackets: UInt64
public var receivedBytes: UInt64
public var transmittedBytes: UInt64
public var receivedErrors: UInt64
public var transmittedErrors: UInt64
public init(
interface: String,
receivedPackets: UInt64,
transmittedPackets: UInt64,
receivedBytes: UInt64,
transmittedBytes: UInt64,
receivedErrors: UInt64,
transmittedErrors: UInt64
) {
self.interface = interface
self.receivedPackets = receivedPackets
self.transmittedPackets = transmittedPackets
self.receivedBytes = receivedBytes
self.transmittedBytes = transmittedBytes
self.receivedErrors = receivedErrors
self.transmittedErrors = transmittedErrors
}
}
/// Memory event counters from cgroup2's memory.events file.
public struct MemoryEventStatistics: Sendable {
/// Number of times the cgroup was reclaimed due to low memory.
public var low: UInt64
/// Number of times the cgroup exceeded its high memory limit.
public var high: UInt64
/// Number of times the cgroup hit its max memory limit.
public var max: UInt64
/// Number of times the cgroup triggered OOM.
public var oom: UInt64
/// Number of processes killed by OOM killer.
public var oomKill: UInt64
public init(low: UInt64, high: UInt64, max: UInt64, oom: UInt64, oomKill: UInt64) {
self.low = low
self.high = high
self.max = max
self.oom = oom
self.oomKill = oomKill
}
}
}
/// Categories of statistics that can be requested.
public struct StatCategory: OptionSet, Sendable {
public let rawValue: Int
public init(rawValue: Int) {
self.rawValue = rawValue
}
/// Process statistics (pids.current, pids.max).
public static let process = StatCategory(rawValue: 1 << 0)
/// Memory usage statistics.
public static let memory = StatCategory(rawValue: 1 << 1)
/// CPU usage statistics.
public static let cpu = StatCategory(rawValue: 1 << 2)
/// Block I/O statistics.
public static let blockIO = StatCategory(rawValue: 1 << 3)
/// Network interface statistics.
public static let network = StatCategory(rawValue: 1 << 4)
/// Memory event counters (OOM kills, pressure events, etc.).
public static let memoryEvents = StatCategory(rawValue: 1 << 5)
/// All available statistics categories.
public static let all: StatCategory = [.process, .memory, .cpu, .blockIO, .network, .memoryEvents]
}
@@ -0,0 +1,91 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
/// DNS configuration for a container. The values will be used to
/// construct /etc/resolv.conf for a given container.
public struct DNS: Sendable {
/// The set of default nameservers to use if none are provided
/// in the constructor.
public static let defaultNameservers = ["1.1.1.1"]
/// The nameservers a container should use.
public var nameservers: [String]
/// The DNS domain to use.
public var domain: String?
/// The DNS search domains to use.
public var searchDomains: [String]
/// The DNS options to use.
public var options: [String]
public init(
nameservers: [String] = defaultNameservers,
domain: String? = nil,
searchDomains: [String] = [],
options: [String] = []
) {
self.nameservers = nameservers
self.domain = domain
self.searchDomains = searchDomains
self.options = options
}
/// Validates the DNS configuration.
///
/// Ensures that all nameserver entries are valid IPv4 or IPv6 addresses.
/// Arbitrary hostnames are not permitted as nameservers.
///
/// - Throws: ``ContainerizationError`` with code `.invalidArgument` if
/// any nameserver is not a valid IP address.
public func validate() throws {
for nameserver in nameservers {
let isValidIPv4 = (try? IPv4Address(nameserver)) != nil
let isValidIPv6 = (try? IPv6Address(nameserver)) != nil
if !isValidIPv4 && !isValidIPv6 {
throw ContainerizationError(
.invalidArgument,
message: "nameserver '\(nameserver)' is not a valid IPv4 or IPv6 address"
)
}
}
}
}
extension DNS {
public var resolvConf: String {
var text = ""
if !nameservers.isEmpty {
text += nameservers.map { "nameserver \($0)" }.joined(separator: "\n") + "\n"
}
if let domain {
text += "domain \(domain)\n"
}
if !searchDomains.isEmpty {
text += "search \(searchDomains.joined(separator: " "))\n"
}
if !options.isEmpty {
text += "options \(options.joined(separator: " "))\n"
}
return text
}
}
+36
View File
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
/// ExitStatus contains the exit code for a given container process,
/// as well as the timestamp at which it exited.
public struct ExitStatus: Sendable {
/// The exit code for the process.
public var exitCode: Int32
/// The timestamp when the process exited.
public var exitedAt: Date
public init(exitCode: Int32) {
self.exitCode = exitCode
self.exitedAt = .now
}
public init(exitCode: Int32, exitedAt: Date) {
self.exitCode = exitCode
self.exitedAt = exitedAt
}
}
+202
View File
@@ -0,0 +1,202 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationOCI
import Foundation
/// Manages single-file mounts by transforming them into virtiofs directory shares
/// plus bind mounts.
///
/// Since virtiofs only supports sharing directories, mounting a single file requires
/// sharing the file's parent directory via virtiofs and then bind mounting the specific
/// file from that share to the final destination in the container.
struct FileMountContext: Sendable {
/// Metadata for a single prepared file mount.
struct PreparedMount: Sendable {
/// Original file path on host
let hostFilePath: String
/// Where the user wants the file in the container
let containerDestination: String
/// Just the filename (after resolving symlinks)
let filename: String
/// The parent directory containing the file (after resolving symlinks)
let parentDirectory: URL
/// The virtiofs tag (hash of parent dir path). Used to find the AttachedFilesystem
let tag: String
/// Mount options from the original mount
let options: [String]
/// Where we mounted the share in the guest (set after mountHoldingDirectories)
var guestHoldingPath: String?
}
/// Prepared file mounts for this context
var preparedMounts: [PreparedMount]
/// The transformed mounts to pass to the VM (files replaced with directory shares)
private(set) var transformedMounts: [Mount]
private init() {
self.preparedMounts = []
self.transformedMounts = []
}
/// Returns true if there are any file mounts that need handling.
var hasFileMounts: Bool {
!preparedMounts.isEmpty
}
/// Returns the set of virtiofs tags for file mount holding directories.
/// These should be filtered out from OCI spec mounts since we mount them
/// separately under /run.
var holdingDirectoryTags: Set<String> {
Set(preparedMounts.map { $0.tag })
}
}
extension FileMountContext {
/// Prepare mounts for a container, detecting file mounts and transforming them.
///
/// This method stats each virtiofs mount source. If it's a regular file rather than
/// a directory, it shares the file's parent directory via virtiofs and records the
/// metadata needed to bind mount the specific file later.
///
/// - Parameter mounts: The original mounts from the container config
/// - Returns: A FileMountContext containing transformed mounts and tracking info
static func prepare(mounts: [Mount]) throws -> FileMountContext {
var context = FileMountContext()
var transformed: [Mount] = []
// Track parent directories we've already added a share for to avoid duplicates.
var sharedParentTags: Set<String> = []
for mount in mounts {
// Only virtiofs mounts can be files
guard case .virtiofs(let runtimeOpts) = mount.runtimeOptions else {
transformed.append(mount)
continue
}
// Stat the source to see if it's a file
let fm = FileManager.default
var isDirectory: ObjCBool = false
guard fm.fileExists(atPath: mount.source, isDirectory: &isDirectory) else {
// Doesn't exist. Let the normal flow handle the error
transformed.append(mount)
continue
}
if isDirectory.boolValue {
// It's a directory, pass through unchanged
transformed.append(mount)
continue
}
// It's a file, so prepare it.
let prepared = try context.prepareFileMount(mount: mount, runtimeOptions: runtimeOpts)
// Only add the directory share once per unique parent directory.
if !sharedParentTags.contains(prepared.tag) {
sharedParentTags.insert(prepared.tag)
// The destination here is unused. We mount the share ourselves
// to a location under /run in mountHoldingDirectories.
let directoryShare = Mount.share(
source: prepared.parentDirectory.path,
destination: "/.file-mount-holding",
options: mount.options.filter { $0 != "bind" },
runtimeOptions: runtimeOpts
)
transformed.append(directoryShare)
}
}
context.transformedMounts = transformed
return context
}
private mutating func prepareFileMount(
mount: Mount,
runtimeOptions: [String]
) throws -> PreparedMount {
let resolvedSource = URL(fileURLWithPath: mount.source).resolvingSymlinksInPath()
let filename = resolvedSource.lastPathComponent
let parentDirectory = resolvedSource.deletingLastPathComponent()
let tag = try hashFilePath(path: parentDirectory.path)
let prepared = PreparedMount(
hostFilePath: mount.source,
containerDestination: mount.destination,
filename: filename,
parentDirectory: parentDirectory,
tag: tag,
options: mount.options,
guestHoldingPath: nil
)
preparedMounts.append(prepared)
return prepared
}
}
extension FileMountContext {
/// Set up the holding directory paths for all file mounts.
/// Since virtiofs shares are now mounted once at /run/virtiofs, the holding
/// directories appear as subdirectories there automatically.
/// - Parameters:
/// - vmMounts: The AttachedFilesystem array from the VM for this container
/// - agent: The VM agent for RPCs (unused, kept for API compatibility)
mutating func mountHoldingDirectories(
vmMounts: [AttachedFilesystem],
agent: any VirtualMachineAgent
) async throws {
for i in preparedMounts.indices {
let prepared = preparedMounts[i]
// Verify the attached filesystem exists
guard
vmMounts.first(where: {
$0.type == "virtiofs" && $0.source == prepared.tag
}) != nil
else {
throw ContainerizationError(
.notFound,
message: "could not find attached filesystem for file mount \(prepared.hostFilePath)"
)
}
// With unified virtiofs, holding directories are subdirectories under /run/virtiofs
let guestPath = "/run/virtiofs/\(prepared.tag)"
preparedMounts[i].guestHoldingPath = guestPath
}
}
}
extension FileMountContext {
/// Get the bind mounts to append to the OCI spec.
func ociBindMounts() -> [ContainerizationOCI.Mount] {
preparedMounts.compactMap { prepared in
guard let guestPath = prepared.guestHoldingPath else {
return nil
}
return ContainerizationOCI.Mount(
type: "none",
source: "\(guestPath)/\(prepared.filename)",
destination: prepared.containerDestination,
options: ["bind"] + prepared.options
)
}
}
}
+40
View File
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import Crypto
import Foundation
extension Mount {
/// A deterministic hash of the mount's source path, used as the virtiofs tag.
///
/// Resolves symlinks before hashing so that different paths to the same
/// directory produce an identical tag.
public var tagHash: String {
get throws {
try hashFilePath(path: self.source)
}
}
}
func hashFilePath(path: String) throws -> String {
// Resolve symlinks so different paths to the same directory get the same hash.
let resolvedSource = URL(fileURLWithPath: path).resolvingSymlinksInPath().path
guard let data = resolvedSource.data(using: .utf8) else {
throw ContainerizationError(.invalidArgument, message: "\(path) could not be converted to Data")
}
return String(SHA256.hash(data: data).encoded.prefix(36))
}
@@ -0,0 +1,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 `<linux/route.h>`. 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)
}
}
@@ -0,0 +1,140 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
/// Static table lookups for a container. The values will be used to
/// construct /etc/hosts for a given container.
public struct Hosts: Sendable {
/// Represents one entry in an /etc/hosts file.
public struct Entry: Sendable {
/// The IPV4 or IPV6 address in String form.
public var ipAddress: String
/// The hostname(s) for the entry.
public var hostnames: [String]
/// An optional comment to be placed to the right side of the entry.
public var comment: String?
public init(ipAddress: String, hostnames: [String], comment: String? = nil) {
self.comment = comment
self.hostnames = hostnames
self.ipAddress = ipAddress
}
/// The information in the structure rendered to a String representation
/// that matches the format /etc/hosts expects.
public var rendered: String {
var line = ipAddress
if !hostnames.isEmpty {
line += " " + hostnames.joined(separator: " ")
}
if let comment {
line += " # \(comment) "
}
return line
}
public static func localHostIPV4(comment: String? = nil) -> Self {
Self(
ipAddress: "127.0.0.1",
hostnames: ["localhost"],
comment: comment
)
}
public static func localHostIPV6(comment: String? = nil) -> Self {
Self(
ipAddress: "::1",
hostnames: ["localhost", "ip6-localhost", "ip6-loopback"],
comment: comment
)
}
public static func ipv6LocalNet(comment: String? = nil) -> Self {
Self(
ipAddress: "fe00::",
hostnames: ["ip6-localnet"],
comment: comment
)
}
public static func ipv6MulticastPrefix(comment: String? = nil) -> Self {
Self(
ipAddress: "ff00::",
hostnames: ["ip6-mcastprefix"],
comment: comment
)
}
public static func ipv6AllNodes(comment: String? = nil) -> Self {
Self(
ipAddress: "ff02::1",
hostnames: ["ip6-allnodes"],
comment: comment
)
}
public static func ipv6AllRouters(comment: String? = nil) -> Self {
Self(
ipAddress: "ff02::2",
hostnames: ["ip6-allrouters"],
comment: comment
)
}
}
/// The entries to be written to /etc/hosts.
public var entries: [Entry]
/// A comment to render at the top of the file.
public var comment: String?
public init(
entries: [Entry],
comment: String? = nil
) {
self.entries = entries
self.comment = comment
}
}
extension Hosts {
/// A default entry that can be used for convenience. It contains a IPV4
/// and IPV6 localhost entry, as well as ipv6 localnet, ipv6 mcastprefix,
/// ipv6 allnodes, and ipv6 allrouters.
public static let `default` = Hosts(entries: [
Entry.localHostIPV4(),
Entry.localHostIPV6(),
Entry.ipv6LocalNet(),
Entry.ipv6MulticastPrefix(),
Entry.ipv6AllNodes(),
Entry.ipv6AllRouters(),
])
/// Returns a string variant of the data that can be written to
/// /etc/hosts directly.
public var hostsFile: String {
var lines: [String] = []
if let comment {
lines.append("# \(comment)")
}
for entry in entries {
lines.append(entry.rendered)
}
return lines.joined(separator: "\n") + "\n"
}
}
@@ -0,0 +1,56 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
/// A provider that manages hotplug operations for a virtual machine instance.
///
/// Conforming types implement the mechanics of hotplugging block devices and
/// virtiofs shares into a running VM.
public protocol HotplugProvider: Sendable {
/// Hotplug a block device into the running VM.
/// - Parameters:
/// - block: The mount configuration for the block device
/// - id: The container ID to associate with this device
/// - Returns: The attached filesystem with the device path in the guest
func hotplug(_ block: Mount, id: String) async throws -> AttachedFilesystem
/// Register mounts for a container in the VM's mount registry.
/// - Parameters:
/// - id: The container ID
/// - rootfs: The rootfs attachment from hotplug
/// - additionalMounts: Additional mounts to register
func registerMounts(id: String, rootfs: AttachedFilesystem, additionalMounts: [Mount]) throws
/// Release a hotplug device.
/// - Parameter id: The container ID who should be released
func releaseHotplug(id: String) async throws
/// Hotplug virtiofs directories into the running VM.
/// - Parameters:
/// - mounts: The virtiofs mounts to add
/// - id: The container ID that owns these mounts
func hotplugVirtioFS(_ mounts: [Mount], id: String) async throws
/// Release virtiofs shares for a container.
/// - Parameter id: The container ID whose shares should be released
func releaseVirtioFS(id: String) async throws
/// Clean up resources held by the provider.
func cleanup()
}
extension HotplugProvider {
public func cleanup() {}
}
@@ -0,0 +1,22 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
/// A type that returns a stream of Data.
public protocol ReaderStream: Sendable {
func stream() -> AsyncStream<Data>
}
@@ -0,0 +1,36 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationOS
import Foundation
extension Terminal: ReaderStream {
public func stream() -> AsyncStream<Data> {
.init { cont in
self.handle.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
self.handle.readabilityHandler = nil
cont.finish()
return
}
cont.yield(data)
}
}
}
}
extension Terminal: Writer {}
+23
View File
@@ -0,0 +1,23 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
/// A type that writes the provided Data.
public protocol Writer: Sendable {
func write(_ data: Data) throws
func close() throws
}
+130
View File
@@ -0,0 +1,130 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
/// Type representing an OCI container image.
public struct Image: Sendable {
private let contentStore: ContentStore
/// The description for the image that comprises of its name and a reference to its root descriptor.
public let description: Description
/// A description of the OCI image.
public struct Description: Sendable {
/// The string reference of the image.
public let reference: String
/// The descriptor identifying the image.
public let descriptor: Descriptor
/// The digest for the image.
public var digest: String { descriptor.digest }
/// The media type of the image.
public var mediaType: String { descriptor.mediaType }
public init(reference: String, descriptor: Descriptor) {
self.reference = reference
self.descriptor = descriptor
}
}
/// The descriptor for the image.
public var descriptor: Descriptor { description.descriptor }
/// The digest of the image.
public var digest: String { description.digest }
/// The media type of the image.
public var mediaType: String { description.mediaType }
/// The string reference for the image.
public var reference: String { description.reference }
public init(description: Description, contentStore: ContentStore) {
self.description = description
self.contentStore = contentStore
}
/// Returns the underlying OCI index for the image.
public func index() async throws -> Index {
guard let content: Content = try await contentStore.get(digest: digest) else {
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
}
return try content.decode()
}
/// Returns the manifest for the specified platform.
public func manifest(for platform: Platform) async throws -> Manifest {
let index = try await self.index()
let desc = index.manifests.first { desc in
desc.platform == platform
}
guard let desc else {
throw ContainerizationError(.unsupported, message: "platform \(platform.description)")
}
guard let content: Content = try await contentStore.get(digest: desc.digest) else {
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
}
return try content.decode()
}
/// Returns the descriptor for the given platform. If it does not exist
/// will throw a ContainerizationError with the code set to .invalidArgument.
public func descriptor(for platform: Platform) async throws -> Descriptor {
let index = try await self.index()
let desc = index.manifests.first { $0.platform == platform }
guard let desc else {
throw ContainerizationError(.invalidArgument, message: "unsupported platform \(platform)")
}
return desc
}
/// Returns the OCI config for the specified platform.
public func config(for platform: Platform) async throws -> ContainerizationOCI.Image {
let manifest = try await self.manifest(for: platform)
let desc = manifest.config
guard let content: Content = try await contentStore.get(digest: desc.digest) else {
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
}
return try content.decode()
}
/// Returns a list of digests to all the referenced OCI objects.
public func referencedDigests() async throws -> [String] {
var referenced: [String] = [self.digest.trimmingDigestPrefix]
let index = try await self.index()
for manifest in index.manifests {
referenced.append(manifest.digest.trimmingDigestPrefix)
guard let m: Manifest = try? await contentStore.get(digest: manifest.digest) else {
// If the requested digest does not exist or is not a manifest. Skip.
// It's safe to skip processing this digest as it won't have any child layers.
continue
}
let descs = m.layers + [m.config]
referenced.append(contentsOf: descs.map { $0.digest.trimmingDigestPrefix })
}
return referenced
}
/// Returns a reference to the content blob for the image. The specified digest must be referenced by the image in one of its layers.
public func getContent(digest: String) async throws -> Content {
guard try await self.referencedDigests().contains(digest.trimmingDigestPrefix) else {
throw ContainerizationError(.internalError, message: "image \(self.reference) does not reference digest \(digest)")
}
guard let content: Content = try await contentStore.get(digest: digest) else {
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
}
return content
}
}
@@ -0,0 +1,179 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
//
import ContainerizationError
import ContainerizationExtras
import ContainerizationIO
import ContainerizationOCI
import Crypto
import Foundation
extension ImageStore {
public struct ExportOperation: Sendable {
let name: String
let tag: String
let contentStore: ContentStore
let client: ContentClient
let progress: ProgressHandler?
public init(name: String, tag: String, contentStore: ContentStore, client: ContentClient, progress: ProgressHandler? = nil) {
self.contentStore = contentStore
self.client = client
self.progress = progress
self.name = name
self.tag = tag
}
@discardableResult
public func export(index: Descriptor, platforms: (Platform) -> Bool, filter: (Descriptor) -> Bool = { _ in true }) async throws -> Descriptor {
var pushQueue: [[Descriptor]] = []
var current: [Descriptor] = [index]
while !current.isEmpty {
let children = try await self.getChildren(descs: current)
let matches = try filterPlatforms(matcher: platforms, children).uniqued { $0.digest }
pushQueue.append(matches)
current = matches
}
let localIndexData = try await self.createIndex(from: index, matching: platforms)
await updatePushProgress(pushQueue: pushQueue, localIndexData: localIndexData)
// We need to work bottom up when pushing an image.
// First, the tar blobs / config layers, then, the manifests and so on...
// When processing a given "level", the requests maybe made in parallel.
// We need to ensure that the child level has been uploaded fully
// before uploading the parent level.
try await withThrowingTaskGroup(of: Void.self) { group in
for layerGroup in pushQueue.reversed() {
for chunk in layerGroup.chunks(ofCount: 8) {
for desc in chunk.filter(filter) {
guard let content = try await self.contentStore.get(digest: desc.digest) else {
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
}
group.addTask {
let readStream = try ReadStream(url: content.path)
try await self.pushContent(descriptor: desc, stream: readStream)
}
}
try await group.waitForAll()
}
}
}
// Lastly, we need to construct and push a new index, since we may
// have pushed content only for specific platforms.
let digest = SHA256.hash(data: localIndexData)
// The descriptor's mediaType becomes the HTTP Content-Type in
// RegistryClient.push and must match the mediaType field inside
// localIndexData. Registries reject mismatches with MANIFEST_INVALID.
let descriptor = Descriptor(
mediaType: index.mediaType,
digest: digest.digestString,
size: Int64(localIndexData.count))
let stream = ReadStream(data: localIndexData)
try await self.pushContent(descriptor: descriptor, stream: stream)
return descriptor
}
private func updatePushProgress(pushQueue: [[Descriptor]], localIndexData: Data) async {
for layerGroup in pushQueue {
for desc in layerGroup {
await progress?([
.addTotalSize(desc.size),
.addTotalItems(1),
])
}
}
await progress?([
.addTotalSize(Int64(localIndexData.count)),
.addTotalItems(1),
])
}
private func createIndex(from index: Descriptor, matching: (Platform) -> Bool) async throws -> Data {
guard let content = try await self.contentStore.get(digest: index.digest) else {
throw ContainerizationError(.notFound, message: "content with digest \(index.digest)")
}
var idx: Index = try content.decode()
let manifests = idx.manifests
var matchedManifests: [Descriptor] = []
var skippedPlatforms = false
for manifest in manifests {
guard let p = manifest.platform else {
continue
}
if matching(p) {
matchedManifests.append(manifest)
} else {
skippedPlatforms = true
}
}
if !skippedPlatforms {
return try content.data()
}
idx.manifests = matchedManifests
return try JSONEncoder().encode(idx)
}
private func pushContent(descriptor: Descriptor, stream: ReadStream) async throws {
do {
let generator = {
try stream.reset()
return stream.stream
}
try await client.push(name: name, ref: tag, descriptor: descriptor, streamGenerator: generator, progress: progress)
await progress?([
.addSize(descriptor.size),
.addItems(1),
])
} catch let err as ContainerizationError {
guard err.code != .exists else {
// We reported the total items and size and have to account for them in existing content.
await progress?([
.addSize(descriptor.size),
.addItems(1),
])
return
}
throw err
}
}
private func getChildren(descs: [Descriptor]) async throws -> [Descriptor] {
var out: [Descriptor] = []
for desc in descs {
let mediaType = desc.mediaType
guard let content = try await self.contentStore.get(digest: desc.digest) else {
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
}
switch mediaType {
case MediaTypes.index, MediaTypes.dockerManifestList:
let index: Index = try content.decode()
out.append(contentsOf: index.manifests)
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
let manifest: Manifest = try content.decode()
out.append(manifest.config)
out.append(contentsOf: manifest.layers)
default:
continue
}
}
return out
}
}
}
@@ -0,0 +1,257 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
//
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
extension ImageStore {
public struct ImportOperation: Sendable {
static let decoder = JSONDecoder()
let client: ContentClient
let ingestDir: URL
let contentStore: ContentStore
let progress: ProgressHandler?
let name: String
let maxConcurrentDownloads: Int
public init(name: String, contentStore: ContentStore, client: ContentClient, ingestDir: URL, progress: ProgressHandler? = nil, maxConcurrentDownloads: Int = 3) {
self.client = client
self.ingestDir = ingestDir
self.contentStore = contentStore
self.progress = progress
self.name = name
self.maxConcurrentDownloads = maxConcurrentDownloads
}
/// Pull the required image layers for the provided descriptor and platform(s) into the given directory using the provided client. Returns a descriptor to the Index manifest.
public func `import`(root: Descriptor, matcher: (ContainerizationOCI.Platform) -> Bool) async throws -> Descriptor {
var toProcess = [root]
while !toProcess.isEmpty {
// Count the total number of blobs and their size
if let progress {
var size: Int64 = 0
for desc in toProcess {
size += desc.size
}
await progress([
.addTotalSize(size),
.addTotalItems(toProcess.count),
])
}
try await self.fetchAll(toProcess)
let children = try await self.walk(toProcess)
let filtered = try filterPlatforms(matcher: matcher, children)
toProcess = filtered.uniqued { $0.digest }
}
guard root.mediaType != MediaTypes.dockerManifestList && root.mediaType != MediaTypes.index else {
return root
}
// Create an index for the root descriptor and write it to the content store
let index = try await self.createIndex(for: root)
// In cases where the root descriptor pointed to `MediaTypes.imageManifest`
// Or `MediaTypes.dockerManifest`, it is required that we check the supported platform
// matches the platforms we were asked to pull. This can be done only after we created
// the Index.
let supportedPlatforms = index.manifests.compactMap { $0.platform }
guard supportedPlatforms.allSatisfy(matcher) else {
throw ContainerizationError(.unsupported, message: "image \(root.digest) does not support required platforms")
}
let writer = try ContentWriter(for: self.ingestDir)
let result = try writer.create(from: index)
return Descriptor(
mediaType: MediaTypes.index,
digest: result.digest.digestString,
size: Int64(result.size))
}
private func getManifestContent<T: Sendable & Codable>(descriptor: Descriptor) async throws -> T {
do {
if let content = try await self.contentStore.get(digest: descriptor.digest.trimmingDigestPrefix) {
return try content.decode()
}
if let content = try? LocalContent(path: ingestDir.appending(path: descriptor.digest.trimmingDigestPrefix)) {
return try content.decode()
}
return try await self.client.fetch(name: name, descriptor: descriptor)
} catch {
throw ContainerizationError(.internalError, message: "cannot fetch content with digest \(descriptor.digest)", cause: error)
}
}
private func walk(_ descriptors: [Descriptor]) async throws -> [Descriptor] {
var out: [Descriptor] = []
for desc in descriptors {
let mediaType = desc.mediaType
switch mediaType {
case MediaTypes.index, MediaTypes.dockerManifestList:
let index: Index = try await self.getManifestContent(descriptor: desc)
out.append(contentsOf: index.manifests)
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
let manifest: Manifest = try await self.getManifestContent(descriptor: desc)
out.append(manifest.config)
out.append(contentsOf: manifest.layers)
default:
// TODO: Explicitly handle other content types
continue
}
}
return out
}
private func fetchAll(_ descriptors: [Descriptor]) async throws {
try await withThrowingTaskGroup(of: Void.self) { group in
var iterator = descriptors.makeIterator()
// Start initial batch of concurrent downloads based on maxConcurrentDownloads
for _ in 0..<self.maxConcurrentDownloads {
if let desc = iterator.next() {
group.addTask {
try await self.fetch(desc)
}
}
}
// As tasks complete, add new ones to maintain concurrency
for try await _ in group {
if let desc = iterator.next() {
group.addTask {
try await self.fetch(desc)
}
}
}
}
}
private func fetch(_ descriptor: Descriptor) async throws {
if let found = try await self.contentStore.get(digest: descriptor.digest) {
try FileManager.default.copyItem(at: found.path, to: ingestDir.appendingPathComponent(descriptor.digest.trimmingDigestPrefix))
await progress?([
// Count the size of the blob
.addSize(descriptor.size),
// Count the number of blobs
.addItems(1),
])
return
}
if descriptor.size > 1.mib() {
try await self.fetchBlob(descriptor)
} else {
try await self.fetchData(descriptor)
}
// Count the number of blobs
await progress?([
.addItems(1)
])
}
private func fetchBlob(_ descriptor: Descriptor) async throws {
let id = UUID().uuidString
let fm = FileManager.default
let tempFile = ingestDir.appendingPathComponent(id)
let (_, digest) = try await client.fetchBlob(name: name, descriptor: descriptor, into: tempFile, progress: progress)
guard digest.digestString == descriptor.digest else {
throw ContainerizationError(.internalError, message: "digest mismatch expected \(descriptor.digest), got \(digest.digestString)")
}
do {
try fm.moveItem(at: tempFile, to: ingestDir.appendingPathComponent(digest.encoded))
} catch let err as NSError {
guard err.code == NSFileWriteFileExistsError else {
throw err
}
try fm.removeItem(at: tempFile)
}
}
@discardableResult
private func fetchData(_ descriptor: Descriptor) async throws -> Data {
let data = try await client.fetchData(name: name, descriptor: descriptor)
let writer = try ContentWriter(for: ingestDir)
let result = try writer.write(data)
if let progress {
let size = Int64(result.size)
await progress([
.addSize(size)
])
}
guard result.digest.digestString == descriptor.digest else {
throw ContainerizationError(.internalError, message: "digest mismatch expected \(descriptor.digest), got \(result.digest.digestString)")
}
return data
}
private func createIndex(for root: Descriptor) async throws -> Index {
switch root.mediaType {
case MediaTypes.index, MediaTypes.dockerManifestList:
return try await self.getManifestContent(descriptor: root)
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
let supportedPlatforms = try await getSupportedPlatforms(for: root)
guard supportedPlatforms.count == 1 else {
throw ContainerizationError(
.internalError,
message:
"descriptor \(root.mediaType) with digest \(root.digest) does not list any supported platform or supports more than one platform, supported platforms: \(supportedPlatforms)"
)
}
let platform = supportedPlatforms.first!
var root = root
root.platform = platform
let index = ContainerizationOCI.Index(
schemaVersion: 2, manifests: [root],
annotations: [
// indicate that this is a synthesized index which is not directly user facing
AnnotationKeys.containerizationIndexIndirect: "true"
])
return index
default:
throw ContainerizationError(.internalError, message: "failed to create index for descriptor \(root.digest), media type \(root.mediaType)")
}
}
private func getSupportedPlatforms(for root: Descriptor) async throws -> [ContainerizationOCI.Platform] {
var supportedPlatforms: [ContainerizationOCI.Platform] = []
var toProcess = [root]
while !toProcess.isEmpty {
let children = try await self.walk(toProcess)
for child in children {
if let p = child.platform {
supportedPlatforms.append(p)
continue
}
switch child.mediaType {
case MediaTypes.imageConfig, MediaTypes.dockerImageConfig:
let config: ContainerizationOCI.Image = try await self.getManifestContent(descriptor: child)
let p = ContainerizationOCI.Platform(
arch: config.architecture, os: config.os, osFeatures: config.osFeatures, variant: config.variant
)
supportedPlatforms.append(p)
default:
continue
}
}
toProcess = children
}
return supportedPlatforms
}
}
}
@@ -0,0 +1,110 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
extension ImageStore {
/// Exports the specified images and their associated layers to an OCI Image Layout directory.
/// This function saves the images identified by the `references` array, including their
/// manifests and layer blobs, into a directory structure compliant with the OCI Image Layout specification at the given `out` URL.
///
/// - Parameters:
/// - references: A list image references that exists in the `ImageStore` that are to be saved in the OCI Image Layout format.
/// - out: A URL to a directory on disk at which the OCI Image Layout structure will be created.
/// - platform: An optional parameter to indicate the platform to be saved for the images.
/// Defaults to `nil` signifying that layers for all supported platforms by the images will be saved.
///
public func save(references: [String], out: URL, platform: Platform? = nil) async throws {
let matcher = createPlatformMatcher(for: platform)
let fileManager = FileManager.default
let tempDir = fileManager.uniqueTemporaryDirectory()
defer {
try? fileManager.removeItem(at: tempDir)
}
var toSave: [Image] = []
for reference in references {
let image = try await self.get(reference: reference)
let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index]
guard allowedMediaTypes.contains(image.mediaType) else {
throw ContainerizationError(.internalError, message: "cannot save image \(image.reference) with Index media type \(image.mediaType)")
}
toSave.append(image)
}
let client = try LocalOCILayoutClient(root: out)
var saved: [Descriptor] = []
for image in toSave {
let ref = try Reference.parse(image.reference)
let name = ref.path
guard let tag = ref.tag ?? ref.digest else {
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(image.reference)")
}
let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: nil)
var descriptor = try await operation.export(index: image.descriptor, platforms: matcher)
client.setImageReferenceAnnotation(descriptor: &descriptor, reference: image.reference)
saved.append(descriptor)
}
try client.createOCILayoutStructure(directory: out, manifests: saved)
}
/// Imports one or more images and their associated layers from an OCI Image Layout directory.
///
/// - Parameters:
/// - directory: A URL to a directory on disk at that follows the OCI Image Layout structure.
/// - progress: An optional handler over which progress update events about the load operation can be received.
/// - Returns: The list of images that were loaded into the `ImageStore`.
///
public func load(from directory: URL, progress: ProgressHandler? = nil) async throws -> [Image] {
let client = try LocalOCILayoutClient(root: directory)
let index = try client.loadIndexFromOCILayout(directory: directory)
let matcher = createPlatformMatcher(for: nil)
var loaded: [Image.Description] = []
let (id, tempDir) = try await self.contentStore.newIngestSession()
do {
for descriptor in index.manifests {
let reference = client.getImageReferencefromDescriptor(descriptor: descriptor)
let ref = try Reference.parse(reference)
let name = ref.path
let operation = ImportOperation(name: name, contentStore: self.contentStore, client: client, ingestDir: tempDir, progress: progress)
let indexDesc = try await operation.import(root: descriptor, matcher: matcher)
loaded.append(Image.Description(reference: reference, descriptor: indexDesc))
}
let loadedImages = loaded
let importedImages = try await self.lock.withLock { lock in
var images: [Image] = []
try await self.contentStore.completeIngestSession(id)
for description in loadedImages {
let img = try await self._create(description: description, lock: lock)
images.append(img)
}
return images
}
guard importedImages.count > 0 else {
throw ContainerizationError(.internalError, message: "failed to import image")
}
return importedImages
} catch {
try? await self.contentStore.cancelIngestSession(id)
throw error
}
}
}
@@ -0,0 +1,90 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationOCI
import Foundation
extension ImageStore {
/// A ReferenceManager handles the mappings between an image's
/// reference and the underlying descriptor inside of a content store.
internal actor ReferenceManager {
private let path: URL
private typealias State = [String: Descriptor]
private var images: State
public init(path: URL) throws {
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
self.path = path
self.images = [:]
}
private func load() throws -> State {
let statePath = self.path.appendingPathComponent("state.json")
guard FileManager.default.fileExists(atPath: statePath.absolutePath()) else {
return [:]
}
do {
let data = try Data(contentsOf: statePath)
return try JSONDecoder().decode(State.self, from: data)
} catch {
throw ContainerizationError(.internalError, message: "failed to load image state \(error.localizedDescription)")
}
}
private func save(_ state: State) throws {
let statePath = self.path.appendingPathComponent("state.json")
try JSONEncoder().encode(state).write(to: statePath)
}
public func delete(reference: String) throws {
var state = try self.load()
state.removeValue(forKey: reference)
try self.save(state)
}
public func delete(image: Image.Description) throws {
try self.delete(reference: image.reference)
}
public func create(description: Image.Description) throws {
var state = try self.load()
state[description.reference] = description.descriptor
try self.save(state)
}
public func list() throws -> [Image.Description] {
let state = try self.load()
return state.map { key, val in
let description = Image.Description(reference: key, descriptor: val)
return description
}
}
public func get(reference: String) throws -> Image.Description {
let images = try self.list()
let hit = images.first(where: { image in
image.reference == reference
})
guard let hit else {
throw ContainerizationError(.notFound, message: "image \(reference) not found")
}
return hit
}
}
}
@@ -0,0 +1,395 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
/// An ImageStore handles the mappings between an image's
/// reference and the underlying descriptor inside of a content store.
public actor ImageStore: Sendable {
/// The ImageStore path it was created with.
public nonisolated let path: URL
private let referenceManager: ReferenceManager
internal let contentStore: ContentStore
internal let lock: AsyncLock = AsyncLock()
public init(path: URL, contentStore: ContentStore? = nil) throws {
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
if let contentStore {
self.contentStore = contentStore
} else {
self.contentStore = try LocalContentStore(path: path.appendingPathComponent("content"))
}
self.path = path
self.referenceManager = try ReferenceManager(path: path)
}
/// Return the default image store for the current user.
public static let `default`: ImageStore = {
do {
let root = try defaultRoot()
return try ImageStore(path: root)
} catch {
fatalError("unable to initialize default ImageStore \(error)")
}
}()
private static func defaultRoot() throws -> URL {
let root = FileManager.default.urls(
for: .applicationSupportDirectory,
in: .userDomainMask
).first
guard let root else {
throw ContainerizationError(.notFound, message: "unable to get Application Support directory for current user")
}
return root.appendingPathComponent("com.apple.containerization")
}
}
extension ImageStore {
/// Get an image from the `ImageStore`.
///
/// - Parameters:
/// - reference: Name of the image.
/// - pull: Pull the image if it is not found.
///
/// - Returns: A `Containerization.Image` object whose `reference` matches the given string.
/// This method throws a `ContainerizationError(code: .notFound)` if the provided reference does not exist in the `ImageStore`.
public func get(reference: String, pull: Bool = false) async throws -> Image {
do {
let desc = try await self.referenceManager.get(reference: reference)
return Image(description: desc, contentStore: self.contentStore)
} catch let error as ContainerizationError {
if error.code == .notFound && pull {
return try await self.pull(reference: reference)
}
throw error
}
}
/// Get a list of all images in the `ImageStore`.
///
/// - Returns: A `[Containerization.Image]` for all the images in the `ImageStore`.
public func list() async throws -> [Image] {
try await self.referenceManager.list().map { desc in
Image(description: desc, contentStore: self.contentStore)
}
}
/// Create a new image in the `ImageStore`.
///
/// - Parameters:
/// - description: The underlying `Image.Description` that contains information about the reference and index descriptor for the image to be created.
///
/// - Note: It is assumed that the underlying manifests and blob layers for the image already exists in the `ContentStore` that the `ImageStore` was initialized with. This method is invoked when the `pull(...)` , `load(...)` and `tag(...)` methods are used.
/// - Returns: A `Containerization.Image`
@discardableResult
public func create(description: Image.Description) async throws -> Image {
try await self.lock.withLock { ctx in
try await self._create(description: description, lock: ctx)
}
}
@discardableResult
internal func _create(description: Image.Description, lock: AsyncLock.Context) async throws -> Image {
try await self.referenceManager.create(description: description)
return Image(description: description, contentStore: self.contentStore)
}
/// Delete an image from the `ImageStore`.
///
/// - Parameters:
/// - reference: Name of the image that is to be deleted.
/// - performCleanup: Perform a garbage collection on the `ContentStore`, removing all unreferenced image layers and manifests,
public func delete(reference: String, performCleanup: Bool = false) async throws {
try await self.lock.withLock { lockCtx in
try await self.referenceManager.delete(reference: reference)
if performCleanup {
try await self._cleanUpOrphanedBlobs(lockCtx)
}
}
}
/// Clean up orphaned blobs that are no longer referenced by any image.
///
/// - Returns: Returns a tuple of `(deleted, freed)`.
/// `deleted` : A list of the names of the content items that were deleted from the `ContentStore`,
/// `freed` : The total size of the items that were deleted.
@discardableResult
public func cleanUpOrphanedBlobs() async throws -> (deleted: [String], freed: UInt64) {
try await self.lock.withLock { lockCtx in
try await self._cleanUpOrphanedBlobs(lockCtx)
}
}
/// Calculate the size of orphaned blobs without deleting them.
///
/// - Returns: The total size in bytes of blobs that are not referenced by any image.
public func calculateOrphanedBlobsSize() async throws -> UInt64 {
try await self.lock.withLock { lockCtx in
try await self._calculateOrphanedBlobsSize(lockCtx)
}
}
@discardableResult
private func _cleanUpOrphanedBlobs(_ lock: AsyncLock.Context) async throws -> (deleted: [String], freed: UInt64) {
let images = try await self.list()
var referenced: [String] = []
for image in images {
try await referenced.append(contentsOf: image.referencedDigests().uniqued())
}
let (deleted, size) = try await self.contentStore.delete(keeping: referenced)
return (deleted, size)
}
private func _calculateOrphanedBlobsSize(_ lock: AsyncLock.Context) async throws -> UInt64 {
let images = try await self.list()
var referenced: [String] = []
for image in images {
try await referenced.append(contentsOf: image.referencedDigests().uniqued())
}
// Calculate size of blobs not in the referenced list
let referencedSet = Set(referenced.map { $0.trimmingDigestPrefix })
let blobsPath = self.path.appendingPathComponent("content/blobs/sha256")
let fileManager = FileManager.default
let allBlobs = try fileManager.contentsOfDirectory(
at: blobsPath,
includingPropertiesForKeys: [.fileSizeKey],
options: [.skipsHiddenFiles]
)
var orphanedSize: UInt64 = 0
for blobURL in allBlobs {
let digest = blobURL.lastPathComponent
if !referencedSet.contains(digest) {
if let resourceValues = try? blobURL.resourceValues(forKeys: [.fileSizeKey]),
let size = resourceValues.fileSize
{
orphanedSize += UInt64(size)
}
}
}
return orphanedSize
}
/// Tag an existing image such that it can be referenced by another name.
///
/// - Parameters:
/// - existing: The reference to an image that already exists in the `ImageStore`.
/// - new: The new reference by which the image should also be referenced as.
/// - Note: The new image created in the `ImageStore` will have the same `Image.Description`
/// as that of the image with reference `existing.`
/// - Returns: A `Containerization.Image` object to the newly created image.
public func tag(existing: String, new: String) async throws -> Image {
let old = try await self.get(reference: existing)
let descriptor = old.descriptor
do {
_ = try Reference.parse(new)
} catch {
throw ContainerizationError(.invalidArgument, message: "invalid reference \(new), error: \(error)")
}
let newDescription = Image.Description(reference: new, descriptor: descriptor)
return try await self.create(description: newDescription)
}
}
extension ImageStore {
/// Pull an image and its associated manifest and blob layers from a remote registry.
///
/// - Parameters:
/// - reference: A string that references an image in a remote registry of the form `<host>[:<port>]/repository:<tag>`
/// For example: "docker.io/library/alpine:latest".
/// - platform: An optional parameter to indicate the platform to be pulled for the image.
/// Defaults to `nil` signifying that layers for all supported platforms by the image will be pulled.
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
/// Defaults to false, meaning the connection to the registry will be over https.
/// - auth: An object that implements the `Authentication` protocol,
/// used to add any credentials to the HTTP requests that are made to the registry.
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
/// - progress: An optional handler over which progress update events about the pull operation can be received.
///
/// - Returns: A `Containerization.Image` object to the newly pulled image.
public func pull(
reference: String, platform: Platform? = nil, insecure: Bool = false,
auth: Authentication? = nil, progress: ProgressHandler? = nil, maxConcurrentDownloads: Int = 3
) async throws -> Image {
let matcher = createPlatformMatcher(for: platform)
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
let ref = try Reference.parse(reference)
let name = ref.path
guard let tag = ref.tag ?? ref.digest else {
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)")
}
let rootDescriptor = try await client.resolve(name: name, tag: tag)
let (id, tempDir) = try await self.contentStore.newIngestSession()
let operation = ImportOperation(
name: name, contentStore: self.contentStore, client: client, ingestDir: tempDir, progress: progress, maxConcurrentDownloads: maxConcurrentDownloads)
do {
let index = try await operation.import(root: rootDescriptor, matcher: matcher)
return try await self.lock.withLock { lock in
try await self.contentStore.completeIngestSession(id)
let description = Image.Description(reference: reference, descriptor: index)
let image = try await self._create(description: description, lock: lock)
return image
}
} catch {
try? await self.contentStore.cancelIngestSession(id)
throw error
}
}
/// Push an image and its associated manifest and blob layers to a remote registry.
///
/// - Parameters:
/// - reference: A string that references an image in the `ImageStore`. It must be of the form `<host>[:<port>]/repository:<tag>`
/// For example: "ghcr.io/foo-bar-baz/image:v1".
/// - platform: An optional parameter to indicate the platform to be pushed for the image.
/// Defaults to `nil` signifying that layers for all supported platforms by the image will be pushed to the remote registry.
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
/// Defaults to false, meaning the connection to the registry will be over https.
/// - auth: An object that implements the `Authentication` protocol,
/// used to add any credentials to the HTTP requests that are made to the registry.
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
/// - progress: An optional handler over which progress update events about the push operation can be received.
///
public func push(reference: String, platform: Platform? = nil, insecure: Bool = false, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws {
let matcher = createPlatformMatcher(for: platform)
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
}
/// Push multiple image references to a remote registry, sharing a single ``RegistryClient``.
///
/// All references must resolve to the same registry host. Passing references that target
/// different hosts throws a ``ContainerizationError`` with code ``invalidArgument``.
///
/// - Parameters:
/// - references: An array of fully qualified image reference strings to push.
/// Each must include a host (e.g., `"ghcr.io/myrepo/myimage:v1"`).
/// - platform: An optional parameter to indicate the platform to be pushed for each image.
/// Defaults to `nil` signifying that layers for all supported platforms will be pushed.
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
/// Defaults to false, meaning the connection to the registry will be over https.
/// - auth: An object that implements the `Authentication` protocol,
/// used to add any credentials to the HTTP requests that are made to the registry.
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
/// - maxConcurrentUploads: Maximum number of concurrent tag pushes. Defaults to 3.
/// - progress: An optional handler over which progress update events about the push operations can be received.
///
public func push(
references: [String], platform: Platform? = nil, insecure: Bool = false,
auth: Authentication? = nil, maxConcurrentUploads: Int = 3, progress: ProgressHandler? = nil
) async throws {
guard let firstReference = references.first else {
return
}
// Parse all references upfront: validate hosts and avoid re-parsing inside tasks.
let parsed = try references.map { ref in try Reference.parse(ref) }
let hosts = parsed.compactMap { $0.resolvedDomain }
guard hosts.count == references.count else {
throw ContainerizationError(.invalidArgument, message: "all references must include a host")
}
let uniqueHosts = Set(hosts)
guard uniqueHosts.count == 1 else {
throw ContainerizationError(
.invalidArgument,
message: "all references must target the same registry host, got: \(uniqueHosts.sorted().joined(separator: ", "))")
}
let matcher = createPlatformMatcher(for: platform)
let client = try RegistryClient(
reference: firstReference, insecure: insecure, auth: auth,
tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
let pushOne: @Sendable (String) async -> (String, String?) = { reference in
do {
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
return (reference, nil)
} catch {
return (reference, String(describing: error))
}
}
var iterator = references.makeIterator()
var failures: [(reference: String, message: String)] = []
await withTaskGroup(of: (String, String?).self) { group in
for _ in 0..<maxConcurrentUploads {
guard let reference = iterator.next() else { break }
group.addTask { await pushOne(reference) }
}
for await (ref, error) in group {
if let error {
failures.append((ref, error))
}
if let reference = iterator.next() {
group.addTask { await pushOne(reference) }
}
}
}
if !failures.isEmpty {
let details = failures.map { "\($0.reference): \($0.message)" }.joined(separator: "\n")
throw ContainerizationError(.internalError, message: "failed to push one or more images:\n\(details)")
}
}
private func pushSingle(
reference: String, client: ContentClient, matcher: @Sendable (Platform) -> Bool, progress: ProgressHandler?
) async throws {
let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index]
let img = try await self.get(reference: reference)
guard allowedMediaTypes.contains(img.mediaType) else {
throw ContainerizationError(.internalError, message: "cannot push image \(reference): unsupported media type \(img.mediaType), expected an index or manifest list")
}
let ref = try Reference.parse(reference)
guard let tag = ref.tag ?? ref.digest else {
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)")
}
let operation = ExportOperation(name: ref.path, tag: tag, contentStore: self.contentStore, client: client, progress: progress)
try await operation.export(index: img.descriptor, platforms: matcher)
}
}
extension ImageStore {
/// Get the image for the init block from the image store.
/// If the image does not exist locally, pull the image.
public func getInitImage(reference: String, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws -> InitImage {
do {
let image = try await self.get(reference: reference)
return InitImage(image: image)
} catch let error as ContainerizationError {
if error.code == .notFound {
let image = try await self.pull(reference: reference, auth: auth, progress: progress)
return InitImage(image: image)
}
throw error
}
}
}
@@ -0,0 +1,85 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationOCI
import Foundation
/// Data representing the image to use as the root filesystem for a virtual machine.
/// Typically this image would contain the guest agent used to facilitate container
/// workloads, as well as any extras that may be useful to have in the guest.
public struct InitImage: Sendable {
public var name: String { image.reference }
let image: Image
public init(image: Image) {
self.image = image
}
}
extension InitImage {
/// Unpack the initial filesystem for the desired platform at a given path.
public func initBlock(at: URL, for platform: SystemPlatform) async throws -> Mount {
let unpacker = EXT4Unpacker(blockSizeInBytes: 512.mib())
var fs = try await unpacker.unpack(self.image, for: platform.ociPlatform(), at: at)
fs.options = ["ro"]
return fs
}
/// Create a new InitImage with the reference as the name.
/// The `rootfs` parameter must be a tar.gz file whose contents make up the filesystem for the image.
public static func create(
reference: String, rootfs: URL, platform: Platform,
labels: [String: String] = [:], imageStore: ImageStore, contentStore: ContentStore
) async throws -> InitImage {
let indexDescriptorStore = AsyncStore<Descriptor>()
try await contentStore.ingest { dir in
let writer = try ContentWriter(for: dir)
var result = try writer.create(from: rootfs)
let layerDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageLayerGzip, digest: result.digest.digestString, size: result.size)
// TODO: compute and fill in the correct diffID for the above layer
// We currently put in the sha of the fully compressed layer, this needs to be replaced with
// the sha of the uncompressed layer.
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [result.digest.digestString])
let runtimeConfig = ContainerizationOCI.ImageConfig(labels: labels)
let imageConfig = ContainerizationOCI.Image(architecture: platform.architecture, os: platform.os, config: runtimeConfig, rootfs: rootfsConfig)
result = try writer.create(from: imageConfig)
let configDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageConfig, digest: result.digest.digestString, size: result.size)
let manifest = Manifest(config: configDescriptor, layers: [layerDescriptor])
result = try writer.create(from: manifest)
let manifestDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageManifest, digest: result.digest.digestString, size: result.size, platform: platform)
let index = ContainerizationOCI.Index(manifests: [manifestDescriptor])
result = try writer.create(from: index)
let indexDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.index, digest: result.digest.digestString, size: result.size)
await indexDescriptorStore.set(indexDescriptor)
}
guard let indexDescriptor = await indexDescriptorStore.get() else {
throw ContainerizationError(.notFound, message: "image for \(reference) not found")
}
let description = Image.Description(reference: reference, descriptor: indexDescriptor)
let image = try await imageStore.create(description: description)
return InitImage(image: image)
}
}
@@ -0,0 +1,94 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationOCI
import Foundation
/// A multi-arch kernel image represented by an OCI image.
public struct KernelImage: Sendable {
/// The media type for a kernel image.
public static let mediaType = "application/vnd.apple.containerization.kernel"
/// The name or reference of the image.
public var name: String { image.reference }
let image: Image
public init(image: Image) {
self.image = image
}
}
extension KernelImage {
/// Return the kernel from a multi arch image for a specific system platform.
public func kernel(for platform: SystemPlatform) async throws -> Kernel {
let manifest = try await image.manifest(for: platform.ociPlatform())
guard let descriptor = manifest.layers.first, descriptor.mediaType == Self.mediaType else {
throw ContainerizationError(.notFound, message: "kernel descriptor for \(platform) not found")
}
let content = try await image.getContent(digest: descriptor.digest)
return Kernel(
path: content.path,
platform: platform
)
}
/// Create a new kernel image with the reference as the name.
/// This will create a multi arch image containing kernel's for each provided architecture.
public static func create(reference: String, binaries: [Kernel], labels: [String: String] = [:], imageStore: ImageStore, contentStore: ContentStore) async throws -> KernelImage
{
let indexDescriptorStore = AsyncStore<Descriptor>()
try await contentStore.ingest { ingestPath in
var descriptors = [Descriptor]()
let writer = try ContentWriter(for: ingestPath)
for kernel in binaries {
var result = try writer.create(from: kernel.path)
let platform = kernel.platform.ociPlatform()
let layerDescriptor = Descriptor(
mediaType: mediaType,
digest: result.digest.digestString,
size: result.size,
platform: platform)
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [result.digest.digestString])
let runtimeConfig = ContainerizationOCI.ImageConfig(labels: labels)
let imageConfig = ContainerizationOCI.Image(architecture: platform.architecture, os: platform.os, config: runtimeConfig, rootfs: rootfsConfig)
result = try writer.create(from: imageConfig)
let configDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageConfig, digest: result.digest.digestString, size: result.size)
let manifest = Manifest(config: configDescriptor, layers: [layerDescriptor])
result = try writer.create(from: manifest)
let manifestDescriptor = Descriptor(
mediaType: ContainerizationOCI.MediaTypes.imageManifest, digest: result.digest.digestString, size: result.size, platform: platform)
descriptors.append(manifestDescriptor)
}
let index = ContainerizationOCI.Index(manifests: descriptors)
let result = try writer.create(from: index)
let indexDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.index, digest: result.digest.digestString, size: result.size)
await indexDescriptorStore.set(indexDescriptor)
}
guard let indexDescriptor = await indexDescriptorStore.get() else {
throw ContainerizationError(.notFound, message: "image for \(reference) not found")
}
let description = Image.Description(reference: reference, descriptor: indexDescriptor)
let image = try await imageStore.create(description: description)
return KernelImage(image: image)
}
}
@@ -0,0 +1,169 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationArchive
import ContainerizationEXT4
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import SystemPackage
public struct EXT4Unpacker: Unpacker {
let blockSizeInBytes: UInt64
let journal: EXT4.JournalConfig?
/// Creates an unpacker that extracts images into EXT4 filesystems.
/// - Parameters:
/// - blockSizeInBytes: The filesystem block size.
/// - journal: The journal configuration to use, or nil for no journaling.
public init(blockSizeInBytes: UInt64, journal: EXT4.JournalConfig? = nil) {
self.blockSizeInBytes = blockSizeInBytes
self.journal = journal
}
/// Performs the unpacking of a tar archive into a filesystem.
/// - Parameters:
/// - archive: The archive to unpack.
/// - compression: The compression to use when unpacking the image.
/// - path: The path to the filesystem that will be created.
public func unpack(
archive: URL,
compression: ContainerizationArchive.Filter,
at path: URL
) async throws {
let cleanedPath = try prepareUnpackPath(path: path)
let filesystem = try EXT4.Formatter(
FilePath(cleanedPath),
minDiskSize: blockSizeInBytes,
journal: journal
)
defer { try? filesystem.close() }
try await filesystem.unpack(
source: archive,
format: .paxRestricted,
compression: compression
)
}
/// Returns a `Mount` point after unpacking the image into a filesystem.
/// - Parameters:
/// - image: The image to unpack.
/// - platform: The platform content to unpack.
/// - path: The path to the directory where the filesystem will be created.
/// - progress: The progress handler to invoke as the unpacking progresses.
public func unpack(
_ image: Image,
for platform: Platform,
at path: URL,
progress: ProgressHandler? = nil
) async throws -> Mount {
let cleanedPath = try prepareUnpackPath(path: path)
let manifest = try await image.manifest(for: platform)
let filesystem = try EXT4.Formatter(
FilePath(
cleanedPath
),
minDiskSize: blockSizeInBytes
)
defer { try? filesystem.close() }
// Resolve layer paths upfront. When progress reporting is enabled and a layer
// uses zstd, decompress once so both the size-scanning pass and the unpack
// pass share the same decompressed file.
var resolvedLayers: [(file: URL, filter: ContainerizationArchive.Filter)] = []
var decompressedFiles: [URL] = []
for layer in manifest.layers {
try Task.checkCancellation()
let content = try await image.getContent(digest: layer.digest)
let compression = try compressionFilter(for: layer.mediaType)
if progress != nil && compression == .zstd {
let decompressed = try ArchiveReader.decompressZstd(content.path)
decompressedFiles.append(decompressed)
resolvedLayers.append((file: decompressed, filter: .none))
} else {
resolvedLayers.append((file: content.path, filter: compression))
}
}
defer {
for file in decompressedFiles {
ArchiveReader.cleanUpDecompressedZstd(file)
}
}
if let progress {
var totalSize: Int64 = 0
var totalItems: Int = 0
for layer in resolvedLayers {
try Task.checkCancellation()
let totals = try EXT4.Formatter.scanArchiveHeaders(
format: .paxRestricted, filter: layer.filter, file: layer.file)
totalSize += totals.size
totalItems += totals.items
}
var totalEvents: [ProgressEvent] = []
if totalSize > 0 {
totalEvents.append(.addTotalSize(totalSize))
}
if totalItems > 0 {
totalEvents.append(.addTotalItems(totalItems))
}
if !totalEvents.isEmpty {
await progress(totalEvents)
}
}
for resolved in resolvedLayers {
try Task.checkCancellation()
let reader = try ArchiveReader(
format: .paxRestricted,
filter: resolved.filter,
file: resolved.file
)
try await filesystem.unpack(reader: reader, progress: progress)
}
return .block(
format: "ext4",
source: cleanedPath,
destination: "/",
options: []
)
}
private func prepareUnpackPath(path: URL) throws -> String {
let blockPath = path.absolutePath()
guard !FileManager.default.fileExists(atPath: blockPath) else {
throw ContainerizationError(.exists, message: "block device already exists at \(blockPath)")
}
return blockPath
}
private func compressionFilter(for mediaType: String) throws -> ContainerizationArchive.Filter {
switch mediaType {
case MediaTypes.imageLayer, MediaTypes.dockerImageLayer:
return .none
case MediaTypes.imageLayerGzip, MediaTypes.dockerImageLayerGzip:
return .gzip
case MediaTypes.imageLayerZstd, MediaTypes.dockerImageLayerZstd:
return .zstd
default:
throw ContainerizationError(.unsupported, message: "media type \(mediaType) not supported.")
}
}
}
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationExtras
import ContainerizationOCI
import Foundation
/// The `Unpacker` protocol defines a standardized interface that involves
/// decompressing, extracting image layers and preparing it for use.
///
/// The `Unpacker` is responsible for managing the lifecycle of the
/// unpacking process, including any temporary files or resources, until the
/// `Mount` object is produced.
public protocol Unpacker {
/// Unpacks the provided image to a specified path for a given platform.
///
/// This asynchronous method should handle the entire unpacking process, from reading
/// the `Image` layers for the given `Platform` via its `Manifest`,
/// to making the extracted contents available as a `Mount`.
/// Implementations of this method may apply platform-specific optimizations
/// or transformations during the unpacking.
///
/// Progress updates can be observed via the optional `progress` handler.
func unpack(_ image: Image, for platform: Platform, at path: URL, progress: ProgressHandler?) async throws -> Mount
}
+46
View File
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationExtras
/// A network interface.
public protocol Interface: Sendable {
/// The interface IPv4 address and subnet prefix length, as a CIDR address.
/// Example: `192.168.64.3/24`
var ipv4Address: CIDRv4 { get }
/// The IPv4 gateway address for the default route, or nil for no IPv4 default route.
var ipv4Gateway: IPv4Address? { get }
/// The interface IPv6 address and subnet prefix length, as a CIDRv6 address, or nil for no IPv6 address.
/// Example: `fd00::1/64`
var ipv6Address: CIDRv6? { get }
/// The IPv6 gateway address for the default route, or nil for no IPv6 default route.
var ipv6Gateway: IPv6Address? { get }
/// The interface MAC address, or nil to auto-configure the address.
var macAddress: MACAddress? { get }
/// The interface MTU (Maximum Transmission Unit).
var mtu: UInt32 { get }
}
extension Interface {
public var mtu: UInt32 { 1500 }
public var ipv6Address: CIDRv6? { nil }
public var ipv6Gateway: IPv6Address? { nil }
}
@@ -0,0 +1,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 <args>` first; if exit 0, the rule
/// exists already return. Otherwise run `iptables -A <args>` 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
@@ -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: " ")
}
}
+101
View File
@@ -0,0 +1,101 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
import Logging
/// An object representing a Linux kernel used to boot a virtual machine.
/// In addition to a path to the kernel itself, this type stores relevant
/// data such as the commandline to pass to the kernel, and init arguments.
public struct Kernel: Sendable, Codable {
/// The command line arguments passed to the kernel on boot.
public struct CommandLine: Sendable, Codable {
public static let kernelDefaults = [
"console=hvc0",
"tsc=reliable",
]
/// Adds the debug argument to the kernel commandline.
mutating public func addDebug() {
self.kernelArgs.append("debug")
}
/// Adds a panic level to the kernel commandline.
mutating public func addPanic(level: Int) {
self.kernelArgs.append("panic=\(level)")
}
// Sets the log level for the Agent
mutating public func setAgentLogLevel(level: Logger.Level) {
self.initArgs.append(contentsOf: ["--log-level", level.description])
}
/// Additional kernel arguments.
public var kernelArgs: [String]
/// Additional arguments passed to the Initial Process / Agent.
public var initArgs: [String]
/// Initializes the kernel commandline using the mix of kernel arguments
/// and init arguments.
public init(
kernelArgs: [String] = kernelDefaults,
initArgs: [String] = []
) {
self.kernelArgs = kernelArgs
self.initArgs = initArgs
}
/// Initializes the kernel commandline to the defaults of Self.kernelDefaults,
/// adds a debug and panic flag as instructed, and optionally a set of init
/// process flags to supply to vminitd.
public init(debug: Bool, panic: Int, initArgs: [String] = []) {
var args = Self.kernelDefaults
if debug {
args.append("debug")
}
args.append("panic=\(panic)")
self.kernelArgs = args
self.initArgs = initArgs
}
}
/// Path on disk to the kernel binary.
public var path: URL
/// Platform for the kernel.
public var platform: SystemPlatform
/// Kernel and init process command line.
public var commandLine: Self.CommandLine
/// Kernel command line arguments.
public var kernelArgs: [String] {
self.commandLine.kernelArgs
}
/// Init process arguments.
public var initArgs: [String] {
self.commandLine.initArgs
}
public init(
path: URL,
platform: SystemPlatform,
commandline: Self.CommandLine = CommandLine(debug: false, panic: 0)
) {
self.path = path
self.platform = platform
self.commandLine = commandline
}
}
@@ -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<UInt32>
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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+485
View File
@@ -0,0 +1,485 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import ContainerizationOS
import Foundation
import Logging
import Synchronization
/// `LinuxProcess` represents a Linux process and is used to
/// setup and control the full lifecycle for the process.
public final class LinuxProcess: Sendable {
/// The ID of the process. This is purely metadata for the caller.
public let id: String
/// What container owns this process (if any).
public let owningContainer: String?
package struct StdioSetup: Sendable {
let port: UInt32
let writer: Writer
}
package struct StdioReaderSetup {
let port: UInt32
let reader: ReaderStream
}
package struct Stdio: Sendable {
let stdin: StdioReaderSetup?
let stdout: StdioSetup?
let stderr: StdioSetup?
}
private struct StdioHandles: Sendable {
var stdin: FileHandle?
var stdout: FileHandle?
var stderr: FileHandle?
mutating func close() throws {
if let stdin {
try stdin.close()
stdin.readabilityHandler = nil
self.stdin = nil
}
if let stdout {
try stdout.close()
stdout.readabilityHandler = nil
self.stdout = nil
}
if let stderr {
try stderr.close()
stderr.readabilityHandler = nil
self.stderr = nil
}
}
}
private struct State {
var spec: ContainerizationOCI.Spec
var pid: Int32
var stdio: StdioHandles
var stdinRelay: Task<(), Never>?
var ioTracker: IoTracker?
var deletionTask: Task<Void, Error>?
struct IoTracker {
let stream: AsyncStream<Void>
let cont: AsyncStream<Void>.Continuation
let configuredStreams: Int
}
}
/// The process ID for the container process. This will be -1
/// if the process has not been started.
public var pid: Int32 {
state.withLock { $0.pid }
}
private let state: Mutex<State>
private let ioSetup: Stdio
private let agent: any VirtualMachineAgent
private let vm: any VirtualMachineInstance
private let ociRuntimePath: String?
private let logger: Logger?
private let onDelete: (@Sendable () async -> Void)?
init(
_ id: String,
containerID: String? = nil,
spec: Spec,
io: Stdio,
ociRuntimePath: String?,
agent: any VirtualMachineAgent,
vm: any VirtualMachineInstance,
logger: Logger?,
onDelete: (@Sendable () async -> Void)? = nil
) {
self.id = id
self.owningContainer = containerID
self.state = Mutex<State>(.init(spec: spec, pid: -1, stdio: StdioHandles()))
self.ioSetup = io
self.agent = agent
self.ociRuntimePath = ociRuntimePath
self.vm = vm
self.logger = logger
self.onDelete = onDelete
}
}
extension LinuxProcess {
func setupIO(listeners: [VsockListener?]) async throws -> [FileHandle?] {
// Nested virtualization (x86_64 CI) is dramatically slower to bring the
// guest vsock listeners up than native arm64 virt, so allow a longer
// window there while keeping the arm64 path tight.
#if arch(x86_64)
let ioTimeout: UInt32 = 30
#else
let ioTimeout: UInt32 = 3
#endif
let handles = try await Timeout.run(seconds: ioTimeout) {
try await withThrowingTaskGroup(of: (Int, FileHandle?).self) { group in
var results = [FileHandle?](repeating: nil, count: 3)
for (index, listener) in listeners.enumerated() {
guard let listener else { continue }
group.addTask {
let first = await listener.first(where: { _ in true })
try listener.finish()
return (index, first)
}
}
for try await (index, fileHandle) in group {
results[index] = fileHandle
}
return results
}
}
// Note: stdin relay is started separately via startStdinRelay() after
// the process has started, to avoid a deadlock where closeStdin is
// called before the process is consuming from the pipe.
var configuredStreams = 0
let (stream, cc) = AsyncStream<Void>.makeStream()
if let stdout = self.ioSetup.stdout {
configuredStreams += 1
handles[1]?.readabilityHandler = { handle in
do {
let data = handle.availableData
if data.isEmpty {
// This block is called when the producer (the guest) closes
// the fd it is writing into.
handles[1]?.readabilityHandler = nil
cc.yield()
return
}
try stdout.writer.write(data)
} catch {
self.logger?.error("failed to write to stdout: \(error)")
}
}
}
if let stderr = self.ioSetup.stderr {
configuredStreams += 1
handles[2]?.readabilityHandler = { handle in
do {
let data = handle.availableData
if data.isEmpty {
handles[2]?.readabilityHandler = nil
cc.yield()
return
}
try stderr.writer.write(data)
} catch {
self.logger?.error("failed to write to stderr: \(error)")
}
}
}
if configuredStreams > 0 {
self.state.withLock {
$0.ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams)
}
}
return handles
}
func startStdinRelay(handle: FileHandle) {
guard let stdin = self.ioSetup.stdin else { return }
self.state.withLock {
$0.stdinRelay = Task {
for await data in stdin.reader.stream() {
do {
try handle.write(contentsOf: data)
} catch {
self.logger?.error("failed to write to stdin: \(error)")
break
}
}
do {
self.logger?.debug("stdin relay finished, closing")
// There's two ways we can wind up here:
//
// 1. The stream finished on its own (e.g. we wrote all the
// data) and we will close the underlying stdin in the guest below.
//
// 2. The client explicitly called closeStdin() themselves
// which will cancel this relay task AFTER actually closing
// the fds. If the client did that, then this task will be
// cancelled, and the fds are already gone so there's nothing
// for us to do.
if Task.isCancelled {
return
}
try await self._closeStdin()
} catch {
self.logger?.error("failed to close stdin: \(error)")
}
}
}
}
/// Start the process.
public func start() async throws {
do {
let spec = self.state.withLock { $0.spec }
var listeners = [VsockListener?](repeating: nil, count: 3)
if let stdin = self.ioSetup.stdin {
listeners[0] = try self.vm.listen(stdin.port)
}
if let stdout = self.ioSetup.stdout {
listeners[1] = try self.vm.listen(stdout.port)
}
if let stderr = self.ioSetup.stderr {
if spec.process!.terminal {
throw ContainerizationError(
.invalidArgument,
message: "stderr should not be configured with terminal=true"
)
}
listeners[2] = try self.vm.listen(stderr.port)
}
let t = Task {
try await self.setupIO(listeners: listeners)
}
try await agent.createProcess(
id: self.id,
containerID: self.owningContainer,
stdinPort: self.ioSetup.stdin?.port,
stdoutPort: self.ioSetup.stdout?.port,
stderrPort: self.ioSetup.stderr?.port,
ociRuntimePath: self.ociRuntimePath,
configuration: spec,
options: nil
)
let result = try await t.value
let pid = try await self.agent.startProcess(
id: self.id,
containerID: self.owningContainer
)
// Start stdin relay after process launch to avoid filling the pipe
// buffer before the process is even running.
if let stdinHandle = result[0] {
self.startStdinRelay(handle: stdinHandle)
}
self.state.withLock {
$0.stdio = StdioHandles(
stdin: result[0],
stdout: result[1],
stderr: result[2]
)
$0.pid = pid
}
} catch {
if let err = error as? ContainerizationError {
throw err
}
throw ContainerizationError(
.internalError,
message: "failed to start process",
cause: error,
)
}
}
/// Kill the process with the specified signal.
public func kill(_ signal: Signal) async throws {
do {
try await agent.signalProcess(
id: self.id,
containerID: self.owningContainer,
signal: signal.rawValue
)
} catch {
throw ContainerizationError(
.internalError,
message: "failed to kill process",
cause: error
)
}
}
/// Resize the processes pty (if requested).
public func resize(to: Terminal.Size) async throws {
do {
try await agent.resizeProcess(
id: self.id,
containerID: self.owningContainer,
columns: UInt32(to.width),
rows: UInt32(to.height)
)
} catch {
throw ContainerizationError(
.internalError,
message: "failed to resize process",
cause: error
)
}
}
public func closeStdin() async throws {
do {
try await self._closeStdin()
self.state.withLock {
$0.stdinRelay?.cancel()
}
} catch {
throw ContainerizationError(
.internalError,
message: "failed to close stdin",
cause: error,
)
}
}
func _closeStdin() async throws {
try await self.agent.closeProcessStdin(
id: self.id,
containerID: self.owningContainer
)
}
/// Wait on the process to exit with an optional timeout. Returns the exit code of the process.
@discardableResult
public func wait(timeoutInSeconds: Int64? = nil) async throws -> ExitStatus {
do {
let exitStatus = try await self.agent.waitProcess(
id: self.id,
containerID: self.owningContainer,
timeoutInSeconds: timeoutInSeconds
)
await self.waitIoComplete()
return exitStatus
} catch {
if error is ContainerizationError {
throw error
}
throw ContainerizationError(
.internalError,
message: "failed to wait on process",
cause: error
)
}
}
/// Wait until the standard output and standard error streams for the process have concluded.
private func waitIoComplete() async {
let ioTracker = self.state.withLock { $0.ioTracker }
guard let ioTracker else {
return
}
do {
try await Timeout.run(seconds: 3) {
var counter = ioTracker.configuredStreams
for await _ in ioTracker.stream {
counter -= 1
if counter == 0 {
ioTracker.cont.finish()
break
}
}
}
} catch {
self.logger?.error("timeout waiting for IO to complete for process \(id): \(error)")
}
self.state.withLock {
$0.ioTracker = nil
}
}
/// Cleans up guest state and waits on and closes any host resources (stdio handles).
public func delete() async throws {
try await self._delete()
await self.onDelete?()
}
func _delete() async throws {
let task = self.state.withLock { state in
if let existingTask = state.deletionTask {
// Deletion already in progress or finished.
return existingTask
}
let task = Task<Void, Error> {
try await self.performDeletion()
}
state.deletionTask = task
return task
}
try await task.value
}
private func performDeletion() async throws {
do {
try await self.agent.deleteProcess(
id: self.id,
containerID: self.owningContainer
)
} catch {
self.state.withLock {
$0.stdinRelay?.cancel()
try? $0.stdio.close()
}
try? await self.agent.close()
throw ContainerizationError(
.internalError,
message: "failed to delete process",
cause: error,
)
}
do {
try self.state.withLock {
$0.stdinRelay?.cancel()
try $0.stdio.close()
}
} catch {
try? await self.agent.close()
throw ContainerizationError(
.internalError,
message: "failed to close stdio",
cause: error,
)
}
do {
try await self.agent.close()
} catch {
throw ContainerizationError(
.internalError,
message: "failed to close agent connection",
cause: error,
)
}
}
}
@@ -0,0 +1,457 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
/// A resource limit (rlimit) configuration for a container process.
public struct LinuxRLimit: Sendable, Hashable {
/// The kind of resource limit.
public var kind: Kind
/// The hard limit value.
public var hard: UInt64
/// The soft limit value.
public var soft: UInt64
/// Creates a new resource limit.
///
/// - Parameters:
/// - kind: The kind of resource limit.
/// - hard: The hard limit value.
/// - soft: The soft limit value.
public init(kind: Kind, hard: UInt64, soft: UInt64) {
self.kind = kind
self.hard = hard
self.soft = soft
}
/// Creates a new resource limit with the same value for both hard and soft limits.
///
/// - Parameters:
/// - kind: The kind of resource limit.
/// - limit: The limit value for both hard and soft limits.
public init(kind: Kind, limit: UInt64) {
self.kind = kind
self.hard = limit
self.soft = limit
}
/// Convert to OCI POSIXRlimit format for transport.
public func toOCI() -> POSIXRlimit {
POSIXRlimit(type: self.kind.description, hard: self.hard, soft: self.soft)
}
}
extension LinuxRLimit {
/// The kind of resource limit.
public struct Kind: Sendable, Hashable {
private enum Value: Hashable, Sendable, CaseIterable {
case addressSpace
case coreFileSize
case cpuTime
case dataSize
case fileSize
case locks
case lockedMemory
case messageQueue
case nice
case openFiles
case numberOfProcesses
case residentSetSize
case realtimePriority
case realtimeTimeout
case signalsPending
case stackSize
}
private var value: Value
private init(_ value: Value) {
self.value = value
}
/// Maximum size of the process's virtual memory (address space) in bytes.
public static var addressSpace: Self {
Self(.addressSpace)
}
/// Maximum size of a core file in bytes.
public static var coreFileSize: Self {
Self(.coreFileSize)
}
/// Maximum amount of CPU time the process can consume in seconds.
public static var cpuTime: Self {
Self(.cpuTime)
}
/// Maximum size of the process's data segment in bytes.
public static var dataSize: Self {
Self(.dataSize)
}
/// Maximum size of files the process may create in bytes.
public static var fileSize: Self {
Self(.fileSize)
}
/// Maximum number of file locks.
public static var locks: Self {
Self(.locks)
}
/// Maximum number of bytes of memory that may be locked into RAM.
public static var lockedMemory: Self {
Self(.lockedMemory)
}
/// Maximum number of bytes that can be allocated for POSIX message queues.
public static var messageQueue: Self {
Self(.messageQueue)
}
/// Maximum nice value that can be set.
public static var nice: Self {
Self(.nice)
}
/// Maximum number of open file descriptors.
public static var openFiles: Self {
Self(.openFiles)
}
/// Maximum number of processes that can be created by the user.
public static var numberOfProcesses: Self {
Self(.numberOfProcesses)
}
/// Maximum size of the process's resident set (physical memory) in bytes.
public static var residentSetSize: Self {
Self(.residentSetSize)
}
/// Maximum real-time scheduling priority.
public static var realtimePriority: Self {
Self(.realtimePriority)
}
/// Maximum amount of CPU time for real-time scheduling in microseconds.
public static var realtimeTimeout: Self {
Self(.realtimeTimeout)
}
/// Maximum number of signals that may be queued.
public static var signalsPending: Self {
Self(.signalsPending)
}
/// Maximum size of the process stack in bytes.
public static var stackSize: Self {
Self(.stackSize)
}
/// Creates a Kind from its OCI string representation.
///
/// - Parameter string: The OCI string representation (e.g., "RLIMIT_NOFILE").
/// - Throws: `ContainerizationError` with code `.invalidArgument` if the string doesn't match a known rlimit kind.
public init(_ string: String) throws {
switch string {
case "RLIMIT_AS":
self = .addressSpace
case "RLIMIT_CORE":
self = .coreFileSize
case "RLIMIT_CPU":
self = .cpuTime
case "RLIMIT_DATA":
self = .dataSize
case "RLIMIT_FSIZE":
self = .fileSize
case "RLIMIT_LOCKS":
self = .locks
case "RLIMIT_MEMLOCK":
self = .lockedMemory
case "RLIMIT_MSGQUEUE":
self = .messageQueue
case "RLIMIT_NICE":
self = .nice
case "RLIMIT_NOFILE":
self = .openFiles
case "RLIMIT_NPROC":
self = .numberOfProcesses
case "RLIMIT_RSS":
self = .residentSetSize
case "RLIMIT_RTPRIO":
self = .realtimePriority
case "RLIMIT_RTTIME":
self = .realtimeTimeout
case "RLIMIT_SIGPENDING":
self = .signalsPending
case "RLIMIT_STACK":
self = .stackSize
default:
throw ContainerizationError(.invalidArgument, message: "invalid rlimit kind: '\(string)'")
}
}
}
}
extension LinuxRLimit.Kind: CustomStringConvertible {
/// The OCI string representation of the resource limit kind.
public var description: String {
switch self.value {
case .addressSpace:
"RLIMIT_AS"
case .coreFileSize:
"RLIMIT_CORE"
case .cpuTime:
"RLIMIT_CPU"
case .dataSize:
"RLIMIT_DATA"
case .fileSize:
"RLIMIT_FSIZE"
case .locks:
"RLIMIT_LOCKS"
case .lockedMemory:
"RLIMIT_MEMLOCK"
case .messageQueue:
"RLIMIT_MSGQUEUE"
case .nice:
"RLIMIT_NICE"
case .openFiles:
"RLIMIT_NOFILE"
case .numberOfProcesses:
"RLIMIT_NPROC"
case .residentSetSize:
"RLIMIT_RSS"
case .realtimePriority:
"RLIMIT_RTPRIO"
case .realtimeTimeout:
"RLIMIT_RTTIME"
case .signalsPending:
"RLIMIT_SIGPENDING"
case .stackSize:
"RLIMIT_STACK"
}
}
}
/// User-friendly Linux capabilities configuration
public struct LinuxCapabilities: Sendable {
/// Capabilities that define the maximum set of capabilities a process can have
public var bounding: [CapabilityName] = []
/// Capabilities that are actually in effect for the current process
public var effective: [CapabilityName] = []
/// Capabilities that can be inherited by child processes
public var inheritable: [CapabilityName] = []
/// Capabilities that are currently permitted for the process
public var permitted: [CapabilityName] = []
/// Capabilities that are preserved across execve() calls
public var ambient: [CapabilityName] = []
/// Grant all capabilities
public static let allCapabilities = LinuxCapabilities(
bounding: CapabilityName.allCases,
effective: CapabilityName.allCases,
inheritable: CapabilityName.allCases,
permitted: CapabilityName.allCases,
ambient: CapabilityName.allCases
)
/// Default configuration
public static let defaultOCICapabilities = LinuxCapabilities(
bounding: [
.chown,
.dacOverride,
.fsetid,
.fowner,
.mknod,
.netRaw,
.setgid,
.setuid,
.setfcap,
.setpcap,
.netBindService,
.sysChroot,
.kill,
.auditWrite,
],
effective: [
.chown,
.dacOverride,
.fsetid,
.fowner,
.mknod,
.netRaw,
.setgid,
.setuid,
.setfcap,
.setpcap,
.netBindService,
.sysChroot,
.kill,
.auditWrite,
],
permitted: [
.chown,
.dacOverride,
.fsetid,
.fowner,
.mknod,
.netRaw,
.setgid,
.setuid,
.setfcap,
.setpcap,
.netBindService,
.sysChroot,
.kill,
.auditWrite,
],
)
public init(
bounding: [CapabilityName] = [],
effective: [CapabilityName] = [],
inheritable: [CapabilityName] = [],
permitted: [CapabilityName] = [],
ambient: [CapabilityName] = []
) {
self.bounding = bounding
self.effective = effective
self.inheritable = inheritable
self.permitted = permitted
self.ambient = ambient
}
/// Convenience initializer that sets the same capabilities to effective, permitted, and bounding sets
/// This matches the typical pattern used by containerd/runc
public init(capabilities: [CapabilityName]) {
self.bounding = capabilities
self.effective = capabilities
self.inheritable = []
self.permitted = capabilities
self.ambient = []
}
/// Convert to OCI format for transport
public func toOCI() -> ContainerizationOCI.LinuxCapabilities {
ContainerizationOCI.LinuxCapabilities(
bounding: bounding.isEmpty ? nil : bounding.map { $0.description },
effective: effective.isEmpty ? nil : effective.map { $0.description },
inheritable: inheritable.isEmpty ? nil : inheritable.map { $0.description },
permitted: permitted.isEmpty ? nil : permitted.map { $0.description },
ambient: ambient.isEmpty ? nil : ambient.map { $0.description }
)
}
}
public struct LinuxProcessConfiguration: Sendable {
/// The default PATH value for a process.
public static let defaultPath = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
/// The arguments for the container process.
public var arguments: [String] = []
/// The environment variables for the container process.
public var environmentVariables: [String] = ["PATH=\(Self.defaultPath)"]
/// The working directory for the container process.
public var workingDirectory: String = "/"
/// The user the container process will run as.
public var user: ContainerizationOCI.User = .init()
/// The rlimits for the container process.
public var rlimits: [LinuxRLimit] = []
/// Whether to set the no_new_privileges bit on the container process. When true, the
/// process and its children cannot gain additional privileges via setuid/setgid binaries
/// or file capabilities.
public var noNewPrivileges: Bool = false
/// The Linux capabilities for the container process. 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.
public var terminal: Bool = false
/// The stdin for the process.
public var stdin: ReaderStream?
/// The stdout for the process.
public var stdout: Writer?
/// The stderr for the process.
public var stderr: Writer?
public init() {}
public init(
arguments: [String],
environmentVariables: [String] = ["PATH=\(Self.defaultPath)"],
workingDirectory: String = "/",
user: ContainerizationOCI.User = .init(),
rlimits: [LinuxRLimit] = [],
noNewPrivileges: Bool = false,
capabilities: LinuxCapabilities = .defaultOCICapabilities,
terminal: Bool = false,
stdin: ReaderStream? = nil,
stdout: Writer? = nil,
stderr: Writer? = nil
) {
self.arguments = arguments
self.environmentVariables = environmentVariables
self.workingDirectory = workingDirectory
self.user = user
self.rlimits = rlimits
self.noNewPrivileges = noNewPrivileges
self.capabilities = capabilities
self.terminal = terminal
self.stdin = stdin
self.stdout = stdout
self.stderr = stderr
}
public init(from config: ImageConfig) {
self.workingDirectory = config.workingDir ?? "/"
self.environmentVariables = config.env ?? []
self.arguments = (config.entrypoint ?? []) + (config.cmd ?? [])
self.user = {
if let rawString = config.user {
return User(username: rawString)
}
return User()
}()
}
/// Sets up IO to be handled by the passed in Terminal, and edits the
/// process configuration to set the necessary state for using a pty.
mutating public func setTerminalIO(terminal: Terminal) {
self.environmentVariables.append("TERM=xterm")
self.terminal = true
self.stdin = terminal
self.stdout = terminal
}
func toOCI() -> ContainerizationOCI.Process {
ContainerizationOCI.Process(
args: self.arguments,
cwd: self.workingDirectory,
env: self.environmentVariables,
noNewPrivileges: self.noNewPrivileges,
capabilities: self.capabilities.toOCI(),
user: self.user,
rlimits: self.rlimits.map { $0.toOCI() },
terminal: self.terminal
)
}
}
+80
View File
@@ -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 `<workDir>/virtiofs-<tag>.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")
}
+452
View File
@@ -0,0 +1,452 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationError
import Foundation
#if os(macOS)
import Virtualization
#endif
#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
/// that will be used for the mount syscall itself.
public var type: String
/// The source path of the mount.
public var source: String
/// The destination path of the mount.
public var destination: String
/// Filesystem or mount specific options.
public var options: [String]
/// Runtime specific options. This can be used
/// as a way to discern what kind of device a vmm
/// should create for this specific mount (virtioblock
/// virtiofs etc.).
public let runtimeOptions: RuntimeOptions
/// A type representing a "hint" of what type
/// of mount this really is (block, directory, purely
/// guest mount) and a set of type specific options, if any.
public enum RuntimeOptions: Sendable {
case virtioblk([String])
case virtiofs([String])
case shared
case any([String])
}
public init(
type: String,
source: String,
destination: String,
options: [String],
runtimeOptions: RuntimeOptions
) {
self.type = type
self.source = source
self.destination = destination
self.options = options
self.runtimeOptions = runtimeOptions
}
/// Mount representing a virtio block device.
public static func block(
format: String,
source: String,
destination: String,
options: [String] = [],
runtimeOptions: [String] = []
) -> Self {
.init(
type: format,
source: source,
destination: destination,
options: options,
runtimeOptions: .virtioblk(runtimeOptions)
)
}
/// Mount representing a virtiofs share.
public static func share(
source: String,
destination: String,
options: [String] = [],
runtimeOptions: [String] = []
) -> Self {
.init(
type: "virtiofs",
source: source,
destination: destination,
options: options,
runtimeOptions: .virtiofs(runtimeOptions)
)
}
/// A generic mount.
public static func any(
type: String,
source: String,
destination: String,
options: [String] = [],
runtimeOptions: [String] = []
) -> Self {
.init(
type: type,
source: source,
destination: destination,
options: options,
runtimeOptions: .any(runtimeOptions)
)
}
/// A mount referencing a shared pod volume by name.
public static func sharedMount(
name: String,
destination: String,
options: [String] = []
) -> Self {
.init(
type: "none",
source: name,
destination: destination,
options: options,
runtimeOptions: .shared
)
}
/// Clone the Mount to the provided path.
///
/// 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 {
#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,
source: to,
destination: self.destination,
options: self.options,
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
}
#if os(macOS)
extension Mount {
private enum StorageAttachmentType {
case diskImage
case networkBlockDevice
}
private var storageAttachmentType: StorageAttachmentType {
let nbdSchemes = ["nbd://", "nbds://", "nbd+unix://", "nbds+unix://"]
if nbdSchemes.contains(where: { self.source.hasPrefix($0) }) {
return .networkBlockDevice
}
return .diskImage
}
func configure(config: inout VZVirtualMachineConfiguration) throws {
switch self.runtimeOptions {
case .virtioblk(let options):
let device: VZStorageDeviceAttachment
switch self.storageAttachmentType {
case .networkBlockDevice:
device = try VZNetworkBlockDeviceStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options)
case .diskImage:
device = try VZDiskImageStorageDeviceAttachment.mountToVZAttachment(mount: self, options: options)
}
let attachment = VZVirtioBlockDeviceConfiguration(attachment: device)
config.storageDevices.append(attachment)
case .virtiofs(_):
// VirtioFS mounts are handled centrally via VZMultipleDirectoryShare in VZVirtualMachineInstance
// No per-mount device configuration needed
break
case .shared, .any:
break
}
}
}
extension VZDiskImageStorageDeviceAttachment {
static func mountToVZAttachment(mount: Mount, options: [String]) throws -> VZDiskImageStorageDeviceAttachment {
var synchronizationMode: VZDiskImageSynchronizationMode = .fsync
var cachingMode: VZDiskImageCachingMode = .cached
for option in options {
let split = option.split(separator: "=")
if split.count != 2 {
continue
}
let key = String(split[0])
let value = String(split[1])
switch key {
case "vzDiskImageCachingMode":
switch value {
case "automatic":
cachingMode = .automatic
case "cached":
cachingMode = .cached
case "uncached":
cachingMode = .uncached
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown vzDiskImageCachingMode value for virtio block device: \(value)"
)
}
case "vzDiskImageSynchronizationMode":
switch value {
case "full":
synchronizationMode = .full
case "fsync":
synchronizationMode = .fsync
case "none":
synchronizationMode = .none
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown vzDiskImageSynchronizationMode value for virtio block device: \(value)"
)
}
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown vmm option encountered: \(key)"
)
}
}
return try VZDiskImageStorageDeviceAttachment(
url: URL(filePath: mount.source),
readOnly: mount.readonly,
cachingMode: cachingMode,
synchronizationMode: synchronizationMode
)
}
}
extension VZNetworkBlockDeviceStorageDeviceAttachment {
static func mountToVZAttachment(mount: Mount, options: [String]) throws -> VZNetworkBlockDeviceStorageDeviceAttachment {
guard let url = URL(string: mount.source) else {
throw ContainerizationError(
.invalidArgument,
message: "invalid NBD URL: \(mount.source)"
)
}
var timeout: TimeInterval = 5
var synchronizationMode: VZDiskSynchronizationMode = .full
for option in options {
let split = option.split(separator: "=")
if split.count != 2 {
continue
}
let key = String(split[0])
let value = String(split[1])
switch key {
case "vzTimeout":
guard let t = TimeInterval(value) else {
throw ContainerizationError(
.invalidArgument,
message: "invalid vzTimeout value for NBD device: \(value)"
)
}
timeout = t
case "vzSynchronizationMode":
switch value {
case "full":
synchronizationMode = .full
case "none":
synchronizationMode = .none
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown vzSynchronizationMode value for NBD device: \(value)"
)
}
default:
throw ContainerizationError(
.invalidArgument,
message: "unknown vmm option encountered: \(key)"
)
}
}
return try VZNetworkBlockDeviceStorageDeviceAttachment(
url: url,
timeout: timeout,
isForcedReadOnly: mount.readonly,
synchronizationMode: synchronizationMode
)
}
}
#endif
extension Mount {
fileprivate var readonly: Bool {
self.options.contains("ro")
}
/// Returns true if this mount is a virtio block device.
public var isBlock: Bool {
if case .virtioblk = self.runtimeOptions {
return true
}
return false
}
}
@@ -0,0 +1,42 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ContainerizationExtras
public struct NATInterface: Interface {
public var ipv4Address: CIDRv4
public var ipv4Gateway: IPv4Address?
public var ipv6Address: CIDRv6?
public var ipv6Gateway: IPv6Address?
public var macAddress: MACAddress?
public var mtu: UInt32
public init(
ipv4Address: CIDRv4,
ipv4Gateway: IPv4Address?,
ipv6Address: CIDRv6? = nil,
ipv6Gateway: IPv6Address? = nil,
macAddress: MACAddress? = nil,
mtu: UInt32 = 1500
) {
self.ipv4Address = ipv4Address
self.ipv4Gateway = ipv4Gateway
self.ipv6Address = ipv6Address
self.ipv6Gateway = ipv6Gateway
self.macAddress = macAddress
self.mtu = mtu
}
}
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
#if os(macOS)
import vmnet
import Virtualization
import ContainerizationError
import ContainerizationExtras
import Synchronization
/// An interface that uses NAT to provide an IP address for a given
/// container/virtual machine.
@available(macOS 26, *)
public final class NATNetworkInterface: Interface, Sendable {
public let ipv4Address: CIDRv4
public let ipv4Gateway: IPv4Address?
public let macAddress: MACAddress?
public let mtu: UInt32
@available(macOS 26, *)
// `reference` isn't used concurrently.
public nonisolated(unsafe) let reference: vmnet_network_ref!
@available(macOS 26, *)
public init(
ipv4Address: CIDRv4,
ipv4Gateway: IPv4Address?,
reference: sending vmnet_network_ref,
macAddress: MACAddress? = nil,
mtu: UInt32 = 1500
) {
self.ipv4Address = ipv4Address
self.ipv4Gateway = ipv4Gateway
self.macAddress = macAddress
self.mtu = mtu
self.reference = reference
}
@available(macOS, obsoleted: 26, message: "Use init(ipv4Address:ipv4Gateway:reference:macAddress:) instead")
public init(
ipv4Address: CIDRv4,
ipv4Gateway: IPv4Address?,
macAddress: MACAddress? = nil,
mtu: UInt32 = 1500
) {
self.ipv4Address = ipv4Address
self.ipv4Gateway = ipv4Gateway
self.macAddress = macAddress
self.mtu = mtu
self.reference = nil
}
}
@available(macOS 26, *)
extension NATNetworkInterface: VZInterface {
public func device() throws -> VZVirtioNetworkDeviceConfiguration {
let config = VZVirtioNetworkDeviceConfiguration()
if let macAddress = self.macAddress {
guard let mac = VZMACAddress(string: macAddress.description) else {
throw ContainerizationError(.invalidArgument, message: "invalid mac address \(macAddress)")
}
config.macAddress = mac
}
config.attachment = VZVmnetNetworkDeviceAttachment(network: self.reference)
return config
}
}
#endif
+21
View File
@@ -0,0 +1,21 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the Containerization project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
/// A network that can allocate and release interfaces for use with containers.
public protocol Network: Sendable {
mutating func createInterface(_ id: String) throws -> Interface?
mutating func releaseInterface(_ id: String) throws
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,493 @@
syntax = "proto3";
package com.apple.containerization.sandbox.v3;
import "google/protobuf/timestamp.proto";
// Context for interacting with a container's runtime environment.
service SandboxContext {
// Mount a filesystem.
rpc Mount(MountRequest) returns (MountResponse);
// Unmount a filesystem.
rpc Umount(UmountRequest) returns (UmountResponse);
// Set an environment variable on the init process.
rpc Setenv(SetenvRequest) returns (SetenvResponse);
// Get an environment variable from the init process.
rpc Getenv(GetenvRequest) returns (GetenvResponse);
// Create a new directory inside the sandbox.
rpc Mkdir(MkdirRequest) returns (MkdirResponse);
// Set sysctls in the context of the sandbox.
rpc Sysctl(SysctlRequest) returns (SysctlResponse);
// Set time in the guest.
rpc SetTime(SetTimeRequest) returns (SetTimeResponse);
// Set up an emulator in the guest for a specific binary format.
rpc SetupEmulator(SetupEmulatorRequest) returns (SetupEmulatorResponse);
// Write data to an existing or new file.
rpc WriteFile(WriteFileRequest) returns (WriteFileResponse);
// Copy a file or directory between the host and guest.
// Data transfer happens over a dedicated vsock connection;
// the gRPC stream is used only for control/metadata.
rpc Copy(CopyRequest) returns (stream CopyResponse);
// Stat a path in the guest filesystem.
rpc Stat(StatRequest) returns (StatResponse);
// Perform a filesystem operation on a mounted filesystem.
rpc FilesystemOperation(FilesystemOperationRequest) returns (FilesystemOperationResponse);
// Create a new process inside the container.
rpc CreateProcess(CreateProcessRequest) returns (CreateProcessResponse);
// Delete an existing process inside the container.
rpc DeleteProcess(DeleteProcessRequest) returns (DeleteProcessResponse);
// Start the provided process.
rpc StartProcess(StartProcessRequest) returns (StartProcessResponse);
// Send a signal to the provided process.
rpc KillProcess(KillProcessRequest) returns (KillProcessResponse);
// Wait for a process to exit and return the exit code.
rpc WaitProcess(WaitProcessRequest) returns (WaitProcessResponse);
// Resize the tty of a given process. This will error if the process does
// not have a pty allocated.
rpc ResizeProcess(ResizeProcessRequest) returns (ResizeProcessResponse);
// Close IO for a given process.
rpc CloseProcessStdin(CloseProcessStdinRequest) returns (CloseProcessStdinResponse);
// Get statistics for containers.
rpc ContainerStatistics(ContainerStatisticsRequest) returns (ContainerStatisticsResponse);
// Proxy a vsock port to a unix domain socket in the guest, or vice versa.
rpc ProxyVsock(ProxyVsockRequest) returns (ProxyVsockResponse);
// Stop a vsock proxy to a unix domain socket.
rpc StopVsockProxy(StopVsockProxyRequest) returns (StopVsockProxyResponse);
// Set the link state of a network interface.
rpc IpLinkSet(IpLinkSetRequest) returns (IpLinkSetResponse);
// Add an IPv4 address to a network interface.
rpc IpAddrAdd(IpAddrAddRequest) returns (IpAddrAddResponse);
// Add an IP route for a network interface.
rpc IpRouteAddLink(IpRouteAddLinkRequest) returns (IpRouteAddLinkResponse);
// Add an IP route for a network interface.
rpc IpRouteAddDefault(IpRouteAddDefaultRequest) returns (IpRouteAddDefaultResponse);
// Configure DNS resolver.
rpc ConfigureDns(ConfigureDnsRequest) returns (ConfigureDnsResponse);
// Configure /etc/hosts.
rpc ConfigureHosts(ConfigureHostsRequest) returns (ConfigureHostsResponse);
// Perform the sync syscall.
rpc Sync(SyncRequest) returns (SyncResponse);
// Send a signal to a process via the PID.
rpc Kill(KillRequest) returns (KillResponse);
}
message Stdio {
optional int32 stdinPort = 1;
optional int32 stdoutPort = 2;
optional int32 stderrPort = 3;
}
message SetupEmulatorRequest {
string binary_path = 1;
string name = 2;
string type = 3;
string offset = 4;
string magic = 5;
string mask = 6;
string flags = 7;
}
message SetupEmulatorResponse {}
message SetTimeRequest {
int64 sec = 1;
int32 usec = 2;
}
message SetTimeResponse {}
message SysctlRequest { map<string, string> settings = 1; }
message SysctlResponse {}
message ProxyVsockRequest {
enum Action {
INTO = 0;
OUT_OF = 1;
}
string id = 1;
uint32 vsock_port = 2;
string guestPath = 3;
optional uint32 guestSocketPermissions = 4;
Action action = 5;
}
message ProxyVsockResponse {}
message StopVsockProxyRequest { string id = 1; }
message StopVsockProxyResponse {}
message MountRequest {
string type = 1;
string source = 2;
string destination = 3;
repeated string options = 4;
}
message MountResponse {}
message UmountRequest {
string path = 1;
int32 flags = 2;
}
message UmountResponse {}
message SetenvRequest {
string key = 1;
optional string value = 2;
}
message SetenvResponse {}
message GetenvRequest { string key = 1; }
message GetenvResponse { optional string value = 1; }
message CreateProcessRequest {
string id = 1;
optional string containerID = 2;
optional uint32 stdin = 3;
optional uint32 stdout = 4;
optional uint32 stderr = 5;
optional string ociRuntimePath = 6;
bytes configuration = 7;
optional bytes options = 8;
}
message CreateProcessResponse {}
message WaitProcessRequest {
string id = 1;
optional string containerID = 2;
}
message WaitProcessResponse {
int32 exitCode = 1;
google.protobuf.Timestamp exited_at = 2;
}
message ResizeProcessRequest {
string id = 1;
optional string containerID = 2;
uint32 rows = 3;
uint32 columns = 4;
}
message ResizeProcessResponse {}
message DeleteProcessRequest {
string id = 1;
optional string containerID = 2;
}
message DeleteProcessResponse {}
message StartProcessRequest {
string id = 1;
optional string containerID = 2;
}
message StartProcessResponse { int32 pid = 1; }
message KillProcessRequest {
string id = 1;
optional string containerID = 2;
int32 signal = 3;
}
message KillProcessResponse { int32 result = 1; }
message CloseProcessStdinRequest {
string id = 1;
optional string containerID = 2;
}
message CloseProcessStdinResponse {}
message MkdirRequest {
string path = 1;
bool all = 2;
uint32 perms = 3;
}
message MkdirResponse {}
message WriteFileRequest {
message WriteFileFlags {
bool create_parent_dirs = 1;
bool append = 2;
bool create_if_missing = 3;
}
string path = 1;
bytes data = 2;
uint32 mode = 3;
WriteFileFlags flags = 4;
}
message WriteFileResponse {}
message CopyRequest {
enum Direction {
// Copy from host into guest.
COPY_IN = 0;
// Copy from guest to host.
COPY_OUT = 1;
}
// Direction of the copy operation.
Direction direction = 1;
// Path in the guest (destination for COPY_IN, source for COPY_OUT).
string path = 2;
// File mode for single-file COPY_IN (defaults to 0644 if not set).
uint32 mode = 3;
// Create parent directories if they don't exist.
bool create_parents = 4;
// Vsock port the host is listening on for data transfer.
uint32 vsock_port = 5;
// For COPY_IN: indicates the data arriving on vsock is a tar+gzip archive.
bool is_archive = 6;
}
message CopyResponse {
enum Status {
// Transfer metadata (first message for COPY_OUT: is_archive, total_size).
METADATA = 0;
// Data transfer completed successfully.
COMPLETE = 1;
}
// What this response represents.
Status status = 1;
// For COPY_OUT METADATA: indicates the data on vsock will be a tar+gzip archive.
bool is_archive = 2;
// For COPY_OUT METADATA: total size in bytes (0 if unknown, e.g. for archives).
uint64 total_size = 3;
// Non-empty if an error occurred.
string error = 4;
}
message StatRequest { string path = 1; }
message Stat {
uint64 dev = 1; // st_dev: ID of device containing file
uint64 ino = 2; // st_ino: inode number
uint32 mode = 3; // st_mode: file type and mode (permissions)
uint64 nlink = 4; // st_nlink: number of hard links
uint32 uid = 5; // st_uid: user ID of owner
uint32 gid = 6; // st_gid: group ID of owner
uint64 rdev = 7; // st_rdev: device ID (if special file)
int64 size = 8; // st_size: total size in bytes
int64 blksize = 9; // st_blksize: preferred block size for filesystem I/O
int64 blocks = 10; // st_blocks: number of 512-byte blocks allocated
google.protobuf.Timestamp atime = 11; // st_atim: time of last access
google.protobuf.Timestamp mtime = 12; // st_mtim: time of last modification
google.protobuf.Timestamp ctime = 13; // st_ctim: time of last status change
}
message StatResponse {
Stat stat = 1;
string error = 2; // Non-empty if stat failed.
}
message FiTrimParams {
oneof schedule {
OneShot one_shot = 1;
}
message OneShot {}
}
message FiFreezeParams {}
message FiThawParams {}
message FiTrimResult {
uint64 trimmed_bytes = 1;
}
message FilesystemOperationRequest {
string path = 1;
oneof operation {
FiTrimParams trim = 2;
FiFreezeParams freeze = 3;
FiThawParams thaw = 4;
}
}
message FilesystemOperationResponse {
oneof result {
FiTrimResult trim = 1;
}
}
message IpLinkSetRequest {
string interface = 1;
bool up = 2;
optional uint32 mtu = 3;
}
message IpLinkSetResponse {}
message IpAddrAddRequest {
string interface = 1;
string ipv4Address = 2;
optional string ipv6Address = 3;
}
message IpAddrAddResponse {}
message IpRouteAddLinkRequest {
string interface = 1;
string dstIpv4Addr = 2;
string srcIpv4Addr = 3;
optional string dstIpv6Addr = 4;
optional string srcIpv6Addr = 5;
}
message IpRouteAddLinkResponse {}
message IpRouteAddDefaultRequest {
string interface = 1;
string ipv4Gateway = 2;
optional string ipv6Gateway = 3;
}
message IpRouteAddDefaultResponse {}
message ConfigureDnsRequest {
string location = 1;
repeated string nameservers = 2;
optional string domain = 3;
repeated string searchDomains = 4;
repeated string options = 5;
}
message ConfigureDnsResponse {}
message ConfigureHostsRequest {
message HostsEntry {
string ipAddress = 1;
repeated string hostnames = 2;
optional string comment = 3;
}
string location = 1;
repeated HostsEntry entries = 2;
optional string comment = 3;
}
message ConfigureHostsResponse {}
message SyncRequest {}
message SyncResponse {}
message KillRequest {
int32 pid = 1;
int32 signal = 3;
}
message KillResponse { int32 result = 1; }
// Categories of statistics that can be requested.
enum StatCategory {
STAT_CATEGORY_UNSPECIFIED = 0;
STAT_CATEGORY_PROCESS = 1;
STAT_CATEGORY_MEMORY = 2;
STAT_CATEGORY_CPU = 3;
STAT_CATEGORY_BLOCK_IO = 4;
STAT_CATEGORY_NETWORK = 5;
STAT_CATEGORY_MEMORY_EVENTS = 6;
}
message ContainerStatisticsRequest {
repeated string container_ids = 1; // Empty = all containers
repeated StatCategory categories = 2; // Empty = all categories
}
message ContainerStatisticsResponse {
repeated ContainerStats containers = 1;
}
message ContainerStats {
string container_id = 1;
ProcessStats process = 2;
MemoryStats memory = 3;
CPUStats cpu = 4;
BlockIOStats block_io = 5;
repeated NetworkStats networks = 6;
MemoryEventStats memory_events = 7;
}
message ProcessStats {
uint64 current = 1;
uint64 limit = 2; // 0 or max value = unlimited
}
message MemoryStats {
uint64 usage_bytes = 1;
uint64 limit_bytes = 2;
uint64 swap_usage_bytes = 3;
uint64 swap_limit_bytes = 4;
uint64 cache_bytes = 5;
uint64 kernel_stack_bytes = 6;
uint64 slab_bytes = 7;
uint64 page_faults = 8;
uint64 major_page_faults = 9;
uint64 inactive_file = 10;
uint64 anon = 11;
uint64 workingset_refault_anon = 12;
uint64 workingset_refault_file = 13;
uint64 pgsteal_kswapd = 14;
uint64 pgsteal_direct = 15;
uint64 pgsteal_khugepaged = 16;
}
message CPUStats {
uint64 usage_usec = 1;
uint64 user_usec = 2;
uint64 system_usec = 3;
uint64 throttling_periods = 4;
uint64 throttled_periods = 5;
uint64 throttled_time_usec = 6;
}
message BlockIOStats {
repeated BlockIOEntry devices = 1;
}
message BlockIOEntry {
uint64 major = 1;
uint64 minor = 2;
uint64 read_bytes = 3;
uint64 write_bytes = 4;
uint64 read_operations = 5;
uint64 write_operations = 6;
}
message NetworkStats {
string interface = 1;
uint64 receivedPackets = 2;
uint64 transmittedPackets = 3;
uint64 receivedBytes = 4;
uint64 transmittedBytes = 5;
uint64 receivedErrors = 6;
uint64 transmittedErrors = 7;
}
// Memory event counters from cgroup2's memory.events file.
message MemoryEventStats {
// Number of times the cgroup was reclaimed due to low memory.
uint64 low = 1;
// Number of times the cgroup exceeded its high memory limit.
uint64 high = 2;
// Number of times the cgroup hit its max memory limit.
uint64 max = 3;
// Number of times the cgroup triggered OOM.
uint64 oom = 4;
// Number of processes killed by OOM killer.
uint64 oom_kill = 5;
// Number of times charge for memory failed because of limit.
uint64 oom_group_kill = 6;
}

Some files were not shown because too many files have changed in this diff Show More