chore: import upstream snapshot with attribution
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
version: 1
|
||||
builder:
|
||||
configs:
|
||||
- documentation_targets: [Containerization, ContainerizationEXT4, ContainerizationOS, ContainerizationOCI, ContainerizationNetlink, ContainerizationIO, ContainerizationExtras, ContainerizationArchive, SendableProperty]
|
||||
swift_version: '6.2'
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
6.3.0
|
||||
@@ -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
@@ -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 that’s completely fine.** Be sure to comment on the issue saying you’re 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").
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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
@@ -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"
|
||||
)
|
||||
)
|
||||
@@ -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*
|
||||
@@ -0,0 +1,196 @@
|
||||
<h1>
|
||||
<img alt="Containerization logo" src="./assets/Containerization-Logo.png" width="70" valign="middle">
|
||||
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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`apple/containerization`
|
||||
- 原始仓库:https://github.com/apple/containerization
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+13
@@ -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.
|
||||
@@ -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
|
||||
@@ -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__ */
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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 */
|
||||
@@ -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 */
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 D3–D5 fill them in. Hotplug methods delegate to
|
||||
/// `CHHotplugProvider` (stubbed in D0, real in D2).
|
||||
public final class CHVirtualMachineInstance: Sendable {
|
||||
public typealias Agent = Vminitd
|
||||
|
||||
/// VM-instance configuration. Mirrors the macOS `VZVirtualMachineInstance.Configuration`,
|
||||
/// minus rosetta / nested-virt (which are macOS-only concepts).
|
||||
public struct Configuration: Sendable {
|
||||
public var cpus: Int
|
||||
public var memoryInBytes: UInt64
|
||||
public var mountsByID: [String: [Mount]]
|
||||
public var interfaces: [any Interface]
|
||||
public var kernel: Kernel?
|
||||
public var initialFilesystem: Mount?
|
||||
public var bootLog: BootLog?
|
||||
public var extensions: [any Sendable] = []
|
||||
|
||||
public init() {
|
||||
self.cpus = 4
|
||||
self.memoryInBytes = 1024 * 1024 * 1024
|
||||
self.mountsByID = [:]
|
||||
self.interfaces = []
|
||||
}
|
||||
}
|
||||
|
||||
/// One boot-time virtio-blk disk. Built deterministically in `init` so
|
||||
/// `start()`'s `VmConfig.disks` ordering matches the allocator letters.
|
||||
struct BootDisk: Sendable {
|
||||
let mount: Mount
|
||||
let containerId: String? // nil for rootfs
|
||||
let letter: Character
|
||||
}
|
||||
|
||||
// MARK: - State
|
||||
|
||||
private let _state: Mutex<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; D3–D5 fill these in)
|
||||
|
||||
extension CHVirtualMachineInstance: VirtualMachineInstance {
|
||||
public func start() async throws {
|
||||
try await lock.withLock { _ in
|
||||
guard self.state == .stopped else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "virtual machine is not stopped (\(self.state))"
|
||||
)
|
||||
}
|
||||
self._state.withLock { $0 = .starting }
|
||||
|
||||
do {
|
||||
var vmConfig = try await self.buildVmConfig()
|
||||
for ext in self.config.extensions.compactMap({ $0 as? any CHInstanceExtension }) {
|
||||
try ext.configureCH(&vmConfig)
|
||||
}
|
||||
let finalConfig = vmConfig
|
||||
|
||||
// Pre-bind the stdio vsock listener pool before launching CH.
|
||||
// CH inherits a fs snapshot at fork time and is blind to
|
||||
// anything we add to workDir after — see `_stdioPool` doc.
|
||||
try self.prebindStdioPool()
|
||||
|
||||
try await self.chProcess.start()
|
||||
|
||||
try await chCall { try await self.client.vmCreate(finalConfig) }
|
||||
try await chCall { try await self.client.vmBoot() }
|
||||
|
||||
let fh = try await self.dialVminitdWithRetries()
|
||||
let agent = try await Vminitd(connection: fh, group: self.group)
|
||||
await self.timeSyncer.start(context: agent)
|
||||
|
||||
for ext in self.config.extensions.compactMap({ $0 as? any CHInstanceExtension }) {
|
||||
try ext.didCreate(self)
|
||||
}
|
||||
|
||||
self._state.withLock { $0 = .running }
|
||||
} catch {
|
||||
self.logger?.warning("CH VM start failed; tearing down partial resources: \(error)")
|
||||
await self.teardownAfterFailedStart()
|
||||
self._state.withLock { $0 = .stopped }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reverse the side effects of any partially-completed `start()`:
|
||||
/// terminate cloud-hypervisor, kill registered virtiofsd processes,
|
||||
/// close pre-bound stdio listener fds, remove the workDir, and shut
|
||||
/// down the owned event-loop group. All steps are best-effort and
|
||||
/// safe to invoke whether the corresponding `start()` step ran or not.
|
||||
private func teardownAfterFailedStart() async {
|
||||
try? await self.timeSyncer.close()
|
||||
|
||||
// chProcess.terminate() is a no-op if `start()` never reached the
|
||||
// spawn — otherwise SIGTERM / SIGKILL ladder + reap.
|
||||
await self.chProcess.terminate(graceSeconds: 5)
|
||||
|
||||
// Kills every virtiofsd registered by buildVmConfig (boot-time) or
|
||||
// by an in-flight hotplug. Empty if neither ran.
|
||||
await self.hotplug.shutdown()
|
||||
|
||||
// Close pre-bound stdio listener fds the start path opened in
|
||||
// prebindStdioPool. Files unlink with workDir below.
|
||||
let leftover = self._stdioPool.withLock { pool -> [PreboundListener] in
|
||||
let entries = Array(pool.values)
|
||||
pool.removeAll()
|
||||
return entries
|
||||
}
|
||||
for entry in leftover {
|
||||
_ = close(entry.listenFd)
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: self.workDir)
|
||||
|
||||
// Drain the AHC HTTP client before shutting down the shared
|
||||
// event-loop group, same rationale as `stop()`: AHC's deferred
|
||||
// connection cleanup must not outlive the group it's parked on.
|
||||
try? await self.client.shutdown()
|
||||
|
||||
if self.ownsGroup {
|
||||
try? await self.group.shutdownGracefully()
|
||||
}
|
||||
}
|
||||
|
||||
public func stop() async throws {
|
||||
try await lock.withLock { _ in
|
||||
guard self.state == .running else {
|
||||
throw ContainerizationError(.invalidState, message: "vm is not running")
|
||||
}
|
||||
self._state.withLock { $0 = .stopping }
|
||||
|
||||
try? await self.timeSyncer.close()
|
||||
|
||||
for ext in self.config.extensions.compactMap({ $0 as? any CHInstanceExtension }) {
|
||||
try? await ext.willStop(self)
|
||||
}
|
||||
|
||||
// Best-effort graceful shutdown via REST. The CH process may
|
||||
// already be on its way out, so swallow errors from these.
|
||||
_ = try? await chCall { try await self.client.vmShutdown() }
|
||||
_ = try? await chCall { try await self.client.vmmShutdown() }
|
||||
|
||||
await self.chProcess.terminate(graceSeconds: 10)
|
||||
await self.hotplug.shutdown()
|
||||
|
||||
// Drain the AHC HTTP client before tearing down the shared
|
||||
// event-loop group. AHC parks deferred connection-cleanup
|
||||
// work on the group's event loops after each response; if we
|
||||
// shut the group down with that work still pending, NIO
|
||||
// prints "Cannot schedule tasks on an EventLoop that has
|
||||
// already shut down" (and will hard-crash in future NIO
|
||||
// releases). Must run after the last `chCall` above and
|
||||
// before `group.shutdownGracefully()` below.
|
||||
try? await self.client.shutdown()
|
||||
|
||||
// Close any listening fds for stdio ports the test never
|
||||
// consumed. The files themselves are removed when workDir is
|
||||
// unlinked below.
|
||||
let leftover = self._stdioPool.withLock { pool -> [PreboundListener] in
|
||||
let entries = Array(pool.values)
|
||||
pool.removeAll()
|
||||
return entries
|
||||
}
|
||||
for entry in leftover {
|
||||
_ = close(entry.listenFd)
|
||||
}
|
||||
|
||||
if self.ownsGroup {
|
||||
try? await self.group.shutdownGracefully()
|
||||
}
|
||||
|
||||
try? FileManager.default.removeItem(at: self.workDir)
|
||||
|
||||
self._state.withLock { $0 = .stopped }
|
||||
}
|
||||
}
|
||||
|
||||
public func dialAgent() async throws -> Vminitd {
|
||||
try await lock.withLock { _ in
|
||||
try self.requireRunning()
|
||||
let fh = try await chVsockDial(
|
||||
baseSocket: self.workDir.appendingPathComponent("vsock.sock"),
|
||||
port: Vminitd.port
|
||||
)
|
||||
return try await Vminitd(connection: fh, group: self.group)
|
||||
}
|
||||
}
|
||||
|
||||
public func dial(_ port: UInt32) async throws -> FileHandle {
|
||||
try await lock.withLock { _ in
|
||||
try self.requireRunning()
|
||||
return try await chVsockDial(
|
||||
baseSocket: self.workDir.appendingPathComponent("vsock.sock"),
|
||||
port: port
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject vsock dials when the VM isn't actually running. Without this,
|
||||
/// a dial issued after `stop()` (or before `start()` finished) raced
|
||||
/// against `workDir` removal and surfaced as an opaque "connect: No
|
||||
/// such file or directory" instead of a clear lifecycle error.
|
||||
private func requireRunning() throws {
|
||||
let current = self.state
|
||||
guard current == .running else {
|
||||
throw ContainerizationError(
|
||||
.invalidState,
|
||||
message: "vm is not running (state=\(current))"
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
public func listen(_ port: UInt32) throws -> VsockListener {
|
||||
// Consume from the pre-bound pool (see `_stdioPool` doc).
|
||||
let prebound = _stdioPool.withLock { $0.removeValue(forKey: port) }
|
||||
guard let prebound else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "vsock port \(port) was not pre-bound; only ports "
|
||||
+ "\(Self.stdioPoolBase)..<\(Self.stdioPoolBase + UInt32(Self.stdioPoolSize)) "
|
||||
+ "are available for stdio. Increase CHVirtualMachineInstance.stdioPoolSize "
|
||||
+ "if you need more concurrent stdio streams per VM."
|
||||
)
|
||||
}
|
||||
let listenFd = prebound.listenFd
|
||||
let path = prebound.path
|
||||
logger?.debug("vsock listen consuming pool entry port=\(port) path=\(path.path)")
|
||||
let listener = VsockListener(port: port) { [path, listenFd, logger] _ in
|
||||
logger?.debug("vsock listen finishing port=\(port) closing listenFd=\(listenFd)")
|
||||
_ = close(listenFd)
|
||||
try? FileManager.default.removeItem(at: path)
|
||||
}
|
||||
let acceptLogger = logger
|
||||
// The accept loop calls a blocking accept() syscall, which is
|
||||
// inappropriate for Swift's cooperative thread pool: a pool thread
|
||||
// pinned to accept() can't service other tasks until the syscall
|
||||
// returns. With even a few leaked accept loops (e.g. when a test's
|
||||
// setupIO times out and the listener is finished only when the
|
||||
// 30s timer fires), Task.detached'd accept loops queue behind the
|
||||
// pinned threads and never run, manifesting as the "vsock acceptLoop
|
||||
// starting" log being silent and the dial-back never being seen by
|
||||
// the host. Use libdispatch's global queue instead — it spawns
|
||||
// OS threads on demand and is the right tool for blocking syscalls.
|
||||
DispatchQueue.global(qos: .userInitiated).async { [listener, listenFd] in
|
||||
acceptLogger?.debug("vsock acceptLoop starting port=\(listener.port) listenFd=\(listenFd)")
|
||||
Self.acceptLoop(listenFd: listenFd, into: listener, logger: acceptLogger)
|
||||
acceptLogger?.debug("vsock acceptLoop exited port=\(listener.port)")
|
||||
}
|
||||
return listener
|
||||
}
|
||||
|
||||
/// Bind every port in `stdioPoolBase..<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
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// ExitStatus contains the exit code for a given container process,
|
||||
/// as well as the timestamp at which it exited.
|
||||
public struct ExitStatus: Sendable {
|
||||
/// The exit code for the process.
|
||||
public var exitCode: Int32
|
||||
/// The timestamp when the process exited.
|
||||
public var exitedAt: Date
|
||||
|
||||
public init(exitCode: Int32) {
|
||||
self.exitCode = exitCode
|
||||
self.exitedAt = .now
|
||||
}
|
||||
|
||||
public init(exitCode: Int32, exitedAt: Date) {
|
||||
self.exitCode = exitCode
|
||||
self.exitedAt = exitedAt
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,202 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// Manages single-file mounts by transforming them into virtiofs directory shares
|
||||
/// plus bind mounts.
|
||||
///
|
||||
/// Since virtiofs only supports sharing directories, mounting a single file requires
|
||||
/// sharing the file's parent directory via virtiofs and then bind mounting the specific
|
||||
/// file from that share to the final destination in the container.
|
||||
struct FileMountContext: Sendable {
|
||||
/// Metadata for a single prepared file mount.
|
||||
struct PreparedMount: Sendable {
|
||||
/// Original file path on host
|
||||
let hostFilePath: String
|
||||
/// Where the user wants the file in the container
|
||||
let containerDestination: String
|
||||
/// Just the filename (after resolving symlinks)
|
||||
let filename: String
|
||||
/// The parent directory containing the file (after resolving symlinks)
|
||||
let parentDirectory: URL
|
||||
/// The virtiofs tag (hash of parent dir path). Used to find the AttachedFilesystem
|
||||
let tag: String
|
||||
/// Mount options from the original mount
|
||||
let options: [String]
|
||||
/// Where we mounted the share in the guest (set after mountHoldingDirectories)
|
||||
var guestHoldingPath: String?
|
||||
}
|
||||
|
||||
/// Prepared file mounts for this context
|
||||
var preparedMounts: [PreparedMount]
|
||||
|
||||
/// The transformed mounts to pass to the VM (files replaced with directory shares)
|
||||
private(set) var transformedMounts: [Mount]
|
||||
|
||||
private init() {
|
||||
self.preparedMounts = []
|
||||
self.transformedMounts = []
|
||||
}
|
||||
|
||||
/// Returns true if there are any file mounts that need handling.
|
||||
var hasFileMounts: Bool {
|
||||
!preparedMounts.isEmpty
|
||||
}
|
||||
|
||||
/// Returns the set of virtiofs tags for file mount holding directories.
|
||||
/// These should be filtered out from OCI spec mounts since we mount them
|
||||
/// separately under /run.
|
||||
var holdingDirectoryTags: Set<String> {
|
||||
Set(preparedMounts.map { $0.tag })
|
||||
}
|
||||
}
|
||||
|
||||
extension FileMountContext {
|
||||
/// Prepare mounts for a container, detecting file mounts and transforming them.
|
||||
///
|
||||
/// This method stats each virtiofs mount source. If it's a regular file rather than
|
||||
/// a directory, it shares the file's parent directory via virtiofs and records the
|
||||
/// metadata needed to bind mount the specific file later.
|
||||
///
|
||||
/// - Parameter mounts: The original mounts from the container config
|
||||
/// - Returns: A FileMountContext containing transformed mounts and tracking info
|
||||
static func prepare(mounts: [Mount]) throws -> FileMountContext {
|
||||
var context = FileMountContext()
|
||||
var transformed: [Mount] = []
|
||||
// Track parent directories we've already added a share for to avoid duplicates.
|
||||
var sharedParentTags: Set<String> = []
|
||||
|
||||
for mount in mounts {
|
||||
// Only virtiofs mounts can be files
|
||||
guard case .virtiofs(let runtimeOpts) = mount.runtimeOptions else {
|
||||
transformed.append(mount)
|
||||
continue
|
||||
}
|
||||
|
||||
// Stat the source to see if it's a file
|
||||
let fm = FileManager.default
|
||||
var isDirectory: ObjCBool = false
|
||||
guard fm.fileExists(atPath: mount.source, isDirectory: &isDirectory) else {
|
||||
// Doesn't exist. Let the normal flow handle the error
|
||||
transformed.append(mount)
|
||||
continue
|
||||
}
|
||||
|
||||
if isDirectory.boolValue {
|
||||
// It's a directory, pass through unchanged
|
||||
transformed.append(mount)
|
||||
continue
|
||||
}
|
||||
|
||||
// It's a file, so prepare it.
|
||||
let prepared = try context.prepareFileMount(mount: mount, runtimeOptions: runtimeOpts)
|
||||
|
||||
// Only add the directory share once per unique parent directory.
|
||||
if !sharedParentTags.contains(prepared.tag) {
|
||||
sharedParentTags.insert(prepared.tag)
|
||||
// The destination here is unused. We mount the share ourselves
|
||||
// to a location under /run in mountHoldingDirectories.
|
||||
let directoryShare = Mount.share(
|
||||
source: prepared.parentDirectory.path,
|
||||
destination: "/.file-mount-holding",
|
||||
options: mount.options.filter { $0 != "bind" },
|
||||
runtimeOptions: runtimeOpts
|
||||
)
|
||||
transformed.append(directoryShare)
|
||||
}
|
||||
}
|
||||
|
||||
context.transformedMounts = transformed
|
||||
return context
|
||||
}
|
||||
|
||||
private mutating func prepareFileMount(
|
||||
mount: Mount,
|
||||
runtimeOptions: [String]
|
||||
) throws -> PreparedMount {
|
||||
let resolvedSource = URL(fileURLWithPath: mount.source).resolvingSymlinksInPath()
|
||||
let filename = resolvedSource.lastPathComponent
|
||||
let parentDirectory = resolvedSource.deletingLastPathComponent()
|
||||
let tag = try hashFilePath(path: parentDirectory.path)
|
||||
|
||||
let prepared = PreparedMount(
|
||||
hostFilePath: mount.source,
|
||||
containerDestination: mount.destination,
|
||||
filename: filename,
|
||||
parentDirectory: parentDirectory,
|
||||
tag: tag,
|
||||
options: mount.options,
|
||||
guestHoldingPath: nil
|
||||
)
|
||||
|
||||
preparedMounts.append(prepared)
|
||||
return prepared
|
||||
}
|
||||
}
|
||||
|
||||
extension FileMountContext {
|
||||
/// Set up the holding directory paths for all file mounts.
|
||||
/// Since virtiofs shares are now mounted once at /run/virtiofs, the holding
|
||||
/// directories appear as subdirectories there automatically.
|
||||
/// - Parameters:
|
||||
/// - vmMounts: The AttachedFilesystem array from the VM for this container
|
||||
/// - agent: The VM agent for RPCs (unused, kept for API compatibility)
|
||||
mutating func mountHoldingDirectories(
|
||||
vmMounts: [AttachedFilesystem],
|
||||
agent: any VirtualMachineAgent
|
||||
) async throws {
|
||||
for i in preparedMounts.indices {
|
||||
let prepared = preparedMounts[i]
|
||||
|
||||
// Verify the attached filesystem exists
|
||||
guard
|
||||
vmMounts.first(where: {
|
||||
$0.type == "virtiofs" && $0.source == prepared.tag
|
||||
}) != nil
|
||||
else {
|
||||
throw ContainerizationError(
|
||||
.notFound,
|
||||
message: "could not find attached filesystem for file mount \(prepared.hostFilePath)"
|
||||
)
|
||||
}
|
||||
|
||||
// With unified virtiofs, holding directories are subdirectories under /run/virtiofs
|
||||
let guestPath = "/run/virtiofs/\(prepared.tag)"
|
||||
preparedMounts[i].guestHoldingPath = guestPath
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension FileMountContext {
|
||||
/// Get the bind mounts to append to the OCI spec.
|
||||
func ociBindMounts() -> [ContainerizationOCI.Mount] {
|
||||
preparedMounts.compactMap { prepared in
|
||||
guard let guestPath = prepared.guestHoldingPath else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return ContainerizationOCI.Mount(
|
||||
type: "none",
|
||||
source: "\(guestPath)/\(prepared.filename)",
|
||||
destination: prepared.containerDestination,
|
||||
options: ["bind"] + prepared.options
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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 {}
|
||||
@@ -0,0 +1,23 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// A type that writes the provided Data.
|
||||
public protocol Writer: Sendable {
|
||||
func write(_ data: Data) throws
|
||||
func close() throws
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import ContainerizationOS
|
||||
import Foundation
|
||||
|
||||
/// Type representing an OCI container image.
|
||||
public struct Image: Sendable {
|
||||
private let contentStore: ContentStore
|
||||
/// The description for the image that comprises of its name and a reference to its root descriptor.
|
||||
public let description: Description
|
||||
|
||||
/// A description of the OCI image.
|
||||
public struct Description: Sendable {
|
||||
/// The string reference of the image.
|
||||
public let reference: String
|
||||
/// The descriptor identifying the image.
|
||||
public let descriptor: Descriptor
|
||||
/// The digest for the image.
|
||||
public var digest: String { descriptor.digest }
|
||||
/// The media type of the image.
|
||||
public var mediaType: String { descriptor.mediaType }
|
||||
|
||||
public init(reference: String, descriptor: Descriptor) {
|
||||
self.reference = reference
|
||||
self.descriptor = descriptor
|
||||
}
|
||||
}
|
||||
|
||||
/// The descriptor for the image.
|
||||
public var descriptor: Descriptor { description.descriptor }
|
||||
/// The digest of the image.
|
||||
public var digest: String { description.digest }
|
||||
/// The media type of the image.
|
||||
public var mediaType: String { description.mediaType }
|
||||
/// The string reference for the image.
|
||||
public var reference: String { description.reference }
|
||||
|
||||
public init(description: Description, contentStore: ContentStore) {
|
||||
self.description = description
|
||||
self.contentStore = contentStore
|
||||
}
|
||||
|
||||
/// Returns the underlying OCI index for the image.
|
||||
public func index() async throws -> Index {
|
||||
guard let content: Content = try await contentStore.get(digest: digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return try content.decode()
|
||||
}
|
||||
|
||||
/// Returns the manifest for the specified platform.
|
||||
public func manifest(for platform: Platform) async throws -> Manifest {
|
||||
let index = try await self.index()
|
||||
let desc = index.manifests.first { desc in
|
||||
desc.platform == platform
|
||||
}
|
||||
guard let desc else {
|
||||
throw ContainerizationError(.unsupported, message: "platform \(platform.description)")
|
||||
}
|
||||
guard let content: Content = try await contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return try content.decode()
|
||||
}
|
||||
|
||||
/// Returns the descriptor for the given platform. If it does not exist
|
||||
/// will throw a ContainerizationError with the code set to .invalidArgument.
|
||||
public func descriptor(for platform: Platform) async throws -> Descriptor {
|
||||
let index = try await self.index()
|
||||
let desc = index.manifests.first { $0.platform == platform }
|
||||
guard let desc else {
|
||||
throw ContainerizationError(.invalidArgument, message: "unsupported platform \(platform)")
|
||||
}
|
||||
return desc
|
||||
}
|
||||
|
||||
/// Returns the OCI config for the specified platform.
|
||||
public func config(for platform: Platform) async throws -> ContainerizationOCI.Image {
|
||||
let manifest = try await self.manifest(for: platform)
|
||||
let desc = manifest.config
|
||||
guard let content: Content = try await contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return try content.decode()
|
||||
}
|
||||
|
||||
/// Returns a list of digests to all the referenced OCI objects.
|
||||
public func referencedDigests() async throws -> [String] {
|
||||
var referenced: [String] = [self.digest.trimmingDigestPrefix]
|
||||
let index = try await self.index()
|
||||
for manifest in index.manifests {
|
||||
referenced.append(manifest.digest.trimmingDigestPrefix)
|
||||
guard let m: Manifest = try? await contentStore.get(digest: manifest.digest) else {
|
||||
// If the requested digest does not exist or is not a manifest. Skip.
|
||||
// It's safe to skip processing this digest as it won't have any child layers.
|
||||
continue
|
||||
}
|
||||
let descs = m.layers + [m.config]
|
||||
referenced.append(contentsOf: descs.map { $0.digest.trimmingDigestPrefix })
|
||||
}
|
||||
return referenced
|
||||
}
|
||||
|
||||
/// Returns a reference to the content blob for the image. The specified digest must be referenced by the image in one of its layers.
|
||||
public func getContent(digest: String) async throws -> Content {
|
||||
guard try await self.referencedDigests().contains(digest.trimmingDigestPrefix) else {
|
||||
throw ContainerizationError(.internalError, message: "image \(self.reference) does not reference digest \(digest)")
|
||||
}
|
||||
guard let content: Content = try await contentStore.get(digest: digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(digest)")
|
||||
}
|
||||
return content
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationIO
|
||||
import ContainerizationOCI
|
||||
import Crypto
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
public struct ExportOperation: Sendable {
|
||||
let name: String
|
||||
let tag: String
|
||||
let contentStore: ContentStore
|
||||
let client: ContentClient
|
||||
let progress: ProgressHandler?
|
||||
|
||||
public init(name: String, tag: String, contentStore: ContentStore, client: ContentClient, progress: ProgressHandler? = nil) {
|
||||
self.contentStore = contentStore
|
||||
self.client = client
|
||||
self.progress = progress
|
||||
self.name = name
|
||||
self.tag = tag
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
public func export(index: Descriptor, platforms: (Platform) -> Bool, filter: (Descriptor) -> Bool = { _ in true }) async throws -> Descriptor {
|
||||
var pushQueue: [[Descriptor]] = []
|
||||
var current: [Descriptor] = [index]
|
||||
while !current.isEmpty {
|
||||
let children = try await self.getChildren(descs: current)
|
||||
let matches = try filterPlatforms(matcher: platforms, children).uniqued { $0.digest }
|
||||
pushQueue.append(matches)
|
||||
current = matches
|
||||
}
|
||||
let localIndexData = try await self.createIndex(from: index, matching: platforms)
|
||||
|
||||
await updatePushProgress(pushQueue: pushQueue, localIndexData: localIndexData)
|
||||
|
||||
// We need to work bottom up when pushing an image.
|
||||
// First, the tar blobs / config layers, then, the manifests and so on...
|
||||
// When processing a given "level", the requests maybe made in parallel.
|
||||
// We need to ensure that the child level has been uploaded fully
|
||||
// before uploading the parent level.
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
for layerGroup in pushQueue.reversed() {
|
||||
for chunk in layerGroup.chunks(ofCount: 8) {
|
||||
for desc in chunk.filter(filter) {
|
||||
guard let content = try await self.contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
|
||||
}
|
||||
group.addTask {
|
||||
let readStream = try ReadStream(url: content.path)
|
||||
try await self.pushContent(descriptor: desc, stream: readStream)
|
||||
}
|
||||
}
|
||||
try await group.waitForAll()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Lastly, we need to construct and push a new index, since we may
|
||||
// have pushed content only for specific platforms.
|
||||
let digest = SHA256.hash(data: localIndexData)
|
||||
// The descriptor's mediaType becomes the HTTP Content-Type in
|
||||
// RegistryClient.push and must match the mediaType field inside
|
||||
// localIndexData. Registries reject mismatches with MANIFEST_INVALID.
|
||||
let descriptor = Descriptor(
|
||||
mediaType: index.mediaType,
|
||||
digest: digest.digestString,
|
||||
size: Int64(localIndexData.count))
|
||||
let stream = ReadStream(data: localIndexData)
|
||||
try await self.pushContent(descriptor: descriptor, stream: stream)
|
||||
return descriptor
|
||||
}
|
||||
|
||||
private func updatePushProgress(pushQueue: [[Descriptor]], localIndexData: Data) async {
|
||||
for layerGroup in pushQueue {
|
||||
for desc in layerGroup {
|
||||
await progress?([
|
||||
.addTotalSize(desc.size),
|
||||
.addTotalItems(1),
|
||||
])
|
||||
}
|
||||
}
|
||||
await progress?([
|
||||
.addTotalSize(Int64(localIndexData.count)),
|
||||
.addTotalItems(1),
|
||||
])
|
||||
}
|
||||
|
||||
private func createIndex(from index: Descriptor, matching: (Platform) -> Bool) async throws -> Data {
|
||||
guard let content = try await self.contentStore.get(digest: index.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(index.digest)")
|
||||
}
|
||||
var idx: Index = try content.decode()
|
||||
let manifests = idx.manifests
|
||||
var matchedManifests: [Descriptor] = []
|
||||
var skippedPlatforms = false
|
||||
for manifest in manifests {
|
||||
guard let p = manifest.platform else {
|
||||
continue
|
||||
}
|
||||
if matching(p) {
|
||||
matchedManifests.append(manifest)
|
||||
} else {
|
||||
skippedPlatforms = true
|
||||
}
|
||||
}
|
||||
if !skippedPlatforms {
|
||||
return try content.data()
|
||||
}
|
||||
idx.manifests = matchedManifests
|
||||
return try JSONEncoder().encode(idx)
|
||||
}
|
||||
|
||||
private func pushContent(descriptor: Descriptor, stream: ReadStream) async throws {
|
||||
do {
|
||||
let generator = {
|
||||
try stream.reset()
|
||||
return stream.stream
|
||||
}
|
||||
try await client.push(name: name, ref: tag, descriptor: descriptor, streamGenerator: generator, progress: progress)
|
||||
await progress?([
|
||||
.addSize(descriptor.size),
|
||||
.addItems(1),
|
||||
])
|
||||
} catch let err as ContainerizationError {
|
||||
guard err.code != .exists else {
|
||||
// We reported the total items and size and have to account for them in existing content.
|
||||
await progress?([
|
||||
.addSize(descriptor.size),
|
||||
.addItems(1),
|
||||
])
|
||||
return
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
private func getChildren(descs: [Descriptor]) async throws -> [Descriptor] {
|
||||
var out: [Descriptor] = []
|
||||
for desc in descs {
|
||||
let mediaType = desc.mediaType
|
||||
guard let content = try await self.contentStore.get(digest: desc.digest) else {
|
||||
throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)")
|
||||
}
|
||||
switch mediaType {
|
||||
case MediaTypes.index, MediaTypes.dockerManifestList:
|
||||
let index: Index = try content.decode()
|
||||
out.append(contentsOf: index.manifests)
|
||||
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
||||
let manifest: Manifest = try content.decode()
|
||||
out.append(manifest.config)
|
||||
out.append(contentsOf: manifest.layers)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
public struct ImportOperation: Sendable {
|
||||
static let decoder = JSONDecoder()
|
||||
|
||||
let client: ContentClient
|
||||
let ingestDir: URL
|
||||
let contentStore: ContentStore
|
||||
let progress: ProgressHandler?
|
||||
let name: String
|
||||
let maxConcurrentDownloads: Int
|
||||
|
||||
public init(name: String, contentStore: ContentStore, client: ContentClient, ingestDir: URL, progress: ProgressHandler? = nil, maxConcurrentDownloads: Int = 3) {
|
||||
self.client = client
|
||||
self.ingestDir = ingestDir
|
||||
self.contentStore = contentStore
|
||||
self.progress = progress
|
||||
self.name = name
|
||||
self.maxConcurrentDownloads = maxConcurrentDownloads
|
||||
}
|
||||
|
||||
/// Pull the required image layers for the provided descriptor and platform(s) into the given directory using the provided client. Returns a descriptor to the Index manifest.
|
||||
public func `import`(root: Descriptor, matcher: (ContainerizationOCI.Platform) -> Bool) async throws -> Descriptor {
|
||||
var toProcess = [root]
|
||||
while !toProcess.isEmpty {
|
||||
// Count the total number of blobs and their size
|
||||
if let progress {
|
||||
var size: Int64 = 0
|
||||
for desc in toProcess {
|
||||
size += desc.size
|
||||
}
|
||||
await progress([
|
||||
.addTotalSize(size),
|
||||
.addTotalItems(toProcess.count),
|
||||
])
|
||||
}
|
||||
|
||||
try await self.fetchAll(toProcess)
|
||||
let children = try await self.walk(toProcess)
|
||||
let filtered = try filterPlatforms(matcher: matcher, children)
|
||||
toProcess = filtered.uniqued { $0.digest }
|
||||
}
|
||||
|
||||
guard root.mediaType != MediaTypes.dockerManifestList && root.mediaType != MediaTypes.index else {
|
||||
return root
|
||||
}
|
||||
|
||||
// Create an index for the root descriptor and write it to the content store
|
||||
let index = try await self.createIndex(for: root)
|
||||
// In cases where the root descriptor pointed to `MediaTypes.imageManifest`
|
||||
// Or `MediaTypes.dockerManifest`, it is required that we check the supported platform
|
||||
// matches the platforms we were asked to pull. This can be done only after we created
|
||||
// the Index.
|
||||
let supportedPlatforms = index.manifests.compactMap { $0.platform }
|
||||
guard supportedPlatforms.allSatisfy(matcher) else {
|
||||
throw ContainerizationError(.unsupported, message: "image \(root.digest) does not support required platforms")
|
||||
}
|
||||
let writer = try ContentWriter(for: self.ingestDir)
|
||||
let result = try writer.create(from: index)
|
||||
return Descriptor(
|
||||
mediaType: MediaTypes.index,
|
||||
digest: result.digest.digestString,
|
||||
size: Int64(result.size))
|
||||
}
|
||||
|
||||
private func getManifestContent<T: Sendable & Codable>(descriptor: Descriptor) async throws -> T {
|
||||
do {
|
||||
if let content = try await self.contentStore.get(digest: descriptor.digest.trimmingDigestPrefix) {
|
||||
return try content.decode()
|
||||
}
|
||||
if let content = try? LocalContent(path: ingestDir.appending(path: descriptor.digest.trimmingDigestPrefix)) {
|
||||
return try content.decode()
|
||||
}
|
||||
return try await self.client.fetch(name: name, descriptor: descriptor)
|
||||
} catch {
|
||||
throw ContainerizationError(.internalError, message: "cannot fetch content with digest \(descriptor.digest)", cause: error)
|
||||
}
|
||||
}
|
||||
|
||||
private func walk(_ descriptors: [Descriptor]) async throws -> [Descriptor] {
|
||||
var out: [Descriptor] = []
|
||||
for desc in descriptors {
|
||||
let mediaType = desc.mediaType
|
||||
switch mediaType {
|
||||
case MediaTypes.index, MediaTypes.dockerManifestList:
|
||||
let index: Index = try await self.getManifestContent(descriptor: desc)
|
||||
out.append(contentsOf: index.manifests)
|
||||
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
||||
let manifest: Manifest = try await self.getManifestContent(descriptor: desc)
|
||||
out.append(manifest.config)
|
||||
out.append(contentsOf: manifest.layers)
|
||||
default:
|
||||
// TODO: Explicitly handle other content types
|
||||
continue
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
private func fetchAll(_ descriptors: [Descriptor]) async throws {
|
||||
try await withThrowingTaskGroup(of: Void.self) { group in
|
||||
var iterator = descriptors.makeIterator()
|
||||
// Start initial batch of concurrent downloads based on maxConcurrentDownloads
|
||||
for _ in 0..<self.maxConcurrentDownloads {
|
||||
if let desc = iterator.next() {
|
||||
group.addTask {
|
||||
try await self.fetch(desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
// As tasks complete, add new ones to maintain concurrency
|
||||
for try await _ in group {
|
||||
if let desc = iterator.next() {
|
||||
group.addTask {
|
||||
try await self.fetch(desc)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func fetch(_ descriptor: Descriptor) async throws {
|
||||
if let found = try await self.contentStore.get(digest: descriptor.digest) {
|
||||
try FileManager.default.copyItem(at: found.path, to: ingestDir.appendingPathComponent(descriptor.digest.trimmingDigestPrefix))
|
||||
await progress?([
|
||||
// Count the size of the blob
|
||||
.addSize(descriptor.size),
|
||||
// Count the number of blobs
|
||||
.addItems(1),
|
||||
])
|
||||
return
|
||||
}
|
||||
|
||||
if descriptor.size > 1.mib() {
|
||||
try await self.fetchBlob(descriptor)
|
||||
} else {
|
||||
try await self.fetchData(descriptor)
|
||||
}
|
||||
// Count the number of blobs
|
||||
await progress?([
|
||||
.addItems(1)
|
||||
])
|
||||
}
|
||||
|
||||
private func fetchBlob(_ descriptor: Descriptor) async throws {
|
||||
let id = UUID().uuidString
|
||||
let fm = FileManager.default
|
||||
let tempFile = ingestDir.appendingPathComponent(id)
|
||||
let (_, digest) = try await client.fetchBlob(name: name, descriptor: descriptor, into: tempFile, progress: progress)
|
||||
guard digest.digestString == descriptor.digest else {
|
||||
throw ContainerizationError(.internalError, message: "digest mismatch expected \(descriptor.digest), got \(digest.digestString)")
|
||||
}
|
||||
do {
|
||||
try fm.moveItem(at: tempFile, to: ingestDir.appendingPathComponent(digest.encoded))
|
||||
} catch let err as NSError {
|
||||
guard err.code == NSFileWriteFileExistsError else {
|
||||
throw err
|
||||
}
|
||||
try fm.removeItem(at: tempFile)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func fetchData(_ descriptor: Descriptor) async throws -> Data {
|
||||
let data = try await client.fetchData(name: name, descriptor: descriptor)
|
||||
let writer = try ContentWriter(for: ingestDir)
|
||||
let result = try writer.write(data)
|
||||
if let progress {
|
||||
let size = Int64(result.size)
|
||||
await progress([
|
||||
.addSize(size)
|
||||
])
|
||||
}
|
||||
guard result.digest.digestString == descriptor.digest else {
|
||||
throw ContainerizationError(.internalError, message: "digest mismatch expected \(descriptor.digest), got \(result.digest.digestString)")
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
private func createIndex(for root: Descriptor) async throws -> Index {
|
||||
switch root.mediaType {
|
||||
case MediaTypes.index, MediaTypes.dockerManifestList:
|
||||
return try await self.getManifestContent(descriptor: root)
|
||||
case MediaTypes.imageManifest, MediaTypes.dockerManifest:
|
||||
let supportedPlatforms = try await getSupportedPlatforms(for: root)
|
||||
guard supportedPlatforms.count == 1 else {
|
||||
throw ContainerizationError(
|
||||
.internalError,
|
||||
message:
|
||||
"descriptor \(root.mediaType) with digest \(root.digest) does not list any supported platform or supports more than one platform, supported platforms: \(supportedPlatforms)"
|
||||
)
|
||||
}
|
||||
let platform = supportedPlatforms.first!
|
||||
var root = root
|
||||
root.platform = platform
|
||||
let index = ContainerizationOCI.Index(
|
||||
schemaVersion: 2, manifests: [root],
|
||||
annotations: [
|
||||
// indicate that this is a synthesized index which is not directly user facing
|
||||
AnnotationKeys.containerizationIndexIndirect: "true"
|
||||
])
|
||||
return index
|
||||
default:
|
||||
throw ContainerizationError(.internalError, message: "failed to create index for descriptor \(root.digest), media type \(root.mediaType)")
|
||||
}
|
||||
}
|
||||
|
||||
private func getSupportedPlatforms(for root: Descriptor) async throws -> [ContainerizationOCI.Platform] {
|
||||
var supportedPlatforms: [ContainerizationOCI.Platform] = []
|
||||
var toProcess = [root]
|
||||
while !toProcess.isEmpty {
|
||||
let children = try await self.walk(toProcess)
|
||||
for child in children {
|
||||
if let p = child.platform {
|
||||
supportedPlatforms.append(p)
|
||||
continue
|
||||
}
|
||||
switch child.mediaType {
|
||||
case MediaTypes.imageConfig, MediaTypes.dockerImageConfig:
|
||||
let config: ContainerizationOCI.Image = try await self.getManifestContent(descriptor: child)
|
||||
let p = ContainerizationOCI.Platform(
|
||||
arch: config.architecture, os: config.os, osFeatures: config.osFeatures, variant: config.variant
|
||||
)
|
||||
supportedPlatforms.append(p)
|
||||
default:
|
||||
continue
|
||||
}
|
||||
}
|
||||
toProcess = children
|
||||
}
|
||||
return supportedPlatforms
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
/// Exports the specified images and their associated layers to an OCI Image Layout directory.
|
||||
/// This function saves the images identified by the `references` array, including their
|
||||
/// manifests and layer blobs, into a directory structure compliant with the OCI Image Layout specification at the given `out` URL.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - references: A list image references that exists in the `ImageStore` that are to be saved in the OCI Image Layout format.
|
||||
/// - out: A URL to a directory on disk at which the OCI Image Layout structure will be created.
|
||||
/// - platform: An optional parameter to indicate the platform to be saved for the images.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms by the images will be saved.
|
||||
///
|
||||
public func save(references: [String], out: URL, platform: Platform? = nil) async throws {
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let fileManager = FileManager.default
|
||||
let tempDir = fileManager.uniqueTemporaryDirectory()
|
||||
defer {
|
||||
try? fileManager.removeItem(at: tempDir)
|
||||
}
|
||||
|
||||
var toSave: [Image] = []
|
||||
for reference in references {
|
||||
let image = try await self.get(reference: reference)
|
||||
let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index]
|
||||
guard allowedMediaTypes.contains(image.mediaType) else {
|
||||
throw ContainerizationError(.internalError, message: "cannot save image \(image.reference) with Index media type \(image.mediaType)")
|
||||
}
|
||||
toSave.append(image)
|
||||
}
|
||||
let client = try LocalOCILayoutClient(root: out)
|
||||
var saved: [Descriptor] = []
|
||||
|
||||
for image in toSave {
|
||||
let ref = try Reference.parse(image.reference)
|
||||
let name = ref.path
|
||||
guard let tag = ref.tag ?? ref.digest else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(image.reference)")
|
||||
}
|
||||
let operation = ExportOperation(name: name, tag: tag, contentStore: self.contentStore, client: client, progress: nil)
|
||||
var descriptor = try await operation.export(index: image.descriptor, platforms: matcher)
|
||||
client.setImageReferenceAnnotation(descriptor: &descriptor, reference: image.reference)
|
||||
saved.append(descriptor)
|
||||
}
|
||||
try client.createOCILayoutStructure(directory: out, manifests: saved)
|
||||
}
|
||||
|
||||
/// Imports one or more images and their associated layers from an OCI Image Layout directory.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - directory: A URL to a directory on disk at that follows the OCI Image Layout structure.
|
||||
/// - progress: An optional handler over which progress update events about the load operation can be received.
|
||||
/// - Returns: The list of images that were loaded into the `ImageStore`.
|
||||
///
|
||||
public func load(from directory: URL, progress: ProgressHandler? = nil) async throws -> [Image] {
|
||||
let client = try LocalOCILayoutClient(root: directory)
|
||||
let index = try client.loadIndexFromOCILayout(directory: directory)
|
||||
let matcher = createPlatformMatcher(for: nil)
|
||||
|
||||
var loaded: [Image.Description] = []
|
||||
let (id, tempDir) = try await self.contentStore.newIngestSession()
|
||||
do {
|
||||
for descriptor in index.manifests {
|
||||
let reference = client.getImageReferencefromDescriptor(descriptor: descriptor)
|
||||
let ref = try Reference.parse(reference)
|
||||
let name = ref.path
|
||||
let operation = ImportOperation(name: name, contentStore: self.contentStore, client: client, ingestDir: tempDir, progress: progress)
|
||||
let indexDesc = try await operation.import(root: descriptor, matcher: matcher)
|
||||
loaded.append(Image.Description(reference: reference, descriptor: indexDesc))
|
||||
}
|
||||
|
||||
let loadedImages = loaded
|
||||
let importedImages = try await self.lock.withLock { lock in
|
||||
var images: [Image] = []
|
||||
try await self.contentStore.completeIngestSession(id)
|
||||
for description in loadedImages {
|
||||
let img = try await self._create(description: description, lock: lock)
|
||||
images.append(img)
|
||||
}
|
||||
return images
|
||||
}
|
||||
guard importedImages.count > 0 else {
|
||||
throw ContainerizationError(.internalError, message: "failed to import image")
|
||||
}
|
||||
return importedImages
|
||||
} catch {
|
||||
try? await self.contentStore.cancelIngestSession(id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
extension ImageStore {
|
||||
/// A ReferenceManager handles the mappings between an image's
|
||||
/// reference and the underlying descriptor inside of a content store.
|
||||
internal actor ReferenceManager {
|
||||
private let path: URL
|
||||
|
||||
private typealias State = [String: Descriptor]
|
||||
private var images: State
|
||||
|
||||
public init(path: URL) throws {
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
|
||||
self.path = path
|
||||
self.images = [:]
|
||||
}
|
||||
|
||||
private func load() throws -> State {
|
||||
let statePath = self.path.appendingPathComponent("state.json")
|
||||
guard FileManager.default.fileExists(atPath: statePath.absolutePath()) else {
|
||||
return [:]
|
||||
}
|
||||
do {
|
||||
let data = try Data(contentsOf: statePath)
|
||||
return try JSONDecoder().decode(State.self, from: data)
|
||||
} catch {
|
||||
throw ContainerizationError(.internalError, message: "failed to load image state \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
private func save(_ state: State) throws {
|
||||
let statePath = self.path.appendingPathComponent("state.json")
|
||||
try JSONEncoder().encode(state).write(to: statePath)
|
||||
}
|
||||
|
||||
public func delete(reference: String) throws {
|
||||
var state = try self.load()
|
||||
state.removeValue(forKey: reference)
|
||||
try self.save(state)
|
||||
}
|
||||
|
||||
public func delete(image: Image.Description) throws {
|
||||
try self.delete(reference: image.reference)
|
||||
}
|
||||
|
||||
public func create(description: Image.Description) throws {
|
||||
var state = try self.load()
|
||||
state[description.reference] = description.descriptor
|
||||
try self.save(state)
|
||||
}
|
||||
|
||||
public func list() throws -> [Image.Description] {
|
||||
let state = try self.load()
|
||||
return state.map { key, val in
|
||||
let description = Image.Description(reference: key, descriptor: val)
|
||||
return description
|
||||
}
|
||||
}
|
||||
|
||||
public func get(reference: String) throws -> Image.Description {
|
||||
let images = try self.list()
|
||||
let hit = images.first(where: { image in
|
||||
image.reference == reference
|
||||
})
|
||||
guard let hit else {
|
||||
throw ContainerizationError(.notFound, message: "image \(reference) not found")
|
||||
}
|
||||
return hit
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,395 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationExtras
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// An ImageStore handles the mappings between an image's
|
||||
/// reference and the underlying descriptor inside of a content store.
|
||||
public actor ImageStore: Sendable {
|
||||
/// The ImageStore path it was created with.
|
||||
public nonisolated let path: URL
|
||||
|
||||
private let referenceManager: ReferenceManager
|
||||
internal let contentStore: ContentStore
|
||||
internal let lock: AsyncLock = AsyncLock()
|
||||
|
||||
public init(path: URL, contentStore: ContentStore? = nil) throws {
|
||||
try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true)
|
||||
|
||||
if let contentStore {
|
||||
self.contentStore = contentStore
|
||||
} else {
|
||||
self.contentStore = try LocalContentStore(path: path.appendingPathComponent("content"))
|
||||
}
|
||||
|
||||
self.path = path
|
||||
self.referenceManager = try ReferenceManager(path: path)
|
||||
}
|
||||
|
||||
/// Return the default image store for the current user.
|
||||
public static let `default`: ImageStore = {
|
||||
do {
|
||||
let root = try defaultRoot()
|
||||
return try ImageStore(path: root)
|
||||
} catch {
|
||||
fatalError("unable to initialize default ImageStore \(error)")
|
||||
}
|
||||
}()
|
||||
|
||||
private static func defaultRoot() throws -> URL {
|
||||
let root = FileManager.default.urls(
|
||||
for: .applicationSupportDirectory,
|
||||
in: .userDomainMask
|
||||
).first
|
||||
guard let root else {
|
||||
throw ContainerizationError(.notFound, message: "unable to get Application Support directory for current user")
|
||||
}
|
||||
return root.appendingPathComponent("com.apple.containerization")
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageStore {
|
||||
/// Get an image from the `ImageStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: Name of the image.
|
||||
/// - pull: Pull the image if it is not found.
|
||||
///
|
||||
/// - Returns: A `Containerization.Image` object whose `reference` matches the given string.
|
||||
/// This method throws a `ContainerizationError(code: .notFound)` if the provided reference does not exist in the `ImageStore`.
|
||||
public func get(reference: String, pull: Bool = false) async throws -> Image {
|
||||
do {
|
||||
let desc = try await self.referenceManager.get(reference: reference)
|
||||
return Image(description: desc, contentStore: self.contentStore)
|
||||
} catch let error as ContainerizationError {
|
||||
if error.code == .notFound && pull {
|
||||
return try await self.pull(reference: reference)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Get a list of all images in the `ImageStore`.
|
||||
///
|
||||
/// - Returns: A `[Containerization.Image]` for all the images in the `ImageStore`.
|
||||
public func list() async throws -> [Image] {
|
||||
try await self.referenceManager.list().map { desc in
|
||||
Image(description: desc, contentStore: self.contentStore)
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a new image in the `ImageStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - description: The underlying `Image.Description` that contains information about the reference and index descriptor for the image to be created.
|
||||
///
|
||||
/// - Note: It is assumed that the underlying manifests and blob layers for the image already exists in the `ContentStore` that the `ImageStore` was initialized with. This method is invoked when the `pull(...)` , `load(...)` and `tag(...)` methods are used.
|
||||
/// - Returns: A `Containerization.Image`
|
||||
@discardableResult
|
||||
public func create(description: Image.Description) async throws -> Image {
|
||||
try await self.lock.withLock { ctx in
|
||||
try await self._create(description: description, lock: ctx)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
internal func _create(description: Image.Description, lock: AsyncLock.Context) async throws -> Image {
|
||||
try await self.referenceManager.create(description: description)
|
||||
return Image(description: description, contentStore: self.contentStore)
|
||||
}
|
||||
|
||||
/// Delete an image from the `ImageStore`.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: Name of the image that is to be deleted.
|
||||
/// - performCleanup: Perform a garbage collection on the `ContentStore`, removing all unreferenced image layers and manifests,
|
||||
public func delete(reference: String, performCleanup: Bool = false) async throws {
|
||||
try await self.lock.withLock { lockCtx in
|
||||
try await self.referenceManager.delete(reference: reference)
|
||||
if performCleanup {
|
||||
try await self._cleanUpOrphanedBlobs(lockCtx)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clean up orphaned blobs that are no longer referenced by any image.
|
||||
///
|
||||
/// - Returns: Returns a tuple of `(deleted, freed)`.
|
||||
/// `deleted` : A list of the names of the content items that were deleted from the `ContentStore`,
|
||||
/// `freed` : The total size of the items that were deleted.
|
||||
@discardableResult
|
||||
public func cleanUpOrphanedBlobs() async throws -> (deleted: [String], freed: UInt64) {
|
||||
try await self.lock.withLock { lockCtx in
|
||||
try await self._cleanUpOrphanedBlobs(lockCtx)
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate the size of orphaned blobs without deleting them.
|
||||
///
|
||||
/// - Returns: The total size in bytes of blobs that are not referenced by any image.
|
||||
public func calculateOrphanedBlobsSize() async throws -> UInt64 {
|
||||
try await self.lock.withLock { lockCtx in
|
||||
try await self._calculateOrphanedBlobsSize(lockCtx)
|
||||
}
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private func _cleanUpOrphanedBlobs(_ lock: AsyncLock.Context) async throws -> (deleted: [String], freed: UInt64) {
|
||||
let images = try await self.list()
|
||||
var referenced: [String] = []
|
||||
for image in images {
|
||||
try await referenced.append(contentsOf: image.referencedDigests().uniqued())
|
||||
}
|
||||
let (deleted, size) = try await self.contentStore.delete(keeping: referenced)
|
||||
return (deleted, size)
|
||||
}
|
||||
|
||||
private func _calculateOrphanedBlobsSize(_ lock: AsyncLock.Context) async throws -> UInt64 {
|
||||
let images = try await self.list()
|
||||
var referenced: [String] = []
|
||||
for image in images {
|
||||
try await referenced.append(contentsOf: image.referencedDigests().uniqued())
|
||||
}
|
||||
|
||||
// Calculate size of blobs not in the referenced list
|
||||
let referencedSet = Set(referenced.map { $0.trimmingDigestPrefix })
|
||||
let blobsPath = self.path.appendingPathComponent("content/blobs/sha256")
|
||||
|
||||
let fileManager = FileManager.default
|
||||
let allBlobs = try fileManager.contentsOfDirectory(
|
||||
at: blobsPath,
|
||||
includingPropertiesForKeys: [.fileSizeKey],
|
||||
options: [.skipsHiddenFiles]
|
||||
)
|
||||
|
||||
var orphanedSize: UInt64 = 0
|
||||
for blobURL in allBlobs {
|
||||
let digest = blobURL.lastPathComponent
|
||||
if !referencedSet.contains(digest) {
|
||||
if let resourceValues = try? blobURL.resourceValues(forKeys: [.fileSizeKey]),
|
||||
let size = resourceValues.fileSize
|
||||
{
|
||||
orphanedSize += UInt64(size)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return orphanedSize
|
||||
}
|
||||
|
||||
/// Tag an existing image such that it can be referenced by another name.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - existing: The reference to an image that already exists in the `ImageStore`.
|
||||
/// - new: The new reference by which the image should also be referenced as.
|
||||
/// - Note: The new image created in the `ImageStore` will have the same `Image.Description`
|
||||
/// as that of the image with reference `existing.`
|
||||
/// - Returns: A `Containerization.Image` object to the newly created image.
|
||||
public func tag(existing: String, new: String) async throws -> Image {
|
||||
let old = try await self.get(reference: existing)
|
||||
let descriptor = old.descriptor
|
||||
do {
|
||||
_ = try Reference.parse(new)
|
||||
} catch {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid reference \(new), error: \(error)")
|
||||
}
|
||||
let newDescription = Image.Description(reference: new, descriptor: descriptor)
|
||||
return try await self.create(description: newDescription)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageStore {
|
||||
/// Pull an image and its associated manifest and blob layers from a remote registry.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: A string that references an image in a remote registry of the form `<host>[:<port>]/repository:<tag>`
|
||||
/// For example: "docker.io/library/alpine:latest".
|
||||
/// - platform: An optional parameter to indicate the platform to be pulled for the image.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms by the image will be pulled.
|
||||
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
|
||||
/// Defaults to false, meaning the connection to the registry will be over https.
|
||||
/// - auth: An object that implements the `Authentication` protocol,
|
||||
/// used to add any credentials to the HTTP requests that are made to the registry.
|
||||
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
|
||||
/// - progress: An optional handler over which progress update events about the pull operation can be received.
|
||||
///
|
||||
/// - Returns: A `Containerization.Image` object to the newly pulled image.
|
||||
public func pull(
|
||||
reference: String, platform: Platform? = nil, insecure: Bool = false,
|
||||
auth: Authentication? = nil, progress: ProgressHandler? = nil, maxConcurrentDownloads: Int = 3
|
||||
) async throws -> Image {
|
||||
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
|
||||
|
||||
let ref = try Reference.parse(reference)
|
||||
let name = ref.path
|
||||
guard let tag = ref.tag ?? ref.digest else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)")
|
||||
}
|
||||
|
||||
let rootDescriptor = try await client.resolve(name: name, tag: tag)
|
||||
let (id, tempDir) = try await self.contentStore.newIngestSession()
|
||||
let operation = ImportOperation(
|
||||
name: name, contentStore: self.contentStore, client: client, ingestDir: tempDir, progress: progress, maxConcurrentDownloads: maxConcurrentDownloads)
|
||||
do {
|
||||
let index = try await operation.import(root: rootDescriptor, matcher: matcher)
|
||||
return try await self.lock.withLock { lock in
|
||||
try await self.contentStore.completeIngestSession(id)
|
||||
let description = Image.Description(reference: reference, descriptor: index)
|
||||
let image = try await self._create(description: description, lock: lock)
|
||||
return image
|
||||
}
|
||||
} catch {
|
||||
try? await self.contentStore.cancelIngestSession(id)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/// Push an image and its associated manifest and blob layers to a remote registry.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - reference: A string that references an image in the `ImageStore`. It must be of the form `<host>[:<port>]/repository:<tag>`
|
||||
/// For example: "ghcr.io/foo-bar-baz/image:v1".
|
||||
/// - platform: An optional parameter to indicate the platform to be pushed for the image.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms by the image will be pushed to the remote registry.
|
||||
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
|
||||
/// Defaults to false, meaning the connection to the registry will be over https.
|
||||
/// - auth: An object that implements the `Authentication` protocol,
|
||||
/// used to add any credentials to the HTTP requests that are made to the registry.
|
||||
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
|
||||
/// - progress: An optional handler over which progress update events about the push operation can be received.
|
||||
///
|
||||
public func push(reference: String, platform: Platform? = nil, insecure: Bool = false, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws {
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth, tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
|
||||
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
|
||||
}
|
||||
|
||||
/// Push multiple image references to a remote registry, sharing a single ``RegistryClient``.
|
||||
///
|
||||
/// All references must resolve to the same registry host. Passing references that target
|
||||
/// different hosts throws a ``ContainerizationError`` with code ``invalidArgument``.
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - references: An array of fully qualified image reference strings to push.
|
||||
/// Each must include a host (e.g., `"ghcr.io/myrepo/myimage:v1"`).
|
||||
/// - platform: An optional parameter to indicate the platform to be pushed for each image.
|
||||
/// Defaults to `nil` signifying that layers for all supported platforms will be pushed.
|
||||
/// - insecure: A boolean indicating if the connection to the remote registry should be made via plain-text http or not.
|
||||
/// Defaults to false, meaning the connection to the registry will be over https.
|
||||
/// - auth: An object that implements the `Authentication` protocol,
|
||||
/// used to add any credentials to the HTTP requests that are made to the registry.
|
||||
/// Defaults to `nil` meaning no additional credentials are added to any HTTP requests made to the registry.
|
||||
/// - maxConcurrentUploads: Maximum number of concurrent tag pushes. Defaults to 3.
|
||||
/// - progress: An optional handler over which progress update events about the push operations can be received.
|
||||
///
|
||||
public func push(
|
||||
references: [String], platform: Platform? = nil, insecure: Bool = false,
|
||||
auth: Authentication? = nil, maxConcurrentUploads: Int = 3, progress: ProgressHandler? = nil
|
||||
) async throws {
|
||||
guard let firstReference = references.first else {
|
||||
return
|
||||
}
|
||||
|
||||
// Parse all references upfront: validate hosts and avoid re-parsing inside tasks.
|
||||
let parsed = try references.map { ref in try Reference.parse(ref) }
|
||||
let hosts = parsed.compactMap { $0.resolvedDomain }
|
||||
guard hosts.count == references.count else {
|
||||
throw ContainerizationError(.invalidArgument, message: "all references must include a host")
|
||||
}
|
||||
let uniqueHosts = Set(hosts)
|
||||
guard uniqueHosts.count == 1 else {
|
||||
throw ContainerizationError(
|
||||
.invalidArgument,
|
||||
message: "all references must target the same registry host, got: \(uniqueHosts.sorted().joined(separator: ", "))")
|
||||
}
|
||||
|
||||
let matcher = createPlatformMatcher(for: platform)
|
||||
let client = try RegistryClient(
|
||||
reference: firstReference, insecure: insecure, auth: auth,
|
||||
tlsConfiguration: TLSUtils.makeEnvironmentAwareTLSConfiguration())
|
||||
|
||||
let pushOne: @Sendable (String) async -> (String, String?) = { reference in
|
||||
do {
|
||||
try await self.pushSingle(reference: reference, client: client, matcher: matcher, progress: progress)
|
||||
return (reference, nil)
|
||||
} catch {
|
||||
return (reference, String(describing: error))
|
||||
}
|
||||
}
|
||||
|
||||
var iterator = references.makeIterator()
|
||||
var failures: [(reference: String, message: String)] = []
|
||||
|
||||
await withTaskGroup(of: (String, String?).self) { group in
|
||||
for _ in 0..<maxConcurrentUploads {
|
||||
guard let reference = iterator.next() else { break }
|
||||
group.addTask { await pushOne(reference) }
|
||||
}
|
||||
for await (ref, error) in group {
|
||||
if let error {
|
||||
failures.append((ref, error))
|
||||
}
|
||||
if let reference = iterator.next() {
|
||||
group.addTask { await pushOne(reference) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !failures.isEmpty {
|
||||
let details = failures.map { "\($0.reference): \($0.message)" }.joined(separator: "\n")
|
||||
throw ContainerizationError(.internalError, message: "failed to push one or more images:\n\(details)")
|
||||
}
|
||||
}
|
||||
|
||||
private func pushSingle(
|
||||
reference: String, client: ContentClient, matcher: @Sendable (Platform) -> Bool, progress: ProgressHandler?
|
||||
) async throws {
|
||||
let allowedMediaTypes = [MediaTypes.dockerManifestList, MediaTypes.index]
|
||||
let img = try await self.get(reference: reference)
|
||||
guard allowedMediaTypes.contains(img.mediaType) else {
|
||||
throw ContainerizationError(.internalError, message: "cannot push image \(reference): unsupported media type \(img.mediaType), expected an index or manifest list")
|
||||
}
|
||||
let ref = try Reference.parse(reference)
|
||||
guard let tag = ref.tag ?? ref.digest else {
|
||||
throw ContainerizationError(.invalidArgument, message: "invalid tag/digest for image reference \(reference)")
|
||||
}
|
||||
let operation = ExportOperation(name: ref.path, tag: tag, contentStore: self.contentStore, client: client, progress: progress)
|
||||
try await operation.export(index: img.descriptor, platforms: matcher)
|
||||
}
|
||||
}
|
||||
|
||||
extension ImageStore {
|
||||
/// Get the image for the init block from the image store.
|
||||
/// If the image does not exist locally, pull the image.
|
||||
public func getInitImage(reference: String, auth: Authentication? = nil, progress: ProgressHandler? = nil) async throws -> InitImage {
|
||||
do {
|
||||
let image = try await self.get(reference: reference)
|
||||
return InitImage(image: image)
|
||||
} catch let error as ContainerizationError {
|
||||
if error.code == .notFound {
|
||||
let image = try await self.pull(reference: reference, auth: auth, progress: progress)
|
||||
return InitImage(image: image)
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// Data representing the image to use as the root filesystem for a virtual machine.
|
||||
/// Typically this image would contain the guest agent used to facilitate container
|
||||
/// workloads, as well as any extras that may be useful to have in the guest.
|
||||
public struct InitImage: Sendable {
|
||||
public var name: String { image.reference }
|
||||
|
||||
let image: Image
|
||||
|
||||
public init(image: Image) {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
|
||||
extension InitImage {
|
||||
/// Unpack the initial filesystem for the desired platform at a given path.
|
||||
public func initBlock(at: URL, for platform: SystemPlatform) async throws -> Mount {
|
||||
let unpacker = EXT4Unpacker(blockSizeInBytes: 512.mib())
|
||||
var fs = try await unpacker.unpack(self.image, for: platform.ociPlatform(), at: at)
|
||||
fs.options = ["ro"]
|
||||
return fs
|
||||
}
|
||||
|
||||
/// Create a new InitImage with the reference as the name.
|
||||
/// The `rootfs` parameter must be a tar.gz file whose contents make up the filesystem for the image.
|
||||
public static func create(
|
||||
reference: String, rootfs: URL, platform: Platform,
|
||||
labels: [String: String] = [:], imageStore: ImageStore, contentStore: ContentStore
|
||||
) async throws -> InitImage {
|
||||
|
||||
let indexDescriptorStore = AsyncStore<Descriptor>()
|
||||
try await contentStore.ingest { dir in
|
||||
let writer = try ContentWriter(for: dir)
|
||||
var result = try writer.create(from: rootfs)
|
||||
let layerDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageLayerGzip, digest: result.digest.digestString, size: result.size)
|
||||
|
||||
// TODO: compute and fill in the correct diffID for the above layer
|
||||
// We currently put in the sha of the fully compressed layer, this needs to be replaced with
|
||||
// the sha of the uncompressed layer.
|
||||
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [result.digest.digestString])
|
||||
let runtimeConfig = ContainerizationOCI.ImageConfig(labels: labels)
|
||||
let imageConfig = ContainerizationOCI.Image(architecture: platform.architecture, os: platform.os, config: runtimeConfig, rootfs: rootfsConfig)
|
||||
result = try writer.create(from: imageConfig)
|
||||
let configDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageConfig, digest: result.digest.digestString, size: result.size)
|
||||
|
||||
let manifest = Manifest(config: configDescriptor, layers: [layerDescriptor])
|
||||
result = try writer.create(from: manifest)
|
||||
let manifestDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageManifest, digest: result.digest.digestString, size: result.size, platform: platform)
|
||||
|
||||
let index = ContainerizationOCI.Index(manifests: [manifestDescriptor])
|
||||
result = try writer.create(from: index)
|
||||
|
||||
let indexDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.index, digest: result.digest.digestString, size: result.size)
|
||||
await indexDescriptorStore.set(indexDescriptor)
|
||||
|
||||
}
|
||||
|
||||
guard let indexDescriptor = await indexDescriptorStore.get() else {
|
||||
throw ContainerizationError(.notFound, message: "image for \(reference) not found")
|
||||
}
|
||||
|
||||
let description = Image.Description(reference: reference, descriptor: indexDescriptor)
|
||||
let image = try await imageStore.create(description: description)
|
||||
return InitImage(image: image)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
//===----------------------------------------------------------------------===//
|
||||
// Copyright © 2025-2026 Apple Inc. and the Containerization project authors.
|
||||
//
|
||||
// Licensed under the Apache License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License.
|
||||
// You may obtain a copy of the License at
|
||||
//
|
||||
// https://www.apache.org/licenses/LICENSE-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//===----------------------------------------------------------------------===//
|
||||
|
||||
import ContainerizationError
|
||||
import ContainerizationOCI
|
||||
import Foundation
|
||||
|
||||
/// A multi-arch kernel image represented by an OCI image.
|
||||
public struct KernelImage: Sendable {
|
||||
/// The media type for a kernel image.
|
||||
public static let mediaType = "application/vnd.apple.containerization.kernel"
|
||||
|
||||
/// The name or reference of the image.
|
||||
public var name: String { image.reference }
|
||||
|
||||
let image: Image
|
||||
|
||||
public init(image: Image) {
|
||||
self.image = image
|
||||
}
|
||||
}
|
||||
|
||||
extension KernelImage {
|
||||
/// Return the kernel from a multi arch image for a specific system platform.
|
||||
public func kernel(for platform: SystemPlatform) async throws -> Kernel {
|
||||
let manifest = try await image.manifest(for: platform.ociPlatform())
|
||||
guard let descriptor = manifest.layers.first, descriptor.mediaType == Self.mediaType else {
|
||||
throw ContainerizationError(.notFound, message: "kernel descriptor for \(platform) not found")
|
||||
}
|
||||
let content = try await image.getContent(digest: descriptor.digest)
|
||||
return Kernel(
|
||||
path: content.path,
|
||||
platform: platform
|
||||
)
|
||||
}
|
||||
|
||||
/// Create a new kernel image with the reference as the name.
|
||||
/// This will create a multi arch image containing kernel's for each provided architecture.
|
||||
public static func create(reference: String, binaries: [Kernel], labels: [String: String] = [:], imageStore: ImageStore, contentStore: ContentStore) async throws -> KernelImage
|
||||
{
|
||||
let indexDescriptorStore = AsyncStore<Descriptor>()
|
||||
try await contentStore.ingest { ingestPath in
|
||||
var descriptors = [Descriptor]()
|
||||
let writer = try ContentWriter(for: ingestPath)
|
||||
|
||||
for kernel in binaries {
|
||||
var result = try writer.create(from: kernel.path)
|
||||
let platform = kernel.platform.ociPlatform()
|
||||
let layerDescriptor = Descriptor(
|
||||
mediaType: mediaType,
|
||||
digest: result.digest.digestString,
|
||||
size: result.size,
|
||||
platform: platform)
|
||||
let rootfsConfig = ContainerizationOCI.Rootfs(type: "layers", diffIDs: [result.digest.digestString])
|
||||
let runtimeConfig = ContainerizationOCI.ImageConfig(labels: labels)
|
||||
let imageConfig = ContainerizationOCI.Image(architecture: platform.architecture, os: platform.os, config: runtimeConfig, rootfs: rootfsConfig)
|
||||
|
||||
result = try writer.create(from: imageConfig)
|
||||
let configDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.imageConfig, digest: result.digest.digestString, size: result.size)
|
||||
|
||||
let manifest = Manifest(config: configDescriptor, layers: [layerDescriptor])
|
||||
result = try writer.create(from: manifest)
|
||||
let manifestDescriptor = Descriptor(
|
||||
mediaType: ContainerizationOCI.MediaTypes.imageManifest, digest: result.digest.digestString, size: result.size, platform: platform)
|
||||
descriptors.append(manifestDescriptor)
|
||||
}
|
||||
let index = ContainerizationOCI.Index(manifests: descriptors)
|
||||
let result = try writer.create(from: index)
|
||||
let indexDescriptor = Descriptor(mediaType: ContainerizationOCI.MediaTypes.index, digest: result.digest.digestString, size: result.size)
|
||||
await indexDescriptorStore.set(indexDescriptor)
|
||||
}
|
||||
|
||||
guard let indexDescriptor = await indexDescriptorStore.get() else {
|
||||
throw ContainerizationError(.notFound, message: "image for \(reference) not found")
|
||||
}
|
||||
|
||||
let description = Image.Description(reference: reference, descriptor: indexDescriptor)
|
||||
let image = try await imageStore.create(description: description)
|
||||
return KernelImage(image: image)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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
|
||||
|
||||
}
|
||||
@@ -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: " ")
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user