chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s

This commit is contained in:
wehub-resource-sync
2026-07-13 12:06:18 +08:00
commit e84fb4a79e
474 changed files with 68321 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
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.
📖 **Need help?** See our guide: [How to write effective reproduction steps](https://github.com/apple/container/blob/main/docs/bug-report-how-to.md#steps-to-reproduce)
validations:
required: true
- type: textarea
id: what-happened
attributes:
label: Problem description
description: |
Briefly describe your problem, and what you believe the correct behavior should be.
📖 **Need help?** See our guide: [Problem Description](https://github.com/apple/container/blob/main/docs/bug-report-how-to.md#problem-description)
validations:
required: true
- type: textarea
attributes:
label: Environment
description: |
Examples:
- **OS**: macOS 26.0 (25A354)
- **Xcode**: Version 26.0 (17A324)
- **Container**: Container CLI version 0.1.0
📖 **Need help gathering this info?** See our guide: [Environment Information](https://github.com/apple/container/blob/main/docs/bug-report-how-to.md#environment-information)
value: |
- OS:
- Xcode:
- Container:
render: markdown
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
+24
View File
@@ -0,0 +1,24 @@
name: Feature or enhancement request
description: File a request for a feature or enhancement
title: "[Request]: "
type: "Feature"
body:
- type: markdown
attributes:
value: |
Thanks for contributing to the container project!
- type: textarea
id: request
attributes:
label: Feature or enhancement request details
description: Describe your proposed feature or enhancement. Code samples that show what's missing, or what new capabilities will be possible, are very helpful! Provide links to existing issues or external references/discussions, if appropriate.
validations:
required: true
- type: checkboxes
id: terms
attributes:
label: Code of Conduct
description: By submitting this issue, you agree to follow our [Code of Conduct](https://github.com/apple/.github/blob/main/CODE_OF_CONDUCT.md).
options:
- label: I agree to follow this project's Code of Conduct
required: true
+5
View File
@@ -0,0 +1,5 @@
blank_issues_enabled: false
contact_links:
- name: Container community support
url: https://github.com/apple/container/discussions
about: Please ask and answer questions here.
+12
View File
@@ -0,0 +1,12 @@
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
groups:
github-actions:
patterns:
- "*"
commit-message:
prefix: "ci"
+16
View File
@@ -0,0 +1,16 @@
cli:
- changed-files:
- any-glob-to-any-file:
- 'Sources/CLI/**'
- 'Sources/ContainerCommands/**'
documentation:
- changed-files:
- any-glob-to-any-file:
- '**/*.md'
- 'docs/**'
ci:
- changed-files:
- any-glob-to-any-file:
- '.github/**'
+13
View File
@@ -0,0 +1,13 @@
## Type of Change
- [ ] Bug fix
- [ ] New feature
- [ ] Breaking change
- [ ] Documentation update
## Motivation and Context
[Why is this change needed?]
## Testing
- [ ] Tested locally
- [ ] Added/updated tests
- [ ] Added/updated docs
+210
View File
@@ -0,0 +1,210 @@
name: container project - common jobs
permissions:
contents: read
on:
workflow_call:
inputs:
release:
type: boolean
description: "Publish this build for release"
default: false
coverage:
type: boolean
description: "Run tests with code coverage enabled"
default: false
pr_number:
type: string
description: "PR number for coverage artifact naming (required when coverage is true)"
default: ""
jobs:
buildAndTest:
name: Build and test the project
if: github.repository == 'apple/container'
timeout-minutes: 75
runs-on: [self-hosted, macos, tahoe, ARM64]
permissions:
contents: read
packages: read
env:
DEVELOPER_DIR: "/Applications/Xcode_swift_6.3.app/Contents/Developer"
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Check formatting
env:
HAWKEYE_AUTO_INSTALL: "1"
run: |
./scripts/install-hawkeye.sh
make fmt
if ! git diff --quiet -- . ; then
echo "❌ The following files require formatting or license header updates:"
git diff --name-only -- .
false
fi
- name: Check protobuf
run: |
make protos
if ! git diff --quiet -- . ; then
echo "❌ The following files require formatting or license header updates:"
git diff --name-only -- .
false
fi
- name: Set build configuration
env:
RELEASE: ${{ inputs.release }}
run: |
echo "BUILD_CONFIGURATION=debug" >> $GITHUB_ENV
if [[ "${RELEASE}" == "true" ]]; then
echo "BUILD_CONFIGURATION=release" >> $GITHUB_ENV
fi
- name: Make the container project and docs
run: |
make container dsym docs
tar cfz _site.tgz _site
- name: Validate coverage inputs
if: ${{ inputs.coverage }}
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: |
if [ -z "${PR_NUMBER}" ]; then
echo "::error::pr_number input is required when coverage is true"
exit 1
fi
- name: Create package
run: |
mkdir -p outputs
mv "bin/${BUILD_CONFIGURATION}/container-installer-unsigned.pkg" outputs
mv "bin/${BUILD_CONFIGURATION}/bundle/container-dSYM.zip" outputs
- name: Set up test environment
run: |
APP_ROOT=$(mktemp -d -p "${RUNNER_TEMP}")
LOG_ROOT="${APP_ROOT}/logs"
echo "created data directory: ${APP_ROOT}"
echo "hostname: $(hostname)"
export NO_PROXY="${NO_PROXY},192.168.0.0/16,fe80::/10"
echo NO_PROXY=${NO_PROXY}
export no_proxy="${no_proxy},192.168.0.0/16,fe80::/10"
echo no_proxy=${no_proxy}
echo "APP_ROOT=${APP_ROOT}" >> $GITHUB_ENV
echo "LOG_ROOT=${LOG_ROOT}" >> $GITHUB_ENV
- name: Test the container project
if: ${{ !inputs.coverage }}
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" test install-kernel integration
- name: Test the container project with coverage
if: ${{ inputs.coverage }}
run: make APP_ROOT="${APP_ROOT}" LOG_ROOT="${LOG_ROOT}" install-kernel coverage
- name: Extract coverage percentages
if: ${{ inputs.coverage }}
env:
PR_NUMBER: ${{ inputs.pr_number }}
run: |
mkdir -p pr-coverage
jq -r '.data[0].totals.lines.percent | . * 100 | round | . / 100' coverage-reports/unit/coverage-summary.json > pr-coverage/unit-line-coverage.txt
jq -r '.data[0].totals.lines.percent | . * 100 | round | . / 100' coverage-reports/integration/coverage-summary.json > pr-coverage/integration-line-coverage.txt
jq -r '.data[0].totals.lines.percent | . * 100 | round | . / 100' coverage-reports/combined/coverage-summary.json > pr-coverage/combined-line-coverage.txt
echo "${PR_NUMBER}" > pr-coverage/pr-number.txt
echo "Coverage data:"
cat pr-coverage/*.txt
- name: Upload coverage data
if: ${{ inputs.coverage }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-coverage-${{ inputs.pr_number }}
path: pr-coverage/
retention-days: 1
if-no-files-found: warn
- name: Upload coverage HTML reports
if: ${{ inputs.coverage }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: coverage-html-reports
path: |
coverage-reports/unit/html/
coverage-reports/integration/html/
coverage-reports/combined/html/
retention-days: 14
if-no-files-found: ignore
- name: Archive test logs
if: always()
run: |
if [ -d "${APP_ROOT}/containers" ] && [ -n "${LOG_ROOT}" ]; then
rsync -a --include='*/' --include='*.log' --exclude='*' \
"${APP_ROOT}/containers/" \
"${LOG_ROOT}/containers/"
fi
if [ -n "${LOG_ROOT}" ] && [ -d "${LOG_ROOT}" ] ; then
echo "Collecting logs from ${LOG_ROOT}..."
tar czf container-logs.tar.gz -C "$(dirname "${LOG_ROOT}")" "$(basename "${LOG_ROOT}")"
echo "Log archive created: container-logs.tar.gz"
fi
if [ -n "${APP_ROOT}" ] ; then
rm -rf "${APP_ROOT}"
echo "Removed data directory ${APP_ROOT}"
fi
rm -rf coverage-reports pr-coverage
- name: Upload logs if present
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-test-logs
path: container-logs.tar.gz
retention-days: 14
if-no-files-found: ignore
- name: Save documentation artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: api-docs
path: "./_site.tgz"
retention-days: 14
- name: Save package artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: container-package
path: ${{ github.workspace }}/outputs
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@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6
- name: Download a single artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
name: api-docs
- name: Add API docs to documentation
run: |
tar xfz _site.tgz
- name: Upload Artifact
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5
with:
path: "./_site"
+46
View File
@@ -0,0 +1,46 @@
# Manual workflow for releasing docs ad-hoc. Workflow can only be run for main or release branches.
# Workflow does NOT publish a release of container.
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/common.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@v5
+22
View File
@@ -0,0 +1,22 @@
name: container project - merge build
permissions:
contents: read
on:
push:
branches:
- main
- release/*
jobs:
build:
name: Invoke build
uses: ./.github/workflows/common.yml
with:
release: true
secrets: inherit
permissions:
contents: read
packages: read
pages: write
+53
View File
@@ -0,0 +1,53 @@
name: container project - PR build
permissions:
contents: read
on:
pull_request:
types: [opened, reopened, synchronize]
jobs:
verify-signatures:
name: Verify commit signatures
runs-on: ubuntu-latest
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!"
build:
name: Invoke build
uses: ./.github/workflows/common.yml
with:
release: false
coverage: true
pr_number: ${{ github.event.pull_request.number }}
secrets: inherit
permissions:
contents: read
packages: read
pages: write
+84
View File
@@ -0,0 +1,84 @@
name: PR Coverage Comment
on:
workflow_run:
workflows: ["container project - PR build"]
types:
- completed
permissions:
contents: read
jobs:
comment:
name: Post coverage comment
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
actions: read
pull-requests: write
steps:
- name: Download coverage artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: pr-coverage-*
merge-multiple: true
path: pr-coverage-data
id: download-artifact
continue-on-error: true
- name: Check artifact exists
if: steps.download-artifact.outcome == 'success'
id: check-artifact
run: |
if [ -f pr-coverage-data/pr-number.txt ]; then
echo "found=true" >> $GITHUB_OUTPUT
else
echo "No coverage artifact found — coverage job may have failed."
echo "found=false" >> $GITHUB_OUTPUT
fi
- name: Validate and post comment
if: steps.check-artifact.outputs.found == 'true'
env:
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
run: |
PR_NUMBER=$(cat pr-coverage-data/pr-number.txt)
grep -qxE '[0-9]+' <<< "$PR_NUMBER" || { echo "Invalid PR number: ${PR_NUMBER}"; exit 1; }
UNIT=$(cat pr-coverage-data/unit-line-coverage.txt)
grep -qxE '[0-9]+(\.[0-9]+)?' <<< "$UNIT" || { echo "Invalid unit coverage: ${UNIT}"; exit 1; }
INTEGRATION=$(cat pr-coverage-data/integration-line-coverage.txt)
grep -qxE '[0-9]+(\.[0-9]+)?' <<< "$INTEGRATION" || { echo "Invalid integration coverage: ${INTEGRATION}"; exit 1; }
COMBINED=$(cat pr-coverage-data/combined-line-coverage.txt)
grep -qxE '[0-9]+(\.[0-9]+)?' <<< "$COMBINED" || { echo "Invalid combined coverage: ${COMBINED}"; exit 1; }
MARKER="<!-- coverage-bot -->"
BODY=$(cat <<EOF
${MARKER}
## Code Coverage
| Tier | Line Coverage |
|------|--------------|
| Unit | ${UNIT}% |
| Integration | ${INTEGRATION}% |
| Combined | ${COMBINED}% |
EOF
)
EXISTING_COMMENT_ID=$(gh api "repos/${REPO}/issues/${PR_NUMBER}/comments" --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" | head -1)
if [ -n "${EXISTING_COMMENT_ID}" ]; then
gh api "repos/${REPO}/issues/comments/${EXISTING_COMMENT_ID}" -X PATCH -f body="${BODY}"
echo "Updated existing coverage comment ${EXISTING_COMMENT_ID}"
else
gh pr comment "${PR_NUMBER}" --repo "${REPO}" --body "${BODY}"
echo "Created new coverage comment"
fi
+28
View File
@@ -0,0 +1,28 @@
name: PR Label Analysis
on:
pull_request:
types: [opened]
permissions:
contents: read
jobs:
analyze:
name: Analyze PR for labeling
runs-on: ubuntu-latest
steps:
- name: Save PR metadata
env:
PR_NUMBER: ${{ github.event.pull_request.number }}
run: |
mkdir -p ./pr-metadata
echo "${PR_NUMBER}" > ./pr-metadata/pr-number.txt
- name: Upload PR metadata as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: pr-metadata-${{ github.event.pull_request.number }}
path: pr-metadata/
retention-days: 1
+47
View File
@@ -0,0 +1,47 @@
name: PR Label Apply
on:
workflow_run:
workflows: ["PR Label Analysis"]
types:
- completed
permissions:
contents: read
jobs:
apply-labels:
name: Apply labels to PR
runs-on: ubuntu-latest
if: ${{ github.event.workflow_run.conclusion == 'success' }}
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Download PR metadata artifact
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
run-id: ${{ github.event.workflow_run.id }}
pattern: pr-metadata-*
merge-multiple: false
id: download-artifact
- name: Read PR number
id: pr-number
run: |
PR_NUMBER=$(cat "pr-number.txt")
echo "number=${PR_NUMBER}" >> $GITHUB_OUTPUT
echo "PR Number: ${PR_NUMBER}"
- name: Apply labels using labeler
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6
with:
pr-number: ${{ steps.pr-number.outputs.number }}
repo-token: ${{ secrets.GITHUB_TOKEN }}
configuration-path: .github/labeler.yml
sync-labels: true
+57
View File
@@ -0,0 +1,57 @@
name: container project - release build
permissions:
contents: read
on:
push:
tags:
- "[0-9]+\\.[0-9]+\\.[0-9]+"
jobs:
build:
name: Invoke build and release
uses: ./.github/workflows/common.yml
with:
release: true
secrets: inherit
permissions:
contents: read
packages: read
pages: write
release:
if: startsWith(github.ref, 'refs/tags/')
name: Publish release
timeout-minutes: 30
needs: build
runs-on: ubuntu-latest
permissions:
contents: write
packages: read
pages: write
steps:
- name: Download artifacts
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
with:
path: outputs
- name: Verify artifacts exist
run: |
echo "Checking for expected artifacts..."
ls -la outputs/container-package/
test -e outputs/container-package/*.zip || (echo "Missing .zip file!" && exit 1)
test -e outputs/container-package/*.pkg || (echo "Missing .pkg file!" && exit 1)
- name: Create release
uses: softprops/action-gh-release@718ea10b132b3b2eba29c1007bb80653f286566b # v3
with:
token: ${{ github.token }}
name: ${{ github.ref_name }}-prerelease
draft: true
make_latest: false
prerelease: true
fail_on_unmatched_files: true
files: |
outputs/container-package/*.zip
outputs/container-package/*.pkg
+33
View File
@@ -0,0 +1,33 @@
.DS_Store
bin
libexec
.build
.local
xcuserdata/
DerivedData/
Packages/
.swiftpm/
.netrc
.swiftpm
api-docs/
workdir/
installer/
.venv/
.claude/
.clitests/
test_results/
*.pid
*.log
*.zip
*.o
*.ext4
*.pkg
*.swp
# Coverage artifacts
*.profraw
coverage-reports/
# API docs for local preview only.
_site/
_serve/
+20
View File
@@ -0,0 +1,20 @@
version: 1
builder:
configs:
-
documentation_targets:
- ContainerAPIService
- ContainerAPIClient
- ContainerRuntimeClient
- ContainerRuntimeLinuxClient
- ContainerRuntimeLinuxServer
- ContainerNetworkService
- ContainerNetworkServiceClient
- ContainerImagesService
- ContainerImagesServiceClient
- ContainerResource
- ContainerLog
- ContainerPlugin
- ContainerXPC
- TerminalProgress
swift_version: '6.2'
+68
View File
@@ -0,0 +1,68 @@
{
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"indentation" : {
"spaces" : 4
},
"indentConditionalCompilationBlocks" : false,
"indentSwitchCaseLabels" : false,
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : false,
"lineBreakBeforeEachGenericRequirement" : false,
"lineLength" : 180,
"maximumBlankLines" : 1,
"multiElementCollectionTrailingCommas" : true,
"noAssignmentInExpressions" : {
"allowedFunctions" : [
"XCTAssertNoThrow"
]
},
"prioritizeKeepingFunctionOutputTogether" : false,
"respectsExistingLineBreaks" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLowerCamelCase" : true,
"AmbiguousTrailingClosureOverload" : false,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : true,
"FileScopedDeclarationPrivacy" : true,
"FullyIndirectEnum" : true,
"GroupNumericLiterals" : true,
"IdentifiersMustBeASCII" : true,
"NeverForceUnwrap" : true,
"NeverUseForceTry" : true,
"NeverUseImplicitlyUnwrappedOptionals" : true,
"NoAccessLevelOnExtensionDeclaration" : true,
"NoAssignmentInExpressions" : true,
"NoBlockComments" : false,
"NoCasesWithOnlyFallthrough" : true,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : true,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoPlaygroundLiterals" : true,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : true,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : true,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : true,
"ReturnVoidInsteadOfEmptyTuple" : true,
"TypeNamesShouldBeCapitalized" : true,
"UseEarlyExits" : true,
"UseLetInEveryBoundCaseVariable" : true,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : true,
"UseSynthesizedInitializer" : true,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : true
},
"spacesAroundRangeFormationOperators" : false,
"tabWidth" : 2,
"version" : 1
}
+68
View File
@@ -0,0 +1,68 @@
{
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"indentation" : {
"spaces" : 4
},
"indentConditionalCompilationBlocks" : false,
"indentSwitchCaseLabels" : false,
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : false,
"lineBreakBeforeEachGenericRequirement" : false,
"lineLength" : 180,
"maximumBlankLines" : 1,
"multiElementCollectionTrailingCommas" : true,
"noAssignmentInExpressions" : {
"allowedFunctions" : [
"XCTAssertNoThrow"
]
},
"prioritizeKeepingFunctionOutputTogether" : false,
"respectsExistingLineBreaks" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLowerCamelCase" : false,
"AmbiguousTrailingClosureOverload" : false,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : false,
"FileScopedDeclarationPrivacy" : false,
"FullyIndirectEnum" : false,
"GroupNumericLiterals" : false,
"IdentifiersMustBeASCII" : false,
"NeverForceUnwrap" : false,
"NeverUseForceTry" : false,
"NeverUseImplicitlyUnwrappedOptionals" : false,
"NoAccessLevelOnExtensionDeclaration" : false,
"NoAssignmentInExpressions" : false,
"NoBlockComments" : false,
"NoCasesWithOnlyFallthrough" : false,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : false,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoPlaygroundLiterals" : false,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : false,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : false,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : false,
"ReturnVoidInsteadOfEmptyTuple" : false,
"TypeNamesShouldBeCapitalized" : false,
"UseEarlyExits" : false,
"UseLetInEveryBoundCaseVariable" : false,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : true,
"UseSynthesizedInitializer" : false,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : false
},
"spacesAroundRangeFormationOperators" : false,
"tabWidth" : 2,
"version" : 1
}
+195
View File
@@ -0,0 +1,195 @@
# Building the project
To build the `container` project, you need:
- Mac with Apple silicon
- macOS 15 minimum, macOS 26 recommended
- Xcode 26, set as the [active developer directory](https://developer.apple.com/library/archive/technotes/tn2339/_index.html#//apple_ref/doc/uid/DTS40014588-CH1-HOW_DO_I_SELECT_THE_DEFAULT_VERSION_OF_XCODE_TO_USE_FOR_MY_COMMAND_LINE_TOOLS_)
> [!IMPORTANT]
> There is a bug in the `vmnet` framework on macOS 26 that causes network creation to fail if the `container` helper applications are located under your `Documents` or `Desktop` directories. If you use `make install`, you can simply run the `container` binary in `/usr/local`. If you prefer to use the binaries that `make all` creates in your project `bin` and `libexec` directories, locate your project elsewhere, such as `~/projects/container`, until this issue is resolved.
## Compile and test
Build `container` and the background services from source, and run basic and integration tests in an isolated application data directory:
```bash
rm -rf test-data
make APP_ROOT=test-data all test integration
```
Copy the binaries to `/usr/local/bin` and `/usr/local/libexec` (requires entering an administrator password):
```bash
make install
```
Or to install a release build, with better performance than the debug build:
```bash
BUILD_CONFIGURATION=release make all test integration
BUILD_CONFIGURATION=release make install
```
## Compile protobufs
`container` uses gRPC to communicate to the builder virtual machine that creates images from `Dockerfile`s, and depends on specific versions of `grpc-swift` and `swift-protobuf`. If you make changes to the gRPC APIs in the [container-builder-shim](https://github.com/apple/container-builder-shim) project, install the tools and re-generate the gRPC code in this project using:
```bash
make protos
```
## Develop using a local copy of Containerization
To make changes to `container` that require changes to the Containerization project, or vice versa:
1. Clone the [Containerization](https://github.com/apple/containerization) repository such that it sits next to your clone
of the `container` repository. Ensure that you [follow containerization instructions](https://github.com/apple/containerization/blob/main/README.md#prepare-to-build-package)
to prepare your build environment.
2. In your development shell, go to the `container` project directory.
```bash
cd container
```
3. If the `container` services are already running, stop them.
```bash
bin/container system stop
```
4. Reconfigure the Swift project to use your local `containerization` package and update your `Package.resolved` file.
```bash
/usr/bin/swift package edit --path ../containerization containerization
/usr/bin/swift package update containerization
```
> [!IMPORTANT]
> If you are using Xcode, do **not** run `swift package edit`. Instead, temporarily modify `Package.swift` to replace the versioned `containerization` dependency:
>
> ```swift
> .package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)),
> ```
>
> with the local path dependency:
>
> ```swift
> .package(path: "../containerization"),
> ```
>
> **Note:** If you have already run `swift package edit`, whether intentionally or by accident, follow the steps in the next section to restore the normal `containerization` dependency. Otherwise, the modified `Package.swift` file will not work, and the project may fail to build.
5. If you want `container` to use any changes you made in the `vminit` subproject of Containerization, set the init image in your runtime configuration file at `~/.config/container/config.toml`:
```toml
[vminit]
image = "vminit:latest"
```
6. Build `container`.
```
make clean all
```
7. Restart the `container` services.
```
bin/container system stop
bin/container system start
```
To revert to using the Containerization dependency from your `Package.swift`:
1. If you were using the local init filesystem, remove the `init` override from your `~/.config/container/config.toml` (or delete the `[vminit]` section if no other image settings are present).
2. Use the Swift package manager to restore the normal `containerization` dependency and update your `Package.resolved` file. If you are using Xcode, revert your `Package.swift` change instead of using `swift package unedit`.
```bash
/usr/bin/swift package unedit containerization
/usr/bin/swift package update containerization
```
3. Rebuild `container`.
```bash
make clean all
```
4. Restart the `container` services.
```bash
bin/container system stop
bin/container system start
```
## Develop using a local copy of container-builder-shim
To test changes that require the `container-builder-shim` project:
1. Clone the [container-builder-shim](https://github.com/apple/container-builder-shim) repository and navigate to its directory.
2. After making the necessary changes, build the custom builder image, set it as the active builder image in `~/.config/container/config.toml`, and remove the existing `buildkit` container so the new image will be used:
```bash
container build -t builder .
container rm -f buildkit
```
Add the following to your `~/.config/container/config.toml`:
```toml
[build]
image = "builder:latest"
```
3. Run the `container` build as usual:
```bash
container build ...
```
> [!IMPORTANT]
> If your modified builder image is broken, make sure to rebuild and correctly tag the builder image before attempting to build `container-builder-shim` again.
## Debug XPC Helpers
Attach debugger to the XPC helpers using their launchd service labels:
1. Find launchd service labels:
```console
% container system start
% container run -d --name test debian:bookworm sleep infinity
test
% launchctl list | grep container
27068 0 com.apple.container.container-network-vmnet.default
27072 0 com.apple.container.container-core-images
26980 0 com.apple.container.apiserver
27331 0 com.apple.container.container-runtime-linux.test
```
2. Stop container and start again after setting the environment variable `CONTAINER_DEBUG_LAUNCHD_LABEL` to the label of service to attach debugger. Services whose label starts with the `CONTAINER_DEBUG_LAUNCHD_LABEL` will wait the debugger:
```console
% export CONTAINER_DEBUG_LAUNCHD_LABEL=com.apple.container.container-runtime-linux.test
% container system start # Only the service `com.apple.container.container-runtime-linux.test` waits debugger
```
```console
% export CONTAINER_DEBUG_LAUNCHD_LABEL=com.apple.container.container-runtime-linux
% container system start # Every service starting with `com.apple.container.container-runtime-linux` waits debugger
```
3. Run the command to launch the service, and attach debugger:
```console
% container run -it --name test debian:bookworm
⠧ [6/6] Starting container [0s] # It hangs as the service is waiting for debugger
```
## 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`.
+3
View File
@@ -0,0 +1,3 @@
# Contributing
Contributions are welcome and encouraged! Read our [main contributing guide](https://github.com/apple/containerization/blob/main/CONTRIBUTING.md) to get started.
+201
View File
@@ -0,0 +1,201 @@
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.
+3
View File
@@ -0,0 +1,3 @@
# Maintainers
See [MAINTAINERS](https://github.com/apple/containerization/blob/main/MAINTAINERS.txt) for the list of current and former maintainers of this project. Thank you for all your contributions!
+415
View File
@@ -0,0 +1,415 @@
# Copyright © 2025-2026 Apple Inc. and the container 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.
# Version and build configuration variables
BUILD_CONFIGURATION ?= debug
WARNINGS_AS_ERRORS ?= true
SWIFT_CONFIGURATION := $(if $(filter-out false,$(WARNINGS_AS_ERRORS)),-Xswiftc -warnings-as-errors)
# Code-coverage instrumentation, layered onto the shared build stages. Empty for
# ordinary builds; the coverage-* targets opt in via a target-specific value so
# only those goals compile instrumented binaries.
COVERAGE_FLAG ?=
export RELEASE_VERSION ?= $(shell git describe --tags --always)
export GIT_COMMIT := $(shell git rev-parse HEAD)
# Commonly used locations
SWIFT := "/usr/bin/swift"
# Shared swift build invocation; callers append --build-tests / --product / etc.
SWIFT_BUILD = $(SWIFT) build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION)
DEST_DIR ?= /usr/local/
ROOT_DIR := $(shell git rev-parse --show-toplevel)
BUILD_BIN_DIR = $(shell $(SWIFT) build -c $(BUILD_CONFIGURATION) --show-bin-path)
STAGING_DIR := bin/$(BUILD_CONFIGURATION)/staging/
PKG_PATH := bin/$(BUILD_CONFIGURATION)/container-installer-unsigned.pkg
DSYM_DIR := bin/$(BUILD_CONFIGURATION)/bundle/container-dSYM
DSYM_PATH := bin/$(BUILD_CONFIGURATION)/bundle/container-dSYM.zip
CODESIGN_OPTS ?= --force --sign - --timestamp=none
# Conditionally use a temporary data directory for integration tests
SYSTEM_START_OPTS :=
ifneq ($(strip $(APP_ROOT)),)
SYSTEM_START_OPTS += --app-root "$(strip $(APP_ROOT))"
endif
ifneq ($(strip $(LOG_ROOT)),)
SYSTEM_START_OPTS += --log-root "$(strip $(LOG_ROOT))"
endif
MACOS_VERSION := $(shell sw_vers -productVersion)
MACOS_MAJOR := $(shell echo $(MACOS_VERSION) | cut -d. -f1)
SUDO ?= sudo
.DEFAULT_GOAL := all
include Protobuf.Makefile
.PHONY: all
all: container
all: init-block
.PHONY: build
build:
@echo Building container binaries...
@$(SWIFT) --version
@$(SWIFT_BUILD)
.PHONY: build-tests
# Shared build stage for every test target: builds the test bundle (and the
# product binaries) once so the test targets can run with --skip-build. This is
# a distinct target from `build` so `make all test` builds products and tests as
# two separate steps rather than colliding on a single once-built target.
# COVERAGE_FLAG instruments the binaries when set by the coverage-* targets.
build-tests:
@echo Building container binaries and tests...
@$(SWIFT) --version
@$(SWIFT_BUILD) --build-tests $(COVERAGE_FLAG)
.PHONY: coverage-all
coverage-all: build-tests
@"$(MAKE)" BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) DEST_DIR="$(ROOT_DIR)/" SUDO= install
@"$(MAKE)" init-block
.PHONY: cli
cli:
@echo Building container CLI...
@$(SWIFT) --version
@$(SWIFT_BUILD) --product container
@echo Installing container CLI to bin/...
@mkdir -p bin
@install "$(BUILD_BIN_DIR)/container" "bin/container"
.PHONY: container
# Install binaries under project directory
container: build
@"$(MAKE)" BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) DEST_DIR="$(ROOT_DIR)/" SUDO= install
.PHONY: release
release: BUILD_CONFIGURATION = release
release: all
.PHONY: init-block
init-block:
@echo Building initfs if containerization is in edit mode
@scripts/install-init.sh $(SYSTEM_START_OPTS)
.PHONY: install
install: installer-pkg
@echo Installing container installer package
@if [ -z "$(SUDO)" ] ; then \
temp_dir=$$(mktemp -d) ; \
xar -xf $(PKG_PATH) -C $${temp_dir} ; \
(cd "$(DEST_DIR)" && pax -rz -f $${temp_dir}/Payload) ; \
rm -rf $${temp_dir} ; \
else \
$(SUDO) installer -pkg $(PKG_PATH) -target / ; \
fi
$(STAGING_DIR):
@echo Installing container binaries from "$(BUILD_BIN_DIR)" into "$(STAGING_DIR)"...
@rm -rf "$(STAGING_DIR)"
@mkdir -p "$(join $(STAGING_DIR), bin)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin)"
@mkdir -p "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources)"
@install "$(BUILD_BIN_DIR)/container" "$(join $(STAGING_DIR), bin/container)"
@install "$(BUILD_BIN_DIR)/container-apiserver" "$(join $(STAGING_DIR), bin/container-apiserver)"
@install "$(BUILD_BIN_DIR)/container-runtime-linux" "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin/container-runtime-linux)"
@install Sources/Plugins/RuntimeLinux/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/config.toml)"
@install "$(BUILD_BIN_DIR)/container-network-vmnet" "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)"
@install Sources/Plugins/NetworkVmnet/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/config.toml)"
@install "$(BUILD_BIN_DIR)/container-core-images" "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)"
@install Sources/Plugins/CoreImages/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/config.toml)"
@install "$(BUILD_BIN_DIR)/machine-apiserver" "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin/machine-apiserver)"
@install Sources/Plugins/MachineAPIServer/config.toml "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/config.toml)"
@install Sources/Plugins/MachineAPIServer/Resources/init "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/init)"
@install Sources/Plugins/MachineAPIServer/Resources/create-user.sh "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/resources/create-user.sh)"
@echo Install update script
@install scripts/update-container.sh "$(join $(STAGING_DIR), bin/update-container.sh)"
@echo Install uninstaller script
@install scripts/uninstall-container.sh "$(join $(STAGING_DIR), bin/uninstall-container.sh)"
.PHONY: installer-pkg
installer-pkg: $(STAGING_DIR)
@echo Signing container binaries...
@codesign $(CODESIGN_OPTS) --identifier com.apple.container.cli "$(join $(STAGING_DIR), bin/container)"
@codesign $(CODESIGN_OPTS) --identifier com.apple.container.apiserver "$(join $(STAGING_DIR), bin/container-apiserver)"
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images)"
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-runtime-linux.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/bin/container-runtime-linux)"
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. --entitlements=signing/container-network-vmnet.entitlements "$(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet)"
@codesign $(CODESIGN_OPTS) --prefix=com.apple.container. "$(join $(STAGING_DIR), libexec/container/plugins/machine-apiserver/bin/machine-apiserver)"
@echo Creating application installer
@pkgbuild --root "$(STAGING_DIR)" --identifier com.apple.container-installer --install-location /usr/local --version ${RELEASE_VERSION} $(PKG_PATH)
@rm -rf "$(STAGING_DIR)"
.PHONY: dsym
dsym:
@echo Copying debug symbols...
@rm -rf "$(DSYM_DIR)"
@mkdir -p "$(DSYM_DIR)"
@cp -a "$(BUILD_BIN_DIR)/container-runtime-linux.dSYM" "$(DSYM_DIR)"
@cp -a "$(BUILD_BIN_DIR)/container-network-vmnet.dSYM" "$(DSYM_DIR)"
@cp -a "$(BUILD_BIN_DIR)/container-core-images.dSYM" "$(DSYM_DIR)"
@cp -a "$(BUILD_BIN_DIR)/container-apiserver.dSYM" "$(DSYM_DIR)"
@cp -a "$(BUILD_BIN_DIR)/container.dSYM" "$(DSYM_DIR)"
@echo Packaging the debug symbols...
@(cd "$(dir $(DSYM_DIR))" ; zip -r $(notdir $(DSYM_PATH)) $(notdir $(DSYM_DIR)))
.PHONY: test
test: build-tests
@$(SWIFT) test --skip-build -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI --skip IntegrationTests
.PHONY: install-kernel
install-kernel:
@echo Stopping system before installing kernel
@bin/container system stop || true
@echo Starting system to install kernel
@bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS)
# Coverage report generation helpers
# Directory that swift test spits out raw coverage data
COV_DATA_DIR = $(shell $(SWIFT) test --show-coverage-path | xargs dirname)
COV_REPORT_FILE = $(ROOT_DIR)/code-coverage-report
COVERAGE_OUTPUT_DIR := $(ROOT_DIR)/coverage-reports
TEST_BINARY = $(BUILD_BIN_DIR)/containerPackageTests.xctest/Contents/MacOS/containerPackageTests
# All product binaries that may be instrumented for coverage.
# Used as additional -object args to llvm-cov for integration/combined reports.
COV_BINARIES := \
$(BUILD_BIN_DIR)/container \
$(BUILD_BIN_DIR)/container-apiserver \
$(BUILD_BIN_DIR)/container-runtime-linux \
$(BUILD_BIN_DIR)/container-network-vmnet \
$(BUILD_BIN_DIR)/container-core-images \
$(BUILD_BIN_DIR)/machine-apiserver
COV_OBJECT_FLAGS := $(patsubst %,-object %,$(COV_BINARIES))
# Set of files we do not want to get caught in the coverage generation
LLVM_COV_IGNORE := \
--ignore-filename-regex=".build/" \
--ignore-filename-regex="/Tests/" \
--ignore-filename-regex="/ContainerTestSupport/" \
--ignore-filename-regex=".pb.swift" \
--ignore-filename-regex=".proto" \
--ignore-filename-regex=".grpc.swift"
# Generate JSON + HTML coverage reports and a coverage-percent.txt from a profdata file.
# $(1) = profdata path, $(2) = tier name (unit/integration/combined), $(3) = additional -object flags (optional)
define GENERATE_COV_REPORTS
@echo Exporting $(2) coverage JSON...
@xcrun llvm-cov export --compilation-dir=`pwd` \
-instr-profile=$(1) \
$(LLVM_COV_IGNORE) \
$(TEST_BINARY) $(3) > $(COVERAGE_OUTPUT_DIR)/$(2)/coverage-summary.json
@echo Generating $(2) coverage HTML report...
@xcrun llvm-cov show --compilation-dir=`pwd` --format=html \
-instr-profile=$(1) \
$(LLVM_COV_IGNORE) \
-output-dir=$(COVERAGE_OUTPUT_DIR)/$(2)/html \
$(TEST_BINARY) $(3)
@echo Extracting $(2) coverage percentages...
@jq -r '.data[0].totals as $$t | \
"Coverage summary:", \
" lines: \($$t.lines.percent | . * 100 | round | . / 100)% (\($$t.lines.covered) of \($$t.lines.count))", \
" functions: \($$t.functions.percent | . * 100 | round | . / 100)% (\($$t.functions.covered) of \($$t.functions.count))", \
" regions: \($$t.regions.percent | . * 100 | round | . / 100)% (\($$t.regions.covered) of \($$t.regions.count))"' \
$(COVERAGE_OUTPUT_DIR)/$(2)/coverage-summary.json > $(COVERAGE_OUTPUT_DIR)/$(2)/coverage-percent.txt
@echo "-- $(2) coverage --"
@cat $(COVERAGE_OUTPUT_DIR)/$(2)/coverage-percent.txt
endef
# PARALLEL_WIDTH controls --experimental-maximum-parallelization-width for the
# concurrent pass. WARMUP_FILTER, CONCURRENT_FILTER, and SERIAL_FILTER select
# the three phases.
PARALLEL_WIDTH ?= $(shell sysctl -n hw.physicalcpu)
WARMUP_FILTER = ImageWarmup/
# Concurrent suites: Test*.swift files whose names do NOT end in Serial.
CONCURRENT_TEST_SUITES ?= $(sort $(addsuffix /,$(basename $(notdir \
$(shell find Tests/IntegrationTests -name 'Test*.swift' \
! -name '*Serial.swift' 2>/dev/null)))))
CONCURRENT_FILTER = $(subst $(space),|,$(strip $(CONCURRENT_TEST_SUITES)))
# Serial suites: Test*.swift files whose names end in Serial.
SERIAL_TEST_SUITES ?= $(sort $(addsuffix /,$(basename $(notdir \
$(shell find Tests/IntegrationTests -name 'Test*Serial.swift' 2>/dev/null)))))
SERIAL_FILTER = $(subst $(space),|,$(strip $(SERIAL_TEST_SUITES)))
INTEGRATION_SWIFT_EXTRA ?=
INTEGRATION_POST_TEST ?=
# Environment prefix applied to the `container system start` invocation. Empty for
# ordinary runs; coverage runs set LLVM_PROFILE_FILE here so launchd-managed helper
# (XPC service) processes emit their own profraw data.
INTEGRATION_PROFILE_ENV ?=
PRESERVE_KERNELS ?= false
# Default scratch root under the project directory so container build can access context
# subdirectories (macOS restricts access to /var/folders from the container binary).
# Override with SCRATCH_ROOT=/your/path on the command line.
SCRATCH_ROOT ?= $(ROOT_DIR)/.test-scratch
define RUN_INTEGRATION
@echo Ensuring apiserver stopped before the CLI integration tests...
@bin/container system stop && sleep 3 && scripts/ensure-container-stopped.sh
@if [ -n "$(APP_ROOT)" ]; then \
mkdir -p $(APP_ROOT) ; \
if [ "$(PRESERVE_KERNELS)" = "true" ]; then \
echo "Clearing application data under $(APP_ROOT) (preserving kernels)..." ; \
find "$(APP_ROOT)" -mindepth 1 -maxdepth 1 ! -name kernels -exec rm -rf {} + ; \
else \
echo "Clearing application data under $(APP_ROOT)..." ; \
find "$(APP_ROOT)" -mindepth 1 -maxdepth 1 -exec rm -rf {} + ; \
fi ; \
fi
@echo Running the integration tests...
@$(INTEGRATION_PROFILE_ENV) bin/container --debug system start --timeout 60 --enable-kernel-install $(SYSTEM_START_OPTS) && \
{ \
CLITEST_LOG_ROOT=$(LOG_ROOT) && export CLITEST_LOG_ROOT ; \
CLITEST_SCRATCH_ROOT=$(SCRATCH_ROOT) && export CLITEST_SCRATCH_ROOT ; \
CONTAINER_CLI_PATH=$(ROOT_DIR)/bin/container && export CONTAINER_CLI_PATH ; \
echo "==> Warmup pass" && \
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --filter "$(WARMUP_FILTER)" && \
echo "==> Concurrent pass (width=$(PARALLEL_WIDTH))" && \
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width $(PARALLEL_WIDTH) --filter "$(CONCURRENT_FILTER)" && \
echo "==> Global pass (serial)" && \
$(SWIFT) test $(INTEGRATION_SWIFT_EXTRA) -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --experimental-maximum-parallelization-width 1 --filter "$(SERIAL_FILTER)" ; \
exit_code=$$? ; \
$(INTEGRATION_POST_TEST) \
echo Ensuring apiserver stopped after the CLI integration tests ; \
scripts/ensure-container-stopped.sh ; \
exit $${exit_code} ; \
}
endef
.PHONY: integration
integration: init-block
$(RUN_INTEGRATION)
.PHONY: coverage-integration
coverage-integration: INTEGRATION_SWIFT_EXTRA = --skip-build --enable-code-coverage
coverage-integration: INTEGRATION_POST_TEST = cp $(COV_DATA_DIR)/*.profraw $(COVERAGE_OUTPUT_DIR)/integration/ || true ;
# Continuous mode (%c) mmaps the profraw and syncs counters live. The XPC helper
# services are torn down by `launchctl bootout` (SIGTERM/SIGKILL) rather than
# exiting cleanly, so a non-continuous profile (written by an atexit handler that
# never runs on SIGKILL) would lose the helpers' counters. %p-%m keeps each
# process/module profile in its own file so they don't collide.
coverage-integration: INTEGRATION_PROFILE_ENV = LLVM_PROFILE_FILE=$(COVERAGE_OUTPUT_DIR)/integration/%p-%m%c.profraw
coverage-integration: coverage-all
@mkdir -p $(COVERAGE_OUTPUT_DIR)/integration
@rm -f $(COVERAGE_OUTPUT_DIR)/integration/*.profraw
$(RUN_INTEGRATION)
@echo Merging integration coverage profdata...
@xcrun llvm-profdata merge -sparse $(COVERAGE_OUTPUT_DIR)/integration/*.profraw -o $(COVERAGE_OUTPUT_DIR)/integration/default.profdata
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/integration/default.profdata,integration,$(COV_OBJECT_FLAGS))
empty :=
space := $(empty) $(empty)
# Opt the coverage targets in to instrumentation. The value propagates to the
# shared build-tests target so compilation is instrumented when necessary.
coverage coverage-all coverage-unit coverage-integration: COVERAGE_FLAG = --enable-code-coverage -Xswiftc -DCONTAINER_COVERAGE
.PHONY: coverage
# Merge the per-tier profdata from coverage-unit and coverage-integration into a
# combined report. Each prerequisite target produces its own tier report first.
coverage: coverage-unit coverage-integration
@echo Merging combined coverage profdata...
@mkdir -p $(COVERAGE_OUTPUT_DIR)/combined
@xcrun llvm-profdata merge -sparse \
$(COVERAGE_OUTPUT_DIR)/unit/default.profdata \
$(COVERAGE_OUTPUT_DIR)/integration/default.profdata \
-o $(COVERAGE_OUTPUT_DIR)/combined/default.profdata
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/combined/default.profdata,combined,$(COV_OBJECT_FLAGS))
.PHONY: coverage-unit
coverage-unit: build-tests
@echo Running unit test coverage...
@rm -f $(COV_DATA_DIR)/*.profraw
@mkdir -p $(COVERAGE_OUTPUT_DIR)/unit
@$(SWIFT) test --skip-build --enable-code-coverage -c $(BUILD_CONFIGURATION) $(SWIFT_CONFIGURATION) --skip TestCLI --skip IntegrationTests
@echo Merging unit coverage profdata...
@xcrun llvm-profdata merge -sparse $(COV_DATA_DIR)/*.profraw -o $(COVERAGE_OUTPUT_DIR)/unit/default.profdata
$(call GENERATE_COV_REPORTS,$(COVERAGE_OUTPUT_DIR)/unit/default.profdata,unit)
.PHONY: fmt
fmt: swift-fmt update-licenses
.PHONY: check
check: swift-fmt-check check-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 Applying the standard code formatting...
@$(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:
$(eval HOOKS_DIR := $(shell git rev-parse --git-path hooks))
cp scripts/pre-commit.fmt $(HOOKS_DIR)/
touch $(HOOKS_DIR)/pre-commit
cat $(HOOKS_DIR)/pre-commit | grep -v 'hooks/pre-commit\.fmt' > /tmp/pre-commit.new || true
echo 'PRECOMMIT_NOFMT=$${PRECOMMIT_NOFMT} $$(git rev-parse --git-path hooks/pre-commit.fmt)' >> /tmp/pre-commit.new
mv /tmp/pre-commit.new $(HOOKS_DIR)/pre-commit
chmod +x $(HOOKS_DIR)/pre-commit
@./scripts/ensure-hawkeye-exists.sh
.PHONY: serve-docs
serve-docs:
@echo 'to browse: open http://127.0.0.1:8000/container/documentation/'
@rm -rf _serve
@mkdir -p _serve
@cp -a _site _serve/container
@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 container
.PHONY: cleancontent
cleancontent:
@bin/container system stop || true
@echo Cleaning the content...
@rm -rf ~/Library/Application\ Support/com.apple.container
.PHONY: clean
clean:
@echo Cleaning build files...
@rm -rf bin/ libexec/
@rm -rf _site _serve
@rm -f $(COV_REPORT_FILE)
@rm -rf $(COVERAGE_OUTPUT_DIR)
@$(SWIFT) package clean
+97
View File
@@ -0,0 +1,97 @@
**Apple Inc. and the AsyncHTTPClient project authors ( async-http-client )**
Copyright © 2018-2021 Apple Inc. and the AsyncHTTPClient 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 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.
**Apple Inc. and the Containerization project authors ( containerization )**
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 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.
**The gRPC Swift Project ( grpc-swift-2 )**
Copyright © 2021 The gRPC Swift Project
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.
**The gRPC Swift Project ( grpc-swift-nio-transport )**
Copyright © 2024 The gRPC Swift Project
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.
**The gRPC Swift Project ( grpc-swift-protobuf )**
Copyright © 2021 The gRPC Swift Project
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.
**Apple Inc. and the Swift project authors ( swift-argument-parser )**
Copyright © 2020 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception (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.
## Runtime Library Exception to the Apache 2.0 License: ##
As an exception, if you use this Software to compile your source code and portions of this Software are embedded into the binary product as a result, you may redistribute such product without providing attribution as would otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.
**Apple Inc. and the Swift project authors ( swift-collections )**
Copyright © 2021-2024 Apple Inc. and the Swift project authors
Licensed under Apache License v2.0 with Runtime Library Exception (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.
## Runtime Library Exception to the Apache 2.0 License: ##
As an exception, if you use this Software to compile your source code and portions of this Software are embedded into the binary product as a result, you may redistribute such product without providing attribution as would otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.
**Apple Inc. and the SwiftConfiguration project authors ( swift-configuration )**
Copyright © 2025 Apple Inc. and the SwiftConfiguration 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 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.
**Mattt ( swift-configuration-toml )**
Copyright © 2025 Mattt (https://mat.tt)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**Apple Inc. and the Swift Logging API project authors ( swift-log )**
Copyright © 2018-2019 Apple Inc. and the Swift Logging API 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 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.
**Apple Inc. and the SwiftNIO project authors ( swift-nio )**
Copyright © 2017-2018 Apple Inc. and the SwiftNIO 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 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.
**Apple Inc. and the project authors ( swift-protobuf )**
Copyright © 2014-2017 Apple Inc. and the project authors
Licensed under Apache License v2.0 with Runtime Library Exception (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.
## Runtime Library Exception to the Apache 2.0 License: ##
As an exception, if you use this Software to compile your source code and portions of this Software are embedded into the binary product as a result, you may redistribute such product without providing attribution as would otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.
**Apple Inc. and the Swift System project authors ( swift-system )**
Copyright © 2020-2025 Apple Inc. and the Swift System project authors
Licensed under Apache License v2.0 with Runtime Library Exception (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.
## Runtime Library Exception to the Apache 2.0 License: ##
As an exception, if you use this Software to compile your source code and portions of this Software are embedded into the binary product as a result, you may redistribute such product without providing attribution as would otherwise be required by Sections 4(a), 4(b) and 4(d) of the License.
**Mattt ( swift-toml )**
Copyright © 2025 Mattt (https://mat.tt)
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
**JP Simard ( Yams )**
Copyright © 2016 JP Simard
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+303
View File
@@ -0,0 +1,303 @@
{
"originHash" : "15ed2ff0d93ae40806863cea3fbe5a18b46054b842b289ce5f5c6031bea5d28a",
"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" : "containerization",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/containerization.git",
"state" : {
"revision" : "1d5641ff962456df021283505eaa6a701d828192",
"version" : "0.37.0"
}
},
{
"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" : "f70225981241859eb4aa1a18a75531d26637c8cc",
"version" : "1.4.0"
}
},
{
"identity" : "swift-async-algorithms",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-async-algorithms.git",
"state" : {
"revision" : "6c050d5ef8e1aa6342528460db614e9770d7f804",
"version" : "1.1.1"
}
},
{
"identity" : "swift-atomics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-atomics.git",
"state" : {
"revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7",
"version" : "1.3.0"
}
},
{
"identity" : "swift-certificates",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-certificates.git",
"state" : {
"revision" : "133a347911b6ad0fc8fe3bf46ca90c66cff97130",
"version" : "1.17.0"
}
},
{
"identity" : "swift-collections",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-collections.git",
"state" : {
"revision" : "03cc312c2c933ed87abace34044a5dff7a3117c1",
"version" : "1.5.0"
}
},
{
"identity" : "swift-configuration",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-configuration",
"state" : {
"revision" : "be76c4ad929eb6c4bcaf3351799f2adf9e6848a9",
"version" : "1.2.0"
}
},
{
"identity" : "swift-configuration-toml",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/swift-configuration-toml",
"state" : {
"revision" : "4ea16b4dfa4b023cecae5ae0368402c4d846d613",
"version" : "2.0.0"
}
},
{
"identity" : "swift-crypto",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-crypto.git",
"state" : {
"revision" : "334e682869394ee239a57dbe9262bff3cd9495bd",
"version" : "3.14.0"
}
},
{
"identity" : "swift-docc-plugin",
"kind" : "remoteSourceControl",
"location" : "https://github.com/swiftlang/swift-docc-plugin.git",
"state" : {
"revision" : "3e4f133a77e644a5812911a0513aeb7288b07d06",
"version" : "1.4.5"
}
},
{
"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" : "76d7627bd88b47bf5a0f8497dd244885960dde0b",
"version" : "1.6.0"
}
},
{
"identity" : "swift-http-types",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-http-types.git",
"state" : {
"revision" : "45eb0224913ea070ec4fba17291b9e7ecf4749ca",
"version" : "1.5.1"
}
},
{
"identity" : "swift-log",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-log.git",
"state" : {
"revision" : "bbd81b6725ae874c69e9b8c8804d462356b55523",
"version" : "1.10.1"
}
},
{
"identity" : "swift-metrics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-metrics",
"state" : {
"revision" : "d51c8d13fa366eec807eedb4e37daa60ff5bfdd5",
"version" : "2.10.1"
}
},
{
"identity" : "swift-nio",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio.git",
"state" : {
"revision" : "cd6710454f25733900e133c6caf5188952763c36",
"version" : "2.98.0"
}
},
{
"identity" : "swift-nio-extras",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-nio-extras.git",
"state" : {
"revision" : "a55c3dd3a81d035af8a20ce5718889c0dcab073d",
"version" : "1.29.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" : "e645014baea2ec1c2db564410c51a656cf47c923",
"version" : "1.25.1"
}
},
{
"identity" : "swift-numerics",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-numerics.git",
"state" : {
"revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2",
"version" : "1.1.1"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "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" : "swift-toml",
"kind" : "remoteSourceControl",
"location" : "https://github.com/mattt/swift-toml.git",
"state" : {
"revision" : "827506c90475e82d5a7f191f950fb3025cbdc0d6",
"version" : "2.0.0"
}
},
{
"identity" : "yams",
"kind" : "remoteSourceControl",
"location" : "https://github.com/jpsim/Yams.git",
"state" : {
"revision" : "deaf82e867fa2cbd3cd865978b079bfcf384ac28",
"version" : "6.2.1"
}
},
{
"identity" : "zstd",
"kind" : "remoteSourceControl",
"location" : "https://github.com/facebook/zstd.git",
"state" : {
"revision" : "f8745da6ff1ad1e7bab384bd1f9d742439278e99",
"version" : "1.5.7"
}
}
],
"version" : 3
}
+621
View File
@@ -0,0 +1,621 @@
// swift-tools-version: 6.2
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 Foundation
import PackageDescription
let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0"
let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified"
let builderShimVersion = "0.12.0"
let scVersion = "0.37.0"
let package = Package(
name: "container",
platforms: [.macOS("15")],
products: [
.library(name: "ContainerCommands", targets: ["ContainerCommands"]),
.library(name: "ContainerBuild", targets: ["ContainerBuild"]),
.library(name: "ContainerAPIService", targets: ["ContainerAPIService"]),
.library(name: "ContainerAPIClient", targets: ["ContainerAPIClient"]),
.library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]),
.library(name: "ContainerNetworkClient", targets: ["ContainerNetworkClient"]),
.library(name: "ContainerNetworkServer", targets: ["ContainerNetworkServer"]),
.library(name: "ContainerNetworkVmnetServer", targets: ["ContainerNetworkVmnetServer"]),
.library(name: "ContainerResource", targets: ["ContainerResource"]),
.library(name: "ContainerLog", targets: ["ContainerLog"]),
.library(name: "ContainerPersistence", targets: ["ContainerPersistence"]),
.library(name: "ContainerPlugin", targets: ["ContainerPlugin"]),
.library(name: "ContainerRuntimeClient", targets: ["ContainerRuntimeClient"]),
.library(name: "ContainerRuntimeLinuxClient", targets: ["ContainerRuntimeLinuxClient"]),
.library(name: "ContainerRuntimeLinuxServer", targets: ["ContainerRuntimeLinuxServer"]),
.library(name: "ContainerVersion", targets: ["ContainerVersion"]),
.library(name: "ContainerXPC", targets: ["ContainerXPC"]),
.library(name: "ContainerOS", targets: ["ContainerOS"]),
.library(name: "SocketForwarder", targets: ["SocketForwarder"]),
.library(name: "TerminalProgress", targets: ["TerminalProgress"]),
.library(name: "MachineAPIClient", targets: ["MachineAPIClient"]),
.library(name: "MachineAPIService", targets: ["MachineAPIService"]),
],
dependencies: [
.package(url: "https://github.com/apple/containerization.git", exact: Version(stringLiteral: scVersion)),
.package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"),
.package(url: "https://github.com/apple/swift-collections.git", from: "1.2.0"),
.package(url: "https://github.com/apple/swift-configuration", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"),
.package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"),
.package(url: "https://github.com/apple/swift-protobuf.git", from: "1.36.0"),
.package(url: "https://github.com/apple/swift-system.git", from: "1.6.4"),
.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/swift-server/async-http-client.git", from: "1.20.1"),
.package(url: "https://github.com/swiftlang/swift-docc-plugin.git", from: "1.1.0"),
.package(url: "https://github.com/mattt/swift-toml.git", from: "2.0.0"),
.package(url: "https://github.com/mattt/swift-configuration-toml", from: "2.0.0"),
.package(url: "https://github.com/jpsim/Yams.git", from: "6.2.1"),
],
targets: [
.executableTarget(
name: "container",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
"ContainerAPIClient",
"ContainerCommands",
],
path: "Sources/CLI"
),
.testTarget(
name: "IntegrationTests",
dependencies: [
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "TOML", package: "swift-toml"),
"ContainerAPIClient",
"ContainerLog",
"ContainerPersistence",
"ContainerResource",
"MachineAPIClient",
"Yams",
],
path: "Tests/IntegrationTests"
),
.target(
name: "ContainerCommands",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SwiftProtobuf", package: "swift-protobuf"),
.product(name: "TOML", package: "swift-toml"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
"ContainerBuild",
"ContainerAPIClient",
"ContainerLog",
"ContainerPersistence",
"ContainerPlugin",
"ContainerResource",
"ContainerRuntimeClient",
"ContainerRuntimeLinuxClient",
"ContainerVersion",
.product(name: "SystemPackage", package: "swift-system"),
"ContainerXPC",
"MachineAPIClient",
"TerminalProgress",
"Yams",
],
path: "Sources/ContainerCommands"
),
.target(
name: "ContainerBuild",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "NIO", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
.product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"),
"ContainerAPIClient",
]
),
.testTarget(
name: "ContainerBuildTests",
dependencies: [
"ContainerBuild"
]
),
.testTarget(
name: "ContainerCommandsTests",
dependencies: [
"ContainerCommands",
"ContainerResource",
]
),
.executableTarget(
name: "container-apiserver",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "AsyncHTTPClient", package: "async-http-client"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ContainerizationEXT4", package: "containerization"),
.product(name: "GRPCCore", package: "grpc-swift-2"),
.product(name: "GRPCNIOTransportHTTP2", package: "grpc-swift-nio-transport"),
.product(name: "GRPCProtobuf", package: "grpc-swift-protobuf"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerAPIService",
"ContainerAPIClient",
"ContainerLog",
"ContainerNetworkClient",
"ContainerPersistence",
"ContainerPlugin",
"ContainerResource",
"ContainerVersion",
"ContainerXPC",
"ContainerOS",
"DNSServer",
],
path: "Sources/APIServer"
),
.target(
name: "ContainerAPIService",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"CVersion",
"ContainerAPIClient",
"ContainerNetworkClient",
"ContainerPersistence",
"ContainerPlugin",
"ContainerResource",
"ContainerRuntimeClient",
"ContainerVersion",
"ContainerXPC",
"TerminalProgress",
],
path: "Sources/Services/ContainerAPIService/Server"
),
.testTarget(
name: "ContainerAPIServiceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
"ContainerResource",
"ContainerRuntimeLinuxClient",
"ContainerRuntimeClient",
]
),
.target(
name: "ContainerAPIClient",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "Logging", package: "swift-log"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerImagesServiceClient",
"ContainerPersistence",
"ContainerPlugin",
"ContainerResource",
"ContainerXPC",
"DNSServer",
"TerminalProgress",
],
path: "Sources/Services/ContainerAPIService/Client"
),
.testTarget(
name: "ContainerAPIClientTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerAPIClient",
"ContainerPersistence",
"ContainerTestSupport",
]
),
.executableTarget(
name: "container-core-images",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerImagesService",
"ContainerLog",
"ContainerPersistence",
"ContainerPlugin",
"ContainerVersion",
"ContainerXPC",
],
path: "Sources/Plugins/CoreImages",
exclude: ["config.toml"]
),
.target(
name: "ContainerImagesService",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationArchive", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
"ContainerAPIClient",
"ContainerImagesServiceClient",
"ContainerLog",
"ContainerPersistence",
"ContainerResource",
"ContainerXPC",
"TerminalProgress",
],
path: "Sources/Services/ContainerImagesService/Server"
),
.target(
name: "ContainerImagesServiceClient",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
"ContainerXPC",
"ContainerLog",
],
path: "Sources/Services/ContainerImagesService/Client"
),
.executableTarget(
name: "container-network-vmnet",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
"ContainerLog",
"ContainerNetworkClient",
"ContainerNetworkServer",
"ContainerNetworkVmnetServer",
"ContainerPersistence",
"ContainerPlugin",
"ContainerResource",
"ContainerVersion",
"ContainerXPC",
],
path: "Sources/Plugins/NetworkVmnet",
exclude: ["config.toml"]
),
.target(
name: "ContainerNetworkClient",
dependencies: [
.product(name: "ContainerizationExtras", package: "containerization"),
"ContainerResource",
"ContainerXPC",
],
path: "Sources/Services/Network/Client"
),
.target(
name: "ContainerNetworkServer",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "ContainerizationExtras", package: "containerization"),
"ContainerNetworkClient",
"ContainerResource",
"ContainerXPC",
],
path: "Sources/Services/Network/Server"
),
.testTarget(
name: "ContainerNetworkServerTests",
dependencies: [
.product(name: "ContainerizationExtras", package: "containerization"),
"ContainerNetworkServer",
]
),
.target(
name: "ContainerNetworkVmnetServer",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "ContainerizationExtras", package: "containerization"),
"ContainerNetworkServer",
"ContainerResource",
"ContainerXPC",
],
path: "Sources/Services/NetworkVmnet/Server"
),
.target(
name: "ContainerRuntimeLinuxClient",
dependencies: [],
path: "Sources/Services/RuntimeLinux/Client"
),
.executableTarget(
name: "container-runtime-linux",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
"ContainerLog",
"ContainerPlugin",
"ContainerResource",
"ContainerRuntimeClient",
"ContainerRuntimeLinuxClient",
"ContainerRuntimeLinuxServer",
"ContainerVersion",
"ContainerXPC",
],
path: "Sources/Plugins/RuntimeLinux",
exclude: ["config.toml"]
),
.target(
name: "ContainerRuntimeLinuxServer",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "ArgumentParser", package: "swift-argument-parser"),
"ContainerAPIClient",
"ContainerNetworkClient",
"ContainerOS",
"ContainerPersistence",
"ContainerResource",
"ContainerRuntimeClient",
"ContainerRuntimeLinuxClient",
"ContainerXPC",
"SocketForwarder",
],
path: "Sources/Services/RuntimeLinux/Server"
),
.target(
name: "ContainerRuntimeClient",
dependencies: [
"ContainerAPIClient",
"ContainerResource",
"ContainerXPC",
],
path: "Sources/Services/Runtime/RuntimeClient"
),
.target(
name: "ContainerResource",
dependencies: [
.product(name: "Collections", package: "swift-collections"),
.product(name: "Containerization", package: "containerization"),
"ContainerXPC",
"CAuditToken",
"CVersion",
]
),
.testTarget(
name: "ContainerResourceTests",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
"ContainerAPIService",
"ContainerResource",
]
),
.target(
name: "ContainerLog",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
]
),
.target(
name: "ContainerPersistence",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "Containerization", package: "containerization"),
.product(name: "Configuration", package: "swift-configuration"),
.product(name: "ConfigurationTOML", package: "swift-configuration-toml"),
.product(name: "SystemPackage", package: "swift-system"),
"CVersion",
"ContainerVersion",
]
),
.testTarget(
name: "ContainerPersistenceTests",
dependencies: [
.product(name: "Configuration", package: "swift-configuration"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerPersistence",
"ContainerTestSupport",
]
),
.target(
name: "ContainerPlugin",
dependencies: [
.product(name: "Logging", package: "swift-log"),
.product(name: "ContainerizationOS", package: "containerization"),
.product(name: "SystemPackage", package: "swift-system"),
.product(name: "TOML", package: "swift-toml"),
"ContainerVersion",
]
),
.testTarget(
name: "ContainerPluginTests",
dependencies: [
"ContainerPlugin"
]
),
.target(
name: "ContainerXPC",
dependencies: [
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "Logging", package: "swift-log"),
"CAuditToken",
]
),
.target(
name: "ContainerOS",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
],
path: "Sources/ContainerOS"
),
.testTarget(
name: "ContainerOSTests",
dependencies: [
"ContainerOS"
]
),
.target(
name: "TerminalProgress",
dependencies: [
.product(name: "ContainerizationOS", package: "containerization")
]
),
.testTarget(
name: "TerminalProgressTests",
dependencies: ["TerminalProgress"]
),
.target(
name: "DNSServer",
dependencies: [
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOPosix", package: "swift-nio"),
.product(name: "Logging", package: "swift-log"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOS", package: "containerization"),
]
),
.testTarget(
name: "DNSServerTests",
dependencies: [
"DNSServer"
]
),
.target(
name: "SocketForwarder",
dependencies: [
.product(name: "Collections", package: "swift-collections"),
.product(name: "Logging", package: "swift-log"),
.product(name: "NIOCore", package: "swift-nio"),
.product(name: "NIOFoundationCompat", package: "swift-nio"),
]
),
.testTarget(
name: "SocketForwarderTests",
dependencies: ["SocketForwarder"]
),
.target(
name: "ContainerVersion",
dependencies: [
.product(name: "SystemPackage", package: "swift-system"),
"CVersion",
],
),
.testTarget(
name: "ContainerVersionTests",
dependencies: [
.product(name: "SystemPackage", package: "swift-system"),
"ContainerVersion",
]
),
.target(
name: "CVersion",
dependencies: [],
publicHeadersPath: "include",
cSettings: [
.define("CZ_VERSION", to: "\"\(scVersion)\""),
.define("GIT_COMMIT", to: "\"\(gitCommit)\""),
.define("RELEASE_VERSION", to: "\"\(releaseVersion)\""),
.define("BUILDER_SHIM_VERSION", to: "\"\(builderShimVersion)\""),
],
),
.target(
name: "CAuditToken",
dependencies: [],
publicHeadersPath: "include",
linkerSettings: [
.linkedLibrary("bsm")
]
),
.target(
name: "ContainerTestSupport",
dependencies: [
.product(name: "SystemPackage", package: "swift-system")
]
),
.target(
name: "MachineAPIClient",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "Logging", package: "swift-log"),
"ContainerAPIClient",
"ContainerPersistence",
"ContainerResource",
"ContainerXPC",
"TerminalProgress",
],
path: "Sources/Services/MachineAPIService/Client"
),
.target(
name: "MachineAPIService",
dependencies: [
.product(name: "Containerization", package: "containerization"),
.product(name: "ContainerizationEXT4", package: "containerization"),
.product(name: "ContainerizationExtras", package: "containerization"),
.product(name: "ContainerizationOCI", package: "containerization"),
.product(name: "Logging", package: "swift-log"),
.product(name: "SystemPackage", package: "swift-system"),
"ContainerAPIClient",
"ContainerResource",
"ContainerRuntimeClient",
"ContainerXPC",
"MachineAPIClient",
],
path: "Sources/Services/MachineAPIService/Server"
),
.executableTarget(
name: "machine-apiserver",
dependencies: [
.product(name: "ArgumentParser", package: "swift-argument-parser"),
.product(name: "Logging", package: "swift-log"),
"ContainerAPIClient",
"ContainerLog",
"ContainerPersistence",
"ContainerPlugin",
"ContainerVersion",
"ContainerXPC",
"MachineAPIClient",
"MachineAPIService",
],
path: "Sources/Plugins/MachineAPIServer",
exclude: ["config.toml", "Resources"]
),
]
)
+64
View File
@@ -0,0 +1,64 @@
# Copyright © 2025-2026 Apple Inc. and the container 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.
ROOT_DIR := $(shell git rev-parse --show-toplevel)
LOCAL_DIR := $(ROOT_DIR)/.local
LOCAL_BIN_DIR := $(LOCAL_DIR)/bin
BUILDER_SHIM_REPO ?= https://github.com/apple/container-builder-shim.git
# Versions
BUILDER_SHIM_VERSION ?= $(shell sed -n 's/let builderShimVersion *= *"\(.*\)"/\1/p' Package.swift)
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...
@mkdir -p $(LOCAL_DIR)
@if [ ! -d "$(LOCAL_DIR)/container-builder-shim" ]; then \
cd $(LOCAL_DIR) && git clone --branch $(BUILDER_SHIM_VERSION) --depth 1 $(BUILDER_SHIM_REPO); \
fi
@$(PROTOC) $(LOCAL_DIR)/container-builder-shim/pkg/api/Builder.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=$(LOCAL_DIR)/container-builder-shim/pkg/api \
--grpc-swift_out="Sources/ContainerBuild" \
--grpc-swift_opt=Visibility=Public \
--swift_out="Sources/ContainerBuild" \
--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*
@rm -rf $(LOCAL_DIR)/container-builder-shim
+92
View File
@@ -0,0 +1,92 @@
<h1>
<img alt="Containerization logo" src="./assets/Containerization-Logo.png" width="70" valign="middle">
&nbsp;container
</h1>
`container` is a tool that you can use to create and run Linux containers as lightweight virtual machines on your Mac. It's written in Swift, and optimized for Apple silicon.
The tool consumes and produces [OCI-compatible container images](https://github.com/opencontainers/image-spec), so you can pull and run images from any standard container registry. You can push images that you build to those registries as well, and run the images in any other OCI-compatible application.
`container` uses the [Containerization](https://github.com/apple/containerization) Swift package for low-level container, image, and process management.
![introductory movie showing some basic commands](./docs/assets/landing-movie.gif)
## Get started
### Requirements
You need a Mac with Apple silicon to run `container`. To build it, see the [BUILDING](./BUILDING.md) document.
`container` is supported on macOS 26, since it takes advantage of new features and enhancements to virtualization and networking in this release. We do not support older versions of macOS and the `container` maintainers typically will not address issues that cannot be reproduced on macOS 26.
### Initial install
Download the latest signed installer package for `container` from the [GitHub release page](https://github.com/apple/container/releases).
To install the tool, double-click the package file and follow the instructions. Enter your administrator password when prompted, to give the installer permission to place the installed files under `/usr/local`.
Start the system service with:
```bash
container system start
```
### Upgrade or downgrade
For both upgrading and downgrading, you can manually download and install the signed installer package by following the steps from [initial install](#initial-install) or use the `update-container.sh` script (installed to `/usr/local/bin`).
If you're upgrading or downgrading, you must stop your existing `container`:
```bash
container system stop
```
To upgrade to the latest release, simply run the command below:
```bash
/usr/local/bin/update-container.sh
```
To downgrade, you must uninstall your existing `container` (the `-k` flag keeps your user data, while `-d` removes it):
```bash
/usr/local/bin/uninstall-container.sh -k
/usr/local/bin/update-container.sh -v 0.3.0
```
Start the system service with:
```bash
container system start
```
### Uninstall
Use the `uninstall-container.sh` script (installed to `/usr/local/bin`) to remove `container` from your system. To remove your user data along with the tool, run:
```bash
/usr/local/bin/uninstall-container.sh -d
```
To retain your user data so that it is available should you reinstall later, run:
```bash
/usr/local/bin/uninstall-container.sh -k
```
## Next steps
- Take [a guided tour of `container`](./docs/tutorials/start-here.md) by building, running, and publishing a simple web server image.
- Learn how to [use various `container` features](./docs/how-to.md).
- Read a brief description and [technical overview](./docs/technical-overview.md) of `container`.
- Browse the [full command reference](./docs/command-reference.md).
- [Build and run](./BUILDING.md) `container` on your own development system.
- View the project [API documentation](https://apple.github.io/container/documentation/).
## Contributing
Contributions to `container` are welcome and encouraged. Please see our [main contributing guide](https://github.com/apple/containerization/blob/main/CONTRIBUTING.md) for more information.
## Project Status
The container project is currently under active development. Its stability, both for consuming the project as a Swift package and the `container` tool, is only guaranteed within patch versions, such as between 0.1.1 and 0.1.2. Minor version releases may include breaking changes until we reach a 1.0.0 release.
+7
View File
@@ -0,0 +1,7 @@
# WeHub 来源说明
- 原始项目:`apple/container`
- 原始仓库:https://github.com/apple/container
- 导入方式:上游默认分支的最新快照
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
+13
View File
@@ -0,0 +1,13 @@
# Security disclosure process
If you believe that you have discovered a security or privacy vulnerability in our open source software, please report it to us using the [GitHub private vulnerability feature](https://github.com/apple/container/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.
+398
View File
@@ -0,0 +1,398 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerAPIService
import ContainerLog
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import ContainerXPC
import ContainerizationExtras
import DNSServer
import Foundation
import Logging
import SystemPackage
extension APIServer {
struct Start: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start helper for the API server"
)
static let listenAddress = "127.0.0.1"
static let localhostDNSPort = 1053
static let dnsPort = 2053
@Flag(name: .long, help: "Enable debug logging")
var debug = false
var appRoot = ApplicationRoot.path
var installRoot = InstallRoot.path
var logRoot = LogRoot.path
func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let commandName = APIServer._commandName
let logPath = logRoot.map { $0.appending(FilePath.Component("\(commandName).log") ?? "unknown") }
let log = ServiceLogger.bootstrap(category: "APIServer", debug: debug, logPath: logPath)
log.info("starting helper", metadata: ["name": "\(commandName)"])
defer {
log.info("stopping helper", metadata: ["name": "\(commandName)"])
}
do {
log.info("configuring XPC server")
var routes = [XPCRoute: XPCServer.RouteHandler]()
let pluginLoader = try initializePluginLoader(log: log)
try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes, debug: debug)
let containersService = try initializeContainersService(
pluginLoader: pluginLoader,
containerSystemConfig: containerSystemConfig,
log: log,
routes: &routes
)
let networkService = try await initializeNetworksService(
pluginLoader: pluginLoader,
containersService: containersService,
containerSystemConfig: containerSystemConfig,
log: log,
routes: &routes
)
await containersService.setNetworksService(networkService)
initializeHealthCheckService(log: log, routes: &routes)
try initializeKernelService(log: log, routes: &routes)
let volumesService = try await initializeVolumeService(containersService: containersService, log: log, routes: &routes)
try initializeDiskUsageService(
containersService: containersService,
volumesService: volumesService,
log: log,
routes: &routes
)
let server = XPCServer(
identifier: "com.apple.container.apiserver",
routes: routes.reduce(
into: [String: XPCServer.RouteHandler](),
{
$0[$1.key.rawValue] = $1.value
}), log: log)
await withTaskGroup(of: Result<Void, Error>.self) { group in
group.addTask {
log.info("starting XPC server")
do {
try await server.listen()
return .success(())
} catch {
return .failure(error)
}
}
// start up host table DNS
group.addTask {
let hostsResolver = ContainerDNSHandler(networkService: networkService)
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [hostsResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS resolver for container hostnames",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.dnsPort)",
]
)
do {
try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort)
return .success(())
} catch {
return .failure(error)
}
}
// start up realhost DNS
group.addTask {
do {
let localhostResolver = LocalhostDNSHandler(log: log)
await localhostResolver.monitorResolvers()
let nxDomainResolver = NxDomainResolver()
let compositeResolver = CompositeResolver(handlers: [localhostResolver, nxDomainResolver])
let hostsQueryValidator = StandardQueryValidator(handler: compositeResolver)
let dnsServer: DNSServer = DNSServer(handler: hostsQueryValidator, log: log)
log.info(
"starting DNS resolver for localhost",
metadata: [
"host": "\(Self.listenAddress)",
"port": "\(Self.localhostDNSPort)",
]
)
try await dnsServer.run(host: Self.listenAddress, port: Self.localhostDNSPort)
return .success(())
} catch {
return .failure(error)
}
}
for await result in group {
switch result {
case .success():
continue
case .failure(let error):
log.error("API server task failed: \(error)")
}
}
}
} catch {
log.error(
"helper failed",
metadata: [
"name": "\(commandName)",
"error": "\(error)",
])
APIServer.exit(withError: error)
}
}
private func initializePluginLoader(log: Logger) throws -> PluginLoader {
log.info(
"initializing plugin loader",
metadata: [
"installRoot": "\(installRoot.string)"
])
// TODO: Remove when we convert PluginLoader to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
log.info("detecting user plugins directory", metadata: ["path": "\(pluginsURL.path(percentEncoded: false))"])
var directoryExists: ObjCBool = false
_ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists)
let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil
// plugins built into the application installed as a Unix-like application
let installRootPluginsPath =
installRoot
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let pluginDirectories = [
userPluginsURL,
installRootPluginsURL,
].compactMap { $0 }
let pluginFactories: [PluginFactory] = [
DefaultPluginFactory(logger: log),
AppBundlePluginFactory(logger: log),
]
for pluginDirectory in pluginDirectories {
log.info("discovered plugin directory", metadata: ["path": "\(pluginDirectory.path(percentEncoded: false))"])
}
let appRootURL = URL(fileURLWithPath: appRoot.string)
return try PluginLoader(
appRoot: appRootURL,
installRoot: installRootURL,
logRoot: logRoot,
pluginDirectories: pluginDirectories,
pluginFactories: pluginFactories,
log: log
)
}
// First load all of the plugins we can find. Then just expose
// the handlers for clients to do whatever they want.
private func initializePlugins(
pluginLoader: PluginLoader,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler],
debug: Bool = false
) async throws {
log.info("initializing plugins")
let bootPlugins = pluginLoader.findPlugins().filter { $0.shouldBoot }
let service = PluginsService(pluginLoader: pluginLoader, log: log)
try await service.loadAll(bootPlugins, debug: debug)
let harness = PluginsHarness(service: service, log: log)
routes[XPCRoute.pluginGet] = XPCServer.route(harness.get)
routes[XPCRoute.pluginList] = XPCServer.route(harness.list)
routes[XPCRoute.pluginLoad] = XPCServer.route(harness.load)
routes[XPCRoute.pluginUnload] = XPCServer.route(harness.unload)
routes[XPCRoute.pluginRestart] = XPCServer.route(harness.restart)
}
private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) {
log.info("initializing health check service")
// TODO: Remove when we convert HealthCheckHarness to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let appRootURL = URL(fileURLWithPath: appRoot.string)
let svc = HealthCheckHarness(
appRoot: appRootURL,
installRoot: installRootURL,
logRoot: logRoot,
log: log
)
routes[XPCRoute.ping] = XPCServer.route(svc.ping)
}
private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws {
log.info("initializing kernel service")
// TODO: Remove when we convert KernelService to FilePath
let appRootURL = URL(fileURLWithPath: appRoot.string)
let svc = try KernelService(log: log, appRoot: appRootURL)
let harness = KernelHarness(service: svc, log: log)
routes[XPCRoute.installKernel] = XPCServer.route(harness.install)
routes[XPCRoute.getDefaultKernel] = XPCServer.route(harness.getDefaultKernel)
}
private func initializeContainersService(
pluginLoader: PluginLoader,
containerSystemConfig: ContainerSystemConfig,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) throws -> ContainersService {
log.info("initializing containers service")
// TODO: Remove when we convert ContainersService to FilePath
let appRootURL = URL(fileURLWithPath: appRoot.string)
let service = try ContainersService(
appRoot: appRootURL,
pluginLoader: pluginLoader,
containerSystemConfig: containerSystemConfig,
log: log,
debugHelpers: debug
)
let harness = ContainersHarness(service: service, log: log)
routes[XPCRoute.containerList] = XPCServer.route(harness.list)
routes[XPCRoute.containerCreate] = XPCServer.route(harness.create)
routes[XPCRoute.containerDelete] = XPCServer.route(harness.delete)
routes[XPCRoute.containerLogs] = XPCServer.route(harness.logs)
routes[XPCRoute.containerBootstrap] = XPCServer.route(harness.bootstrap)
routes[XPCRoute.containerDial] = XPCServer.route(harness.dial)
routes[XPCRoute.containerStop] = XPCServer.route(harness.stop)
routes[XPCRoute.containerStartProcess] = XPCServer.route(harness.startProcess)
routes[XPCRoute.containerCreateProcess] = XPCServer.route(harness.createProcess)
routes[XPCRoute.containerResize] = XPCServer.route(harness.resize)
routes[XPCRoute.containerWait] = XPCServer.route(harness.wait)
routes[XPCRoute.containerKill] = XPCServer.route(harness.kill)
routes[XPCRoute.containerStats] = XPCServer.route(harness.stats)
routes[XPCRoute.containerDiskUsage] = XPCServer.route(harness.diskUsage)
routes[XPCRoute.containerCopyIn] = XPCServer.route(harness.copyIn)
routes[XPCRoute.containerCopyOut] = XPCServer.route(harness.copyOut)
routes[XPCRoute.containerExport] = XPCServer.route(harness.export)
return service
}
private func initializeNetworksService(
pluginLoader: PluginLoader,
containersService: ContainersService,
containerSystemConfig: ContainerSystemConfig,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) async throws -> NetworksService {
log.info("initializing networks service")
let resourceRoot = appRoot.appending(FilePath.Component("networks"))
let defaultNetworkConfig = try NetworkConfiguration(
name: NetworkClient.defaultNetworkName,
mode: .nat,
ipv4Subnet: containerSystemConfig.network.subnet,
ipv6Subnet: containerSystemConfig.network.subnetv6,
labels: try .init([ResourceLabelKeys.role: ResourceRoleValues.builtin]),
plugin: "container-network-vmnet"
)
let service = try await NetworksService(
pluginLoader: pluginLoader,
resourceRoot: resourceRoot,
containersService: containersService,
defaultNetworkConfiguration: defaultNetworkConfig,
log: log,
debugHelpers: debug
)
let defaultNetwork = try await service.list()
.filter { $0.isBuiltin }
.first
if defaultNetwork == nil {
// FIXME: default network should be configurable elsewhere
_ = try await service.create(configuration: defaultNetworkConfig)
}
let harness = NetworksHarness(service: service, log: log)
if #available(macOS 26, *) {
routes[XPCRoute.networkCreate] = XPCServer.route(harness.create)
}
routes[XPCRoute.networkList] = XPCServer.route(harness.list)
routes[XPCRoute.networkDelete] = XPCServer.route(harness.delete)
return service
}
private func initializeVolumeService(
containersService: ContainersService,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) async throws -> VolumesService {
log.info("initializing volume service")
let resourceRoot = appRoot.appending(FilePath.Component("volumes"))
let service = try await VolumesService(resourceRoot: resourceRoot, containersService: containersService, log: log)
let harness = VolumesHarness(service: service, log: log)
routes[XPCRoute.volumeCreate] = XPCServer.route(harness.create)
routes[XPCRoute.volumeDelete] = XPCServer.route(harness.delete)
routes[XPCRoute.volumeList] = XPCServer.route(harness.list)
routes[XPCRoute.volumeInspect] = XPCServer.route(harness.inspect)
routes[XPCRoute.volumeDiskUsage] = XPCServer.route(harness.diskUsage)
return service
}
private func initializeDiskUsageService(
containersService: ContainersService,
volumesService: VolumesService,
log: Logger,
routes: inout [XPCRoute: XPCServer.RouteHandler]
) throws {
log.info("initializing disk usage service")
let service = DiskUsageService(
containersService: containersService,
volumesService: volumesService,
log: log
)
let harness = DiskUsageHarness(service: service, log: log)
routes[XPCRoute.systemDiskUsage] = XPCServer.route(harness.get)
}
}
}
+28
View File
@@ -0,0 +1,28 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerVersion
@main
struct APIServer: AsyncParsableCommand {
static let configuration = CommandConfiguration(
commandName: "container-apiserver",
abstract: "Container management API server",
version: ReleaseVersion.singleLine(appName: "container-apiserver"),
subcommands: [Start.self],
)
}
+104
View File
@@ -0,0 +1,104 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 ContainerAPIService
import ContainerizationExtras
import DNSServer
/// Handler that uses table lookup to resolve hostnames.
struct ContainerDNSHandler: DNSHandler {
private let networkService: NetworksService
private let ttl: UInt32
public init(networkService: NetworksService, ttl: UInt32 = 5) {
self.networkService = networkService
self.ttl = ttl
}
public func answer(query: Message) async throws -> Message? {
guard let question = query.questions.first else {
return nil
}
let record: ResourceRecord?
switch question.type {
case ResourceRecordType.host:
record = try await answerHost(question: question)
case ResourceRecordType.host6:
let result = try await answerHost6(question: question)
if result.record == nil && result.hostnameExists {
// Return NODATA (noError with empty answers) when hostname exists but has no IPv6.
// This is required because musl libc has issues when A record exists but AAAA returns NXDOMAIN.
// musl treats NXDOMAIN on AAAA as "domain doesn't exist" and fails DNS resolution entirely.
// NODATA correctly indicates "no IPv6 address available, but domain exists".
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: []
)
}
record = result.record
default:
return Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)
}
guard let record else {
return nil
}
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: [record]
)
}
private func answerHost(question: Question) async throws -> ResourceRecord? {
guard let ipAllocation = try await networkService.lookup(hostname: question.name) else {
return nil
}
let ipv4 = ipAllocation.ipv4Address.address.description
guard let ip = try? IPv4Address(ipv4) else {
throw DNSResolverError.serverError("failed to parse IP address: \(ipv4)")
}
return HostRecord<IPv4Address>(name: question.name, ttl: ttl, ip: ip)
}
private func answerHost6(question: Question) async throws -> (record: ResourceRecord?, hostnameExists: Bool) {
guard let ipAllocation = try await networkService.lookup(hostname: question.name) else {
return (nil, false)
}
guard let ipv6Address = ipAllocation.ipv6Address else {
return (nil, true)
}
let ipv6 = ipv6Address.address.description
guard let ip = try? IPv6Address(ipv6) else {
throw DNSResolverError.serverError("failed to parse IPv6 address: \(ipv6)")
}
return (HostRecord<IPv6Address>(name: question.name, ttl: ttl, ip: ip), true)
}
}
+119
View File
@@ -0,0 +1,119 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 ContainerAPIClient
import ContainerOS
import ContainerPersistence
import ContainerizationError
import ContainerizationExtras
import DNSServer
import Foundation
import Logging
import Synchronization
import SystemPackage
actor LocalhostDNSHandler: DNSHandler {
private let ttl: UInt32
private let watcher: DirectoryWatcher
private var dns: [DNSName: IPv4Address]
public init(configPath: FilePath = HostDNSResolver.defaultConfigPath, ttl: UInt32 = 5, log: Logger) {
self.ttl = ttl
self.watcher = DirectoryWatcher(directoryPath: configPath, log: log)
self.dns = [DNSName: IPv4Address]()
}
public func monitorResolvers() async {
await self.watcher.startWatching { [weak self] filePaths in
var dns: [DNSName: IPv4Address] = [:]
let regex = try Regex(HostDNSResolver.localhostOptionsRegex)
for file in filePaths.filter({
$0.lastComponent?.string.starts(with: HostDNSResolver.containerizationPrefix) == true
}) {
let content = try String(contentsOfFile: file.string, encoding: .utf8)
if let match = content.firstMatch(of: regex),
let ipv4 = (match[1].substring.flatMap { try? IPv4Address(String($0)) })
{
guard let lastName = file.lastComponent?.string else {
continue
}
let name = String(lastName.dropFirst(HostDNSResolver.containerizationPrefix.count))
guard let dnsName = try? DNSName(name) else {
continue
}
dns[dnsName] = ipv4
}
}
if let self {
Task { await self.updateDNS(dns) }
}
}
}
public func answer(query: Message) async throws -> Message? {
guard let question = query.questions.first else {
return nil
}
let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name
let key = try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))
var record: ResourceRecord?
switch question.type {
case ResourceRecordType.host:
if let ip = dns[key] {
record = HostRecord<IPv4Address>(name: question.name, ttl: ttl, ip: ip)
}
case ResourceRecordType.host6:
guard dns[key] != nil else {
return nil
}
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: []
)
default:
return Message(
id: query.id,
type: .response,
returnCode: .notImplemented,
questions: query.questions,
answers: []
)
}
guard let record else {
return nil
}
return Message(
id: query.id,
type: .response,
returnCode: .noError,
questions: query.questions,
answers: [record]
)
}
private func updateDNS(_ dns: [DNSName: IPv4Address]) {
self.dns = dns
}
}
+17
View File
@@ -0,0 +1,17 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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.
//===----------------------------------------------------------------------===//
// This file is required for Xcode to generate `CAuditToken.o`.
+20
View File
@@ -0,0 +1,20 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 <xpc/xpc.h>
#include <bsm/libbsm.h>
void xpc_dictionary_get_audit_token(xpc_object_t xdict, audit_token_t *token);
+39
View File
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerCommands
@main
public struct ContainerCLI: AsyncParsableCommand {
public init() {}
@Argument(parsing: .captureForPassthrough)
var arguments: [String] = []
public static let configuration = Application.configuration
public static func main() async throws {
try await Application.main()
}
public func run() async throws {
var application = try Application.parse(arguments)
try application.validate()
try application.run()
}
}
+33
View File
@@ -0,0 +1,33 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 "Version.h"
const char* get_git_commit() {
return GIT_COMMIT;
}
const char* get_release_version() {
return RELEASE_VERSION;
}
const char* get_swift_containerization_version() {
return CZ_VERSION;
}
const char* get_container_builder_shim_version() {
return BUILDER_SHIM_VERSION;
}
+39
View File
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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_VERSION
#define CZ_VERSION "latest"
#endif
#ifndef GIT_COMMIT
#define GIT_COMMIT "unspecified"
#endif
#ifndef RELEASE_VERSION
#define RELEASE_VERSION "0.0.0"
#endif
#ifndef BUILDER_SHIM_VERSION
#define BUILDER_SHIM_VERSION "0.0.0"
#endif
const char* get_git_commit();
const char* get_release_version();
const char* get_swift_containerization_version();
const char* get_container_builder_shim_version();
@@ -0,0 +1,150 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Containerization
import ContainerizationOCI
public typealias IO = Com_Apple_Container_Build_V1_IO
public typealias InfoRequest = Com_Apple_Container_Build_V1_InfoRequest
public typealias InfoResponse = Com_Apple_Container_Build_V1_InfoResponse
public typealias ClientStream = Com_Apple_Container_Build_V1_ClientStream
public typealias ServerStream = Com_Apple_Container_Build_V1_ServerStream
public typealias ImageTransfer = Com_Apple_Container_Build_V1_ImageTransfer
public typealias BuildTransfer = Com_Apple_Container_Build_V1_BuildTransfer
extension BuildTransfer {
func stage() -> String? {
let stage = self.metadata["stage"]
return stage == "" ? nil : stage
}
func method() -> String? {
let method = self.metadata["method"]
return method == "" ? nil : method
}
func includePatterns() -> [String]? {
guard let includePatternsString = self.metadata["include-patterns"] else {
return nil
}
return includePatternsString == "" ? nil : includePatternsString.components(separatedBy: ",")
}
func followPaths() -> [String]? {
guard let followPathString = self.metadata["followpaths"] else {
return nil
}
return followPathString == "" ? nil : followPathString.components(separatedBy: ",")
}
func mode() -> String? {
self.metadata["mode"]
}
func size() -> Int? {
guard let sizeStr = self.metadata["size"] else {
return nil
}
return sizeStr == "" ? nil : Int(sizeStr)
}
func offset() -> UInt64? {
guard let offsetStr = self.metadata["offset"] else {
return nil
}
return offsetStr == "" ? nil : UInt64(offsetStr)
}
func len() -> Int? {
guard let lenStr = self.metadata["length"] else {
return nil
}
return lenStr == "" ? nil : Int(lenStr)
}
}
extension ImageTransfer {
func stage() -> String? {
self.metadata["stage"]
}
func method() -> String? {
self.metadata["method"]
}
func ref() -> String? {
self.metadata["ref"]
}
func platform() throws -> Platform? {
let metadata = self.metadata
guard let platform = metadata["platform"] else {
return nil
}
return try Platform(from: platform)
}
func mode() -> String? {
self.metadata["mode"]
}
func size() -> Int? {
let metadata = self.metadata
guard let sizeStr = metadata["size"] else {
return nil
}
return Int(sizeStr)
}
func len() -> Int? {
let metadata = self.metadata
guard let lenStr = metadata["length"] else {
return nil
}
return Int(lenStr)
}
func offset() -> UInt64? {
let metadata = self.metadata
guard let offsetStr = metadata["offset"] else {
return nil
}
return UInt64(offsetStr)
}
}
extension ServerStream {
func getImageTransfer() -> ImageTransfer? {
if case .imageTransfer(let v) = self.packetType {
return v
}
return nil
}
func getBuildTransfer() -> BuildTransfer? {
if case .buildTransfer(let v) = self.packetType {
return v
}
return nil
}
func getIO() -> IO? {
if case .io(let v) = self.packetType {
return v
}
return nil
}
}
+536
View File
@@ -0,0 +1,536 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 Collections
import ContainerAPIClient
import ContainerizationArchive
import ContainerizationOCI
import CryptoKit
import Foundation
import GRPCCore
actor BuildFSSync: BuildPipelineHandler {
let contextDir: URL
init(_ contextDir: URL) throws {
guard FileManager.default.fileExists(atPath: contextDir.cleanPath) else {
throw Error.contextNotFound(contextDir.cleanPath)
}
guard try contextDir.isDir() else {
throw Error.contextIsNotDirectory(contextDir.cleanPath)
}
self.contextDir = contextDir
}
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
guard let buildTransfer = packet.getBuildTransfer() else {
return false
}
guard buildTransfer.stage() == "fssync" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let buildTransfer = packet.getBuildTransfer() else {
throw Error.buildTransferMissing
}
guard let method = buildTransfer.method() else {
throw Error.methodMissing
}
switch try FSSyncMethod(method) {
case .read:
try await self.read(sender, buildTransfer, packet.buildID)
case .info:
try await self.info(sender, buildTransfer, packet.buildID)
case .walk:
try await self.walk(sender, buildTransfer, packet.buildID)
}
}
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
var path: URL
if packet.source.hasPrefix("/") {
path = URL(fileURLWithPath: packet.source).standardizedFileURL
} else {
path =
contextDir
.appendingPathComponent(packet.source)
.standardizedFileURL
}
if !FileManager.default.fileExists(atPath: path.cleanPath) {
path = URL(filePath: self.contextDir.cleanPath)
path.append(components: packet.source.cleanPathComponent)
}
let data = try {
if try path.isDir() {
return Data()
}
let file = try LocalContent(path: path.standardizedFileURL)
return try file.data(offset: offset, length: size) ?? Data()
}()
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true, data: data)
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
}
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let path: URL
if packet.source.hasPrefix("/") {
path = URL(fileURLWithPath: packet.source).standardizedFileURL
} else {
path =
contextDir
.appendingPathComponent(packet.source)
.standardizedFileURL
}
let transfer = try path.buildTransfer(id: packet.id, contextDir: self.contextDir, complete: true)
var response = ClientStream()
response.buildID = buildID
response.buildTransfer = transfer
response.packetType = .buildTransfer(transfer)
sender.yield(response)
}
private struct DirEntry: Hashable {
let url: URL
let isDirectory: Bool
let relativePath: String
func hash(into hasher: inout Hasher) {
hasher.combine(relativePath)
}
static func == (lhs: DirEntry, rhs: DirEntry) -> Bool {
lhs.relativePath == rhs.relativePath
}
}
func walk(
_ sender: AsyncStream<ClientStream>.Continuation,
_ packet: BuildTransfer,
_ buildID: String
) async throws {
let wantsTar = packet.mode() == "tar"
var entries: [String: Set<DirEntry>] = [:]
let followPaths: [String] = packet.followPaths() ?? []
let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
for url in followPathsWalked {
guard self.contextDir.absoluteURL.cleanPath != url.absoluteURL.cleanPath else {
continue
}
guard self.contextDir.parentOf(url) else {
continue
}
let relPath = try url.relativeChildPath(to: contextDir)
let parentPath = try url.deletingLastPathComponent().relativeChildPath(to: contextDir)
let entry = DirEntry(url: url, isDirectory: url.hasDirectoryPath, relativePath: relPath)
entries[parentPath, default: []].insert(entry)
if url.isSymlink {
let target: URL = url.resolvingSymlinksInPath()
if self.contextDir.parentOf(target) {
let relPath = try target.relativeChildPath(to: self.contextDir)
let entry = DirEntry(url: target, isDirectory: target.hasDirectoryPath, relativePath: relPath)
let parentPath: String = try target.deletingLastPathComponent().relativeChildPath(to: self.contextDir)
entries[parentPath, default: []].insert(entry)
}
}
}
var fileOrder = [String]()
try processDirectory("", inputEntries: entries, processedPaths: &fileOrder)
if !wantsTar {
let fileInfos = try fileOrder.map { rel -> FileInfo in
try FileInfo(path: contextDir.appendingPathComponent(rel), contextDir: contextDir)
}
let data = try JSONEncoder().encode(fileInfos)
let transfer = BuildTransfer(
id: packet.id,
source: packet.source,
complete: true,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "json",
],
data: data
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = transfer
resp.packetType = .buildTransfer(transfer)
sender.yield(resp)
return
}
let tarURL = URL.temporaryDirectory
.appendingPathComponent(UUID().uuidString + ".tar")
defer { try? FileManager.default.removeItem(at: tarURL) }
let writerCfg = ArchiveWriterConfiguration(
format: .paxRestricted,
filter: .none)
let tarHash = try Archiver.compress(
source: contextDir,
destination: tarURL,
writerConfiguration: writerCfg
) { url in
guard let rel = try? url.relativeChildPath(to: contextDir) else {
return nil
}
guard let parent = try? url.deletingLastPathComponent().relativeChildPath(to: self.contextDir) else {
return nil
}
guard let items = entries[parent] else {
return nil
}
let include = items.contains { item in
item.relativePath == rel
}
guard include else {
return nil
}
return Archiver.ArchiveEntryInfo(
pathOnHost: url,
pathInArchive: URL(fileURLWithPath: rel))
}
let hash = tarHash.compactMap { String(format: "%02x", $0) }.joined()
let header = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: false,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
"hash": hash,
]
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = header
resp.packetType = .buildTransfer(header)
sender.yield(resp)
for try await chunk in try tarURL.bufferedCopyReader() {
let part = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: false,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
],
data: chunk
)
var resp = ClientStream()
resp.buildID = buildID
resp.buildTransfer = part
resp.packetType = .buildTransfer(part)
sender.yield(resp)
}
let done = BuildTransfer(
id: packet.id,
source: tarURL.path,
complete: true,
isDir: false,
metadata: [
"os": "linux",
"stage": "fssync",
"mode": "tar",
],
data: Data()
)
var finalResp = ClientStream()
finalResp.buildID = buildID
finalResp.buildTransfer = done
finalResp.packetType = .buildTransfer(done)
sender.yield(finalResp)
}
func walk(root: URL, includePatterns: [String]) throws -> [URL] {
let globber = Globber(root)
for p in includePatterns {
try globber.match(p)
}
return Array(globber.results)
}
private func processDirectory(
_ currentDir: String,
inputEntries: [String: Set<DirEntry>],
processedPaths: inout [String]
) throws {
guard let entries = inputEntries[currentDir] else {
return
}
// Sort purely by lexicographical order of relativePath
let sortedEntries = entries.sorted { $0.relativePath < $1.relativePath }
for entry in sortedEntries {
processedPaths.append(entry.relativePath)
if entry.isDirectory {
try processDirectory(
entry.relativePath,
inputEntries: inputEntries,
processedPaths: &processedPaths
)
}
}
}
struct FileInfo: Codable {
let name: String
let modTime: String
let mode: UInt32
let size: UInt64
let isDir: Bool
let uid: UInt32
let gid: UInt32
let target: String
init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
self.name = try path.relativeChildPath(to: contextDir)
self.modTime = try path.modTime()
self.mode = try path.mode()
self.size = try path.size()
self.isDir = path.hasDirectoryPath
self.uid = 0
self.gid = 0
}
}
enum FSSyncMethod: String {
case read = "Read"
case info = "Info"
case walk = "Walk"
init(_ method: String) throws {
switch method {
case "Read":
self = .read
case "Info":
self = .info
case "Walk":
self = .walk
default:
throw Error.unknownMethod(method)
}
}
}
}
extension BuildFSSync {
enum Error: Swift.Error, CustomStringConvertible, Equatable {
case buildTransferMissing
case methodMissing
case unknownMethod(String)
case contextNotFound(String)
case contextIsNotDirectory(String)
case couldNotDetermineFileSize(String)
case couldNotDetermineModTime(String)
case couldNotDetermineFileMode(String)
case invalidOffsetSizeForFile(String, UInt64, Int)
case couldNotDetermineUID(String)
case couldNotDetermineGID(String)
case pathIsNotChild(String, String)
var description: String {
switch self {
case .buildTransferMissing:
return "buildTransfer field missing in packet"
case .methodMissing:
return "method is missing in request"
case .unknownMethod(let m):
return "unknown content-store method \(m)"
case .contextNotFound(let path):
return "context dir \(path) not found"
case .contextIsNotDirectory(let path):
return "context \(path) not a directory"
case .couldNotDetermineFileSize(let path):
return "could not determine size of file \(path)"
case .couldNotDetermineModTime(let path):
return "could not determine last modified time of \(path)"
case .couldNotDetermineFileMode(let path):
return "could not determine posix permissions (FileMode) of \(path)"
case .invalidOffsetSizeForFile(let digest, let offset, let size):
return "invalid request for file: \(digest) with offset: \(offset) size: \(size)"
case .couldNotDetermineUID(let path):
return "could not determine UID of file at path: \(path)"
case .couldNotDetermineGID(let path):
return "could not determine GID of file at path: \(path)"
case .pathIsNotChild(let path, let parent):
return "\(path) is not a child of \(parent)"
}
}
}
}
extension BuildTransfer {
fileprivate init(id: String, source: String, complete: Bool, isDir: Bool, metadata: [String: String], data: Data? = nil) {
self.init()
self.id = id
self.source = source
self.direction = .outof
self.complete = complete
self.metadata = metadata
self.isDirectory = isDir
if let data {
self.data = data
}
}
}
extension URL {
fileprivate func size() throws -> UInt64 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let size = attrs[FileAttributeKey.size] as? UInt64 {
return size
}
throw BuildFSSync.Error.couldNotDetermineFileSize(self.cleanPath)
}
fileprivate func modTime() throws -> String {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let date = attrs[FileAttributeKey.modificationDate] as? Date {
return date.rfc3339()
}
throw BuildFSSync.Error.couldNotDetermineModTime(self.cleanPath)
}
fileprivate func isDir() throws -> Bool {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
guard let t = attrs[.type] as? FileAttributeType, t == .typeDirectory else {
return false
}
return true
}
fileprivate func mode() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let mode = attrs[FileAttributeKey.posixPermissions] as? NSNumber {
return mode.uint32Value
}
throw BuildFSSync.Error.couldNotDetermineFileMode(self.cleanPath)
}
fileprivate func uid() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let uid = attrs[.ownerAccountID] as? UInt32 {
return uid
}
throw BuildFSSync.Error.couldNotDetermineUID(self.cleanPath)
}
fileprivate func gid() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let gid = attrs[.groupOwnerAccountID] as? UInt32 {
return gid
}
throw BuildFSSync.Error.couldNotDetermineGID(self.cleanPath)
}
fileprivate func buildTransfer(
id: String,
contextDir: URL? = nil,
complete: Bool = false,
data: Data = Data()
) throws -> BuildTransfer {
let p = try {
if let contextDir { return try self.relativeChildPath(to: contextDir) }
return self.cleanPath
}()
return BuildTransfer(
id: id,
source: String(p),
complete: complete,
isDir: try self.isDir(),
metadata: [
"os": "linux",
"stage": "fssync",
"mode": String(try self.mode()),
"size": String(try self.size()),
"modified_at": try self.modTime(),
"uid": String(try self.uid()),
"gid": String(try self.gid()),
],
data: data
)
}
}
extension Date {
fileprivate func rfc3339() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // Adjust if necessary
return dateFormatter.string(from: self)
}
}
extension String {
var cleanPathComponent: String {
let trimmed = self.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if let clean = trimmed.removingPercentEncoding {
return clean
}
return trimmed
}
}
+46
View File
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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
public struct BuildFile {
/// Tries to resolve either a Dockerfile or Containerfile relative to contextDir.
/// Checks for Dockerfile, then falls back to Containerfile.
public static func resolvePath(contextDir: String, log: Logger? = nil) throws -> String? {
// Check for Dockerfile then Containerfile in context directory
let dockerfilePath = URL(filePath: contextDir).appendingPathComponent("Dockerfile").path
let containerfilePath = URL(filePath: contextDir).appendingPathComponent("Containerfile").path
let dockerfileExists = FileManager.default.fileExists(atPath: dockerfilePath)
let containerfileExists = FileManager.default.fileExists(atPath: containerfilePath)
if dockerfileExists && containerfileExists {
log?.info("Detected both Dockerfile and Containerfile, choosing Dockerfile")
return dockerfilePath
}
if dockerfileExists {
return dockerfilePath
}
if containerfileExists {
return containerfilePath
}
return nil
}
}
@@ -0,0 +1,178 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 ContainerAPIClient
import ContainerPersistence
import Containerization
import ContainerizationOCI
import Foundation
import GRPCCore
import Logging
import TerminalProgress
struct BuildImageResolver: BuildPipelineHandler {
let contentStore: ContentStore
let quiet: Bool
let output: FileHandle
let pull: Bool
let containerSystemConfig: ContainerSystemConfig
public init(_ contentStore: ContentStore, quiet: Bool = false, output: FileHandle = FileHandle.standardError, pull: Bool = false, containerSystemConfig: ContainerSystemConfig)
throws
{
self.contentStore = contentStore
self.quiet = quiet
self.output = output
self.pull = pull
self.containerSystemConfig = containerSystemConfig
}
func accept(_ packet: ServerStream) throws -> Bool {
guard let imageTransfer = packet.getImageTransfer() else {
return false
}
guard imageTransfer.stage() == "resolver" else {
return false
}
guard imageTransfer.method() == "/resolve" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let imageTransfer = packet.getImageTransfer() else {
throw Error.imageTransferMissing
}
guard let ref = imageTransfer.ref() else {
throw Error.tagMissing
}
guard let platform = try imageTransfer.platform() else {
throw Error.platformMissing
}
let img = try await {
let progressConfig = try ProgressConfig(
terminal: self.output,
description: "Pulling \(ref)",
showPercent: true,
showProgressBar: true,
showSize: true,
showSpeed: true,
disableProgressUpdates: self.quiet
)
let progress = ProgressBar(config: progressConfig)
defer { progress.finish() }
progress.start()
if self.pull {
return try await ClientImage.pull(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
}
// Use fetch() which checks cache first, then pulls if needed
return try await ClientImage.fetch(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
}()
let index: Index = try await img.index()
let buildID = packet.buildID
let platforms = index.manifests.compactMap { $0.platform }
for pl in platforms {
if pl == platform {
let manifest = try await img.manifest(for: pl)
guard let ociImage: ContainerizationOCI.Image = try await self.contentStore.get(digest: manifest.config.digest) else {
continue
}
let enc = JSONEncoder()
let data = try enc.encode(ociImage)
let transfer = try ImageTransfer(
id: imageTransfer.id,
digest: img.descriptor.digest,
ref: ref,
platform: platform.description,
data: data
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
return
}
}
throw Error.unknownPlatformForImage(platform.description, ref)
}
}
extension ImageTransfer {
fileprivate init(id: String, digest: String, ref: String, platform: String, data: Data) throws {
self.init()
self.id = id
self.tag = digest
self.metadata = [
"os": "linux",
"stage": "resolver",
"method": "/resolve",
"ref": ref,
"platform": platform,
]
self.complete = true
self.direction = .into
self.data = data
}
}
extension BuildImageResolver {
enum Error: Swift.Error, CustomStringConvertible {
case imageTransferMissing
case tagMissing
case platformMissing
case imageNameMissing
case imageTagMissing
case imageNotFound
case indexDigestMissing(String)
case unknownRegistry(String)
case digestIsNotIndex(String)
case digestIsNotManifest(String)
case unknownPlatformForImage(String, String)
var description: String {
switch self {
case .imageTransferMissing:
return "imageTransfer is missing"
case .tagMissing:
return "tag parameter missing in metadata"
case .platformMissing:
return "platform parameter missing in metadata"
case .imageNameMissing:
return "image name missing in $ref parameter"
case .imageTagMissing:
return "image tag missing in $ref parameter"
case .imageNotFound:
return "image not found"
case .indexDigestMissing(let ref):
return "index digest is missing for image: \(ref)"
case .unknownRegistry(let registry):
return "registry \(registry) is unknown"
case .digestIsNotIndex(let digest):
return "digest \(digest) is not a descriptor to an index"
case .digestIsNotManifest(let digest):
return "digest \(digest) is not a descriptor to a manifest"
case .unknownPlatformForImage(let platform, let ref):
return "platform \(platform) for image \(ref) not found"
}
}
}
}
@@ -0,0 +1,198 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 GRPCCore
import NIO
protocol BuildPipelineHandler: Sendable {
func accept(_ packet: ServerStream) throws -> Bool
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws
}
public actor BuildPipeline {
let handlers: [BuildPipelineHandler]
public init(_ config: Builder.BuildConfig) async throws {
self.handlers =
[
try BuildFSSync(URL(filePath: config.contextDir)),
try BuildRemoteContentProxy(config.contentStore),
try BuildImageResolver(
config.contentStore,
quiet: config.quiet,
output: config.terminal?.handle ?? FileHandle.standardError,
pull: config.pull,
containerSystemConfig: config.containerSystemConfig
),
try BuildStdio(quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError),
]
}
public func run<S: AsyncSequence & Sendable>(
sender: AsyncStream<ClientStream>.Continuation,
receiver: S
) async throws where S.Element == ServerStream {
defer { sender.finish() }
try await untilFirstError { group in
for try await packet in receiver {
try Task.checkCancellation()
for handler in self.handlers {
try Task.checkCancellation()
guard try handler.accept(packet) else {
continue
}
try Task.checkCancellation()
try await handler.handle(sender, packet)
break
}
}
}
}
/// untilFirstError() throws when any one of its submitted tasks fail.
/// This is useful for asynchronous packet processing scenarios which
/// have the following 3 requirements:
/// - the packet should be processed without blocking I/O
/// - the packet stream is never-ending
/// - when the first task fails, the error needs to be propagated to the caller
///
/// Usage:
///
/// ```
/// try await untilFirstError { group in
/// for try await packet in receiver {
/// group.addTask {
/// try await handler.handle(sender, packet)
/// }
/// }
/// }
/// ```
///
///
/// WithThrowingTaskGroup cannot accomplish this because it
/// doesn't provide a mechanism to exit when one of the tasks fail
/// before all the tasks have been added. i.e. it is more suitable for
/// tasks that are limited. Here's a sample code where withThrowingTaskGroup
/// doesn't solve the problem:
///
/// ```
/// withThrowingTaskGroup { group in
/// for try await packet in receiver {
/// group.addTask {
/// /* process packet */
/// }
/// } /* this loop blocks forever waiting for more packets */
/// try await group.next() /* this never gets called */
/// }
/// ```
/// The above closure never returns even when a handler encounters an error
/// because the blocking operation `try await group.next()` cannot be
/// called while iterating over the receiver stream.
private func untilFirstError(body: @Sendable @escaping (UntilFirstError) async throws -> Void) async throws {
let group = try await UntilFirstError()
var taskContinuation: AsyncStream<Task<(), Error>>.Continuation?
let tasks = AsyncStream<Task<(), Error>> { continuation in
taskContinuation = continuation
}
guard let taskContinuation else {
throw NSError(
domain: "untilFirstError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "failed to initialize task continuation"])
}
defer { taskContinuation.finish() }
let stream = AsyncStream<Error> { continuation in
let processTasks = Task {
let taskStream = await group.tasks()
defer {
continuation.finish()
}
for await item in taskStream {
try Task.checkCancellation()
let addedTask = Task {
try Task.checkCancellation()
do {
try await item()
} catch {
continuation.yield(error)
await group.continuation?.finish()
throw error
}
}
taskContinuation.yield(addedTask)
}
}
taskContinuation.yield(processTasks)
let mainTask = Task { @Sendable in
defer {
continuation.finish()
processTasks.cancel()
taskContinuation.finish()
}
do {
try Task.checkCancellation()
try await body(group)
} catch {
continuation.yield(error)
await group.continuation?.finish()
throw error
}
}
taskContinuation.yield(mainTask)
}
// when the first handler fails, cancel all tasks and throw error
for await item in stream {
try Task.checkCancellation()
Task {
for await task in tasks {
task.cancel()
}
}
throw item
}
// if none of the handlers fail, wait for all subtasks to complete
for await task in tasks {
try Task.checkCancellation()
try await task.value
}
}
private actor UntilFirstError {
var stream: AsyncStream<@Sendable () async throws -> Void>?
var continuation: AsyncStream<@Sendable () async throws -> Void>.Continuation?
init() async throws {
self.stream = AsyncStream { cont in
self.continuation = cont
}
guard let _ = continuation else {
throw NSError()
}
}
func addTask(body: @Sendable @escaping () async throws -> Void) {
if !Task.isCancelled {
self.continuation?.yield(body)
}
}
func tasks() -> AsyncStream<@Sendable () async throws -> Void> {
self.stream!
}
}
}
@@ -0,0 +1,188 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 ContainerAPIClient
import Containerization
import ContainerizationArchive
import ContainerizationOCI
import Foundation
import GRPCCore
struct BuildRemoteContentProxy: BuildPipelineHandler {
let local: ContentStore
public init(_ contentStore: ContentStore) throws {
self.local = contentStore
}
func accept(_ packet: ServerStream) throws -> Bool {
guard let imageTransfer = packet.getImageTransfer() else {
return false
}
guard imageTransfer.stage() == "content-store" else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let imageTransfer = packet.getImageTransfer() else {
throw Error.imageTransferMissing
}
guard let method = imageTransfer.method() else {
throw Error.methodMissing
}
switch try ContentStoreMethod(method) {
case .info:
try await self.info(sender, imageTransfer, packet.buildID)
case .readerAt:
try await self.readerAt(sender, imageTransfer, packet.buildID)
default:
throw Error.unknownMethod(method)
}
}
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
let descriptor = try await local.get(digest: packet.tag)
let size = try descriptor?.size()
let transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.info.rawValue,
size: size
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
}
func readerAt(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
let digest = packet.descriptor.digest
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
guard let descriptor = try await local.get(digest: digest) else {
throw Error.contentMissing
}
if offset == 0 && size == 0 { // Metadata request
var transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.readerAt.rawValue,
size: descriptor.size(),
data: Data()
)
transfer.complete = true
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
return
}
guard let data = try descriptor.data(offset: offset, length: size) else {
throw Error.invalidOffsetSizeForContent(packet.descriptor.digest, offset, size)
}
let transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.readerAt.rawValue,
size: UInt64(data.count),
data: data
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
}
func delete(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.delete)"])
}
func update(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.update)"])
}
func walk(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.walk)"])
}
enum ContentStoreMethod: String {
case info = "/containerd.services.content.v1.Content/Info"
case readerAt = "/containerd.services.content.v1.Content/ReaderAt"
case delete = "/containerd.services.content.v1.Content/Delete"
case update = "/containerd.services.content.v1.Content/Update"
case walk = "/containerd.services.content.v1.Content/Walk"
init(_ method: String) throws {
guard let value = ContentStoreMethod(rawValue: method) else {
throw Error.unknownMethod(method)
}
self = value
}
}
}
extension ImageTransfer {
fileprivate init(id: String, digest: String, method: String, size: UInt64? = nil, data: Data = Data()) throws {
self.init()
self.id = id
self.tag = digest
self.metadata = [
"os": "linux",
"stage": "content-store",
"method": method,
]
if let size {
self.metadata["size"] = String(size)
}
self.complete = true
self.direction = .into
self.data = data
}
}
extension BuildRemoteContentProxy {
enum Error: Swift.Error, CustomStringConvertible {
case imageTransferMissing
case methodMissing
case contentMissing
case unknownMethod(String)
case invalidOffsetSizeForContent(String, UInt64, Int)
var description: String {
switch self {
case .imageTransferMissing:
return "imageTransfer is missing"
case .methodMissing:
return "method is missing in request"
case .contentMissing:
return "content cannot be found"
case .unknownMethod(let m):
return "unknown content-store method \(m)"
case .invalidOffsetSizeForContent(let digest, let offset, let size):
return "invalid request for content: \(digest) with offset: \(offset) size: \(size)"
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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
import GRPCCore
import NIO
actor BuildStdio: BuildPipelineHandler {
public let quiet: Bool
public let handle: FileHandle
init(quiet: Bool = false, output: FileHandle = FileHandle.standardError) throws {
self.quiet = quiet
self.handle = output
}
nonisolated func accept(_ packet: ServerStream) throws -> Bool {
guard let _ = packet.getIO() else {
return false
}
return true
}
func handle(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard !quiet else {
return
}
guard let io = packet.getIO() else {
throw Error.ioMissing
}
if let cmdString = try TerminalCommand().json() {
var response = ClientStream()
response.buildID = packet.buildID
response.command = .init()
response.command.id = packet.buildID
response.command.command = cmdString
sender.yield(response)
}
handle.write(io.data)
}
}
extension BuildStdio {
enum Error: Swift.Error, CustomStringConvertible {
case ioMissing
case invalidContinuation
var description: String {
switch self {
case .ioMissing:
return "io field missing in packet"
case .invalidContinuation:
return "continuation could not created"
}
}
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+433
View File
@@ -0,0 +1,433 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 ContainerAPIClient
import ContainerPersistence
import Containerization
import ContainerizationOCI
import ContainerizationOS
import Foundation
import GRPCCore
import GRPCNIOTransportHTTP2
import Logging
import NIO
import NIOPosix
public struct Builder: Sendable {
public static let builderContainerId = "buildkit"
let client: Com_Apple_Container_Build_V1_Builder.Client<HTTP2ClientTransport.WrappedChannel>
let grpcClient: GRPCClient<HTTP2ClientTransport.WrappedChannel>
let group: EventLoopGroup
let builderShimSocket: FileHandle
let clientTask: Task<Void, any Swift.Error>
let logger: Logger
public init(socket: FileHandle, group: EventLoopGroup, logger: Logger) async throws {
try socket.setSendBufSize(4 << 20)
try socket.setRecvBufSize(2 << 20)
let transport = try await HTTP2ClientTransport.WrappedChannel.wrapping(
config: .defaults,
serviceConfig: .init()
) { configure in
try await withCheckedThrowingContinuation { continuation in
ClientBootstrap(group: group)
.channelInitializer { channel in
configure(channel).map { configured in
continuation.resume(returning: configured)
}
}
.withConnectedSocket(socket.fileDescriptor)
.whenFailure { error in
continuation.resume(throwing: error)
}
}
}
let grpcClient = GRPCClient(transport: transport)
self.grpcClient = grpcClient
self.client = Com_Apple_Container_Build_V1_Builder.Client(wrapping: grpcClient)
self.group = group
self.builderShimSocket = socket
self.logger = logger
// Start the client connection loop in a background task
self.clientTask = Task {
do {
try await grpcClient.runConnections()
} catch is CancellationError {
// Expected during graceful shutdown - re-throw
throw CancellationError()
} catch let error as RPCError where error.code == .unavailable {
// Connection closed - this is expected when the container stops
logger.debug("gRPC connection closed: \(error)")
throw error
} catch {
// Log unexpected connection errors
logger.error("gRPC client connection error: \(error)")
throw error
}
}
}
public func info() async throws -> InfoResponse {
var opts = CallOptions.defaults
opts.timeout = .seconds(30)
return try await self.client.info(InfoRequest(), options: opts)
}
// TODO
// - Symlinks in build context dir
// - cache-to, cache-from
// - output (other than the default OCI image output, e.g., local, tar, Docker)
public func build(_ config: BuildConfig) async throws {
var continuation: AsyncStream<ClientStream>.Continuation?
let reqStream = AsyncStream<ClientStream> { (cont: AsyncStream<ClientStream>.Continuation) in
continuation = cont
}
guard let continuation else {
throw Error.invalidContinuation
}
defer {
continuation.finish()
}
if let terminal = config.terminal {
Task {
let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH])
let setWinch = { (rows: UInt16, cols: UInt16) in
var winch = ClientStream()
winch.command = .init()
if let cmdString = try TerminalCommand(rows: rows, cols: cols).json() {
winch.command.command = cmdString
continuation.yield(winch)
}
}
let size = try terminal.size
var width = size.width
var height = size.height
try setWinch(height, width)
for await _ in winchHandler.signals {
let size = try terminal.size
let cols = size.width
let rows = size.height
if cols != width || rows != height {
width = cols
height = rows
try setWinch(height, width)
}
}
}
}
let pipeline = try await BuildPipeline(config)
do {
try await self.client.performBuild(
metadata: try Self.buildMetadata(config),
options: .defaults,
requestProducer: { writer in
for await message in reqStream {
try await writer.write(message)
}
},
onResponse: { response in
try await pipeline.run(sender: continuation, receiver: response.messages)
}
)
} catch Error.buildComplete {
self.grpcClient.beginGracefulShutdown()
self.clientTask.cancel()
try await group.shutdownGracefully()
return
}
}
public struct BuildExport: Sendable {
public let type: String
public var destination: URL?
public let additionalFields: [String: String]
public let rawValue: String
public init(type: String, destination: URL?, additionalFields: [String: String], rawValue: String) {
self.type = type
self.destination = destination
self.additionalFields = additionalFields
self.rawValue = rawValue
}
public init(from input: String) throws {
var typeValue: String?
var destinationValue: URL?
var additionalFields: [String: String] = [:]
let pairs = input.components(separatedBy: ",")
for pair in pairs {
let parts = pair.components(separatedBy: "=")
guard parts.count == 2 else { continue }
let key = parts[0].trimmingCharacters(in: .whitespaces)
let value = parts[1].trimmingCharacters(in: .whitespaces)
switch key {
case "type":
typeValue = value
case "dest":
destinationValue = try Self.resolveDestination(dest: value)
default:
additionalFields[key] = value
}
}
guard let type = typeValue else {
throw Builder.Error.invalidExport(input, "type field is required")
}
switch type {
case "oci":
break
case "tar":
if destinationValue == nil {
throw Builder.Error.invalidExport(input, "dest field is required")
}
case "local":
if destinationValue == nil {
throw Builder.Error.invalidExport(input, "dest field is required")
}
default:
throw Builder.Error.invalidExport(input, "unsupported output type")
}
self.init(type: type, destination: destinationValue, additionalFields: additionalFields, rawValue: input)
}
public var stringValue: String {
get throws {
var components = ["type=\(type)"]
switch type {
case "oci", "tar", "local":
break // ignore destination
default:
throw Builder.Error.invalidExport(rawValue, "unsupported output type")
}
for (key, value) in additionalFields {
components.append("\(key)=\(value)")
}
return components.joined(separator: ",")
}
}
static func resolveDestination(dest: String) throws -> URL {
let destination = URL(fileURLWithPath: dest)
let fileManager = FileManager.default
if fileManager.fileExists(atPath: destination.path) {
let resourceValues = try destination.resourceValues(forKeys: [.isDirectoryKey])
let isDir = resourceValues.isDirectory
if isDir != nil && isDir == false {
throw Builder.Error.invalidExport(dest, "dest path already exists")
}
var finalDestination = destination.appendingPathComponent("out.tar")
var index = 1
while fileManager.fileExists(atPath: finalDestination.path) {
let path = "out.tar.\(index)"
finalDestination = destination.appendingPathComponent(path)
index += 1
}
return finalDestination
} else {
let parentDirectory = destination.deletingLastPathComponent()
try? fileManager.createDirectory(at: parentDirectory, withIntermediateDirectories: true, attributes: nil)
}
return destination
}
}
public struct BuildConfig: Sendable {
public let buildID: String
public let contentStore: ContentStore
public let buildArgs: [String]
public let secrets: [String: Data]
public let contextDir: String
public let dockerfile: Data
public let dockerignore: Data?
public let labels: [String]
public let noCache: Bool
public let platforms: [Platform]
public let terminal: Terminal?
public let tags: [String]
public let target: String
public let quiet: Bool
public let exports: [BuildExport]
public let cacheIn: [String]
public let cacheOut: [String]
public let pull: Bool
public let containerSystemConfig: ContainerSystemConfig
public init(
buildID: String,
contentStore: ContentStore,
buildArgs: [String],
secrets: [String: Data],
contextDir: String,
dockerfile: Data,
dockerignore: Data?,
labels: [String],
noCache: Bool,
platforms: [Platform],
terminal: Terminal?,
tags: [String],
target: String,
quiet: Bool,
exports: [BuildExport],
cacheIn: [String],
cacheOut: [String],
pull: Bool,
containerSystemConfig: ContainerSystemConfig
) {
self.buildID = buildID
self.contentStore = contentStore
self.buildArgs = buildArgs
self.secrets = secrets
self.contextDir = contextDir
self.dockerfile = dockerfile
self.dockerignore = dockerignore
self.labels = labels
self.noCache = noCache
self.platforms = platforms
self.terminal = terminal
self.tags = tags
self.target = target
self.quiet = quiet
self.exports = exports
self.cacheIn = cacheIn
self.cacheOut = cacheOut
self.pull = pull
self.containerSystemConfig = containerSystemConfig
}
}
static func buildMetadata(_ config: BuildConfig) throws -> Metadata {
var metadata = Metadata()
metadata.addString(config.buildID, forKey: "build-id")
metadata.addString(URL(filePath: config.contextDir).path(percentEncoded: false), forKey: "context")
metadata.addString(config.dockerfile.base64EncodedString(), forKey: "dockerfile")
metadata.addString(config.terminal != nil ? "tty" : "plain", forKey: "progress")
metadata.addString(config.target, forKey: "target")
if let dockerignore = config.dockerignore {
metadata.addString(dockerignore.base64EncodedString(), forKey: "dockerignore")
}
for tag in config.tags {
metadata.addString(tag, forKey: "tag")
}
for platform in config.platforms {
metadata.addString(platform.description, forKey: "platforms")
}
if config.noCache {
metadata.addString("", forKey: "no-cache")
}
for label in config.labels {
metadata.addString(label, forKey: "labels")
}
for buildArg in config.buildArgs {
metadata.addString(buildArg, forKey: "build-args")
}
for (id, data) in config.secrets {
metadata.addString(id + "=" + data.base64EncodedString(), forKey: "secrets")
}
for output in config.exports {
metadata.addString(try output.stringValue, forKey: "outputs")
}
for cacheIn in config.cacheIn {
metadata.addString(cacheIn, forKey: "cache-in")
}
for cacheOut in config.cacheOut {
metadata.addString(cacheOut, forKey: "cache-out")
}
return metadata
}
}
extension Builder {
enum Error: Swift.Error, CustomStringConvertible {
case invalidContinuation
case buildComplete
case invalidExport(String, String)
var description: String {
switch self {
case .invalidContinuation:
return "continuation could not created"
case .buildComplete:
return "build completed"
case .invalidExport(let exp, let reason):
return "export entry \(exp) is invalid: \(reason)"
}
}
}
}
extension FileHandle {
@discardableResult
func setSendBufSize(_ bytes: Int) throws -> Int {
try setSockOpt(
level: SOL_SOCKET,
name: SO_SNDBUF,
value: bytes)
return bytes
}
@discardableResult
func setRecvBufSize(_ bytes: Int) throws -> Int {
try setSockOpt(
level: SOL_SOCKET,
name: SO_RCVBUF,
value: bytes)
return bytes
}
private func setSockOpt(level: Int32, name: Int32, value: Int) throws {
var v = Int32(value)
let res = withUnsafePointer(to: &v) { ptr -> Int32 in
ptr.withMemoryRebound(
to: UInt8.self,
capacity: MemoryLayout<Int32>.size
) { raw in
#if canImport(Darwin)
return setsockopt(
self.fileDescriptor,
level, name,
raw,
socklen_t(MemoryLayout<Int32>.size))
#else
fatalError("unsupported platform")
#endif
}
}
if res == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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
public class Globber {
let input: URL
var results: Set<URL> = .init()
public init(_ input: URL) {
self.input = input
}
public func match(_ pattern: String) throws {
let adjustedPattern =
pattern
.replacingOccurrences(of: #"^\./(?=.)"#, with: "", options: .regularExpression)
.replacingOccurrences(of: "^\\.[/]?$", with: "*", options: .regularExpression)
.replacingOccurrences(of: "\\*{2,}[/]", with: "*/**/", options: .regularExpression)
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)
for child in input.children {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
private func match(input: URL, components: [String]) throws {
if components.isEmpty {
var dir = input.standardizedFileURL
while dir != self.input.standardizedFileURL {
results.insert(dir)
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
}
let head = components.first ?? ""
let tail = components.tail
if head == "**" {
var tail: [String] = tail
while tail.first == "**" {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
try self.match(input: child, components: components)
}
return
}
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)
for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}
func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
+ NSRegularExpression.escapedPattern(for: pattern)
.replacingOccurrences(of: "\\*", with: "[^/]*")
.replacingOccurrences(of: "\\?", with: "[^/]")
.replacingOccurrences(of: "[\\^", with: "[^")
.replacingOccurrences(of: "\\[", with: "[")
.replacingOccurrences(of: "\\]", with: "]") + "$"
// validate the regex pattern created
let _ = try Regex(regexPattern)
return input.range(of: regexPattern, options: .regularExpression) != nil
}
}
extension URL {
var children: [URL] {
(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}
var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}
extension [String] {
var tail: [String] {
if self.count <= 1 {
return []
}
return Array(self.dropFirst())
}
}
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import Foundation
struct TerminalCommand: Codable {
let commandType: String
let code: String
let rows: UInt16
let cols: UInt16
enum CodingKeys: String, CodingKey {
case commandType = "command_type"
case code
case rows
case cols
}
init(rows: UInt16, cols: UInt16) {
self.commandType = "terminal"
self.code = "winch"
self.rows = rows
self.cols = cols
}
init() {
self.commandType = "terminal"
self.code = "ack"
self.rows = 0
self.cols = 0
}
func json() throws -> String? {
let encoder = JSONEncoder()
let data = try encoder.encode(self)
return data.base64EncodedString().trimmingCharacters(in: CharacterSet(charactersIn: "="))
}
}
+284
View File
@@ -0,0 +1,284 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 String {
fileprivate var fs_cleaned: String {
var value = self
if value.hasPrefix("file://") {
value.removeFirst("file://".count)
}
if value.count > 1 && value.last == "/" {
value.removeLast()
}
return value.removingPercentEncoding ?? value
}
fileprivate var fs_components: [String] {
var parts: [String] = []
for segment in self.split(separator: "/", omittingEmptySubsequences: true) {
switch segment {
case ".":
continue
case "..":
if !parts.isEmpty { parts.removeLast() }
default:
parts.append(String(segment))
}
}
return parts
}
fileprivate var fs_isAbsolute: Bool { first == "/" }
}
extension URL {
var cleanPath: String {
self.path.fs_cleaned
}
func parentOf(_ url: URL) -> Bool {
let parentPath = self.absoluteURL.cleanPath
let childPath = url.absoluteURL.cleanPath
guard parentPath.fs_isAbsolute else {
return true
}
let parentParts = parentPath.fs_components
let childParts = childPath.fs_components
guard parentParts.count <= childParts.count else { return false }
return zip(parentParts, childParts).allSatisfy { $0 == $1 }
}
func relativeChildPath(to context: URL) throws -> String {
guard context.parentOf(self) else {
throw BuildFSSync.Error.pathIsNotChild(cleanPath, context.cleanPath)
}
let ctxParts = context.cleanPath.fs_components
let selfParts = cleanPath.fs_components
return selfParts.dropFirst(ctxParts.count).joined(separator: "/")
}
func relativePathFrom(from base: URL) -> String {
let destParts = cleanPath.fs_components
let baseParts = base.cleanPath.fs_components
let common = zip(destParts, baseParts).prefix { $0 == $1 }.count
guard common > 0 else { return cleanPath }
let ups = Array(repeating: "..", count: baseParts.count - common)
let remainder = destParts.dropFirst(common)
return (ups + remainder).joined(separator: "/")
}
func zeroCopyReader(
chunk: Int = 1024 * 1024,
buffer: AsyncStream<Data>.Continuation.BufferingPolicy = .unbounded
) throws -> AsyncStream<Data> {
let path = self.cleanPath
let fd = open(path, O_RDONLY | O_NONBLOCK)
guard fd >= 0 else { throw POSIXError.fromErrno() }
let channel = DispatchIO(
type: .stream,
fileDescriptor: fd,
queue: .global(qos: .userInitiated)
) { errno in
close(fd)
}
channel.setLimit(highWater: chunk)
return AsyncStream(bufferingPolicy: buffer) { continuation in
channel.read(
offset: 0, length: Int.max,
queue: .global(qos: .userInitiated)
) { done, ddata, err in
if err != 0 {
continuation.finish()
return
}
if let ddata, ddata.count > -1 {
let data = Data(ddata)
switch continuation.yield(data) {
case .terminated:
channel.close(flags: .stop)
default: break
}
}
if done {
channel.close(flags: .stop)
continuation.finish()
}
}
}
}
func bufferedCopyReader(chunkSize: Int = 4 * 1024 * 1024) throws -> BufferedCopyReader {
try BufferedCopyReader(url: self, chunkSize: chunkSize)
}
}
/// A synchronous buffered reader that reads one chunk at a time from a file
/// Uses a configurable buffer size (default 4MB) and only reads when nextChunk() is called
/// Implements AsyncSequence for use with `for await` loops
public final class BufferedCopyReader: AsyncSequence {
public typealias Element = Data
public typealias AsyncIterator = BufferedCopyReaderIterator
private let inputStream: InputStream
private let chunkSize: Int
private var isFinished: Bool = false
private let reusableBuffer: UnsafeMutablePointer<UInt8>
/// Initialize a buffered copy reader for the given URL
/// - Parameters:
/// - url: The file URL to read from
/// - chunkSize: Size of each chunk to read (default: 4MB)
public init(url: URL, chunkSize: Int = 4 * 1024 * 1024) throws {
guard let stream = InputStream(url: url) else {
throw CocoaError(.fileReadNoSuchFile)
}
self.inputStream = stream
self.chunkSize = chunkSize
self.reusableBuffer = UnsafeMutablePointer<UInt8>.allocate(capacity: chunkSize)
self.inputStream.open()
}
deinit {
inputStream.close()
reusableBuffer.deallocate()
}
/// Create an async iterator for this sequence
public func makeAsyncIterator() -> BufferedCopyReaderIterator {
BufferedCopyReaderIterator(reader: self)
}
/// Read the next chunk of data from the file
/// - Returns: Data chunk, or nil if end of file reached
/// - Throws: Any file reading errors
public func nextChunk() throws -> Data? {
guard !isFinished else { return nil }
// Read directly into our reusable buffer
let bytesRead = inputStream.read(reusableBuffer, maxLength: chunkSize)
// Check for errors
if bytesRead < 0 {
if let error = inputStream.streamError {
throw error
}
throw CocoaError(.fileReadUnknown)
}
// If we read no data, we've reached the end
if bytesRead == 0 {
isFinished = true
return nil
}
// If we read less than the chunk size, this is the last chunk
if bytesRead < chunkSize {
isFinished = true
}
// Create Data object only with the bytes actually read
return Data(bytes: reusableBuffer, count: bytesRead)
}
/// Check if the reader has finished reading the file
public var hasFinished: Bool {
isFinished
}
/// Reset the reader to the beginning of the file
/// Note: InputStream doesn't support seeking, so this recreates the stream
/// - Throws: Any file opening errors
public func reset() throws {
inputStream.close()
// Note: InputStream doesn't provide a way to get the original URL,
// so reset functionality is limited. Consider removing this method
// or storing the original URL if reset is needed.
throw CocoaError(
.fileReadUnsupportedScheme,
userInfo: [
NSLocalizedDescriptionKey: "reset not supported with InputStream-based implementation"
])
}
/// Get the current file offset
/// Note: InputStream doesn't provide offset information
/// - Returns: Current position in the file
/// - Throws: Unsupported operation error
public func currentOffset() throws -> UInt64 {
throw CocoaError(
.fileReadUnsupportedScheme,
userInfo: [
NSLocalizedDescriptionKey: "offset tracking not supported with InputStream-based implementation"
])
}
/// Seek to a specific offset in the file
/// Note: InputStream doesn't support seeking
/// - Parameter offset: The byte offset to seek to
/// - Throws: Unsupported operation error
public func seek(to offset: UInt64) throws {
throw CocoaError(
.fileReadUnsupportedScheme,
userInfo: [
NSLocalizedDescriptionKey: "seeking not supported with InputStream-based implementation"
])
}
/// Close the input stream explicitly (called automatically in deinit)
public func close() {
inputStream.close()
isFinished = true
}
}
/// AsyncIteratorProtocol implementation for BufferedCopyReader
public struct BufferedCopyReaderIterator: AsyncIteratorProtocol {
public typealias Element = Data
private let reader: BufferedCopyReader
init(reader: BufferedCopyReader) {
self.reader = reader
}
/// Get the next chunk of data asynchronously
/// - Returns: Next data chunk, or nil when finished
/// - Throws: Any file reading errors
public mutating func next() async throws -> Data? {
// Yield control to allow other tasks to run, then read synchronously
await Task.yield()
return try reader.nextChunk()
}
}
@@ -0,0 +1,32 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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.
//===----------------------------------------------------------------------===//
/// An error type that aggregates multiple errors into one.
///
/// When displayed, each underlying error is printed on its own line.
public struct AggregateError: Swift.Error, Sendable {
public let errors: [any Error]
public init(_ errors: [any Error]) {
self.errors = errors
}
}
extension AggregateError: CustomStringConvertible {
public var description: String {
errors.map { String(describing: $0) }.joined(separator: "\n")
}
}
+287
View File
@@ -0,0 +1,287 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerLog
import ContainerPersistence
import ContainerPlugin
import ContainerVersion
import ContainerizationError
import ContainerizationOS
import Foundation
import Logging
import SystemPackage
import TerminalProgress
// This logger is only used until `asyncCommand.run()`.
// `log` is updated only once in the `validate()` method.
private nonisolated(unsafe) var bootstrapLogger = {
LoggingSystem.bootstrap({ _ in StderrLogHandler() })
var log = Logger(label: "com.apple.container")
log.logLevel = .info
return log
}()
public struct Application: AsyncLoggableCommand {
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public static let configuration = CommandConfiguration(
commandName: "container",
abstract: "A container platform for macOS",
version: ReleaseVersion.singleLine(appName: "container CLI"),
subcommands: [
DefaultCommand.self,
HelpCommand.self,
],
groupedSubcommands: [
CommandGroup(
name: "Container",
subcommands: [
ContainerCopy.self,
ContainerCreate.self,
ContainerDelete.self,
ContainerExec.self,
ContainerExport.self,
ContainerInspect.self,
ContainerKill.self,
ContainerList.self,
ContainerLogs.self,
ContainerRun.self,
ContainerStart.self,
ContainerStats.self,
ContainerStop.self,
ContainerPrune.self,
]
),
CommandGroup(
name: "Image",
subcommands: [
BuildCommand.self,
ImageCommand.self,
RegistryCommand.self,
]
),
CommandGroup(
name: "Machine",
subcommands: [
MachineCommand.self
]
),
CommandGroup(
name: "Volume",
subcommands: [
VolumeCommand.self
]
),
CommandGroup(
name: "Other",
subcommands: Self.otherCommands()
),
],
// Hidden command to handle plugins on unrecognized input.
defaultSubcommand: DefaultCommand.self
)
public static func main() async throws {
restoreCursorAtExit()
#if DEBUG
let warning = "Running debug build. Performance may be degraded."
let formattedWarning: String
if isatty(FileHandle.standardError.fileDescriptor) == 1 {
formattedWarning = "\u{001B}[33mWarning!\u{001B}[0m \(warning)\n"
} else {
formattedWarning = "Warning! \(warning)\n"
}
let warningData = Data(formattedWarning.utf8)
FileHandle.standardError.write(warningData)
#endif
let fullArgs = CommandLine.arguments
let args = Array(fullArgs.dropFirst())
do {
var command = try Application.parseAsRoot(args)
if var asyncCommand = command as? AsyncParsableCommand {
try await asyncCommand.run()
} else {
try command.run()
}
} catch {
// --help/-h on the root command (e.g. `container --help`) is intercepted
// by ArgumentParser and lands here.
let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help")
if fullArgs.count <= 2 && containsHelp {
let pluginLoader = try? await createPluginLoader()
await Self.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
let errorAsString: String = String(describing: error)
if errorAsString.contains("XPC connection error") {
let modifiedError = ContainerizationError(.interrupted, message: "\(error)\nEnsure container system service has been started with `container system start`.")
Application.exit(withError: modifiedError)
} else {
Application.exit(withError: error)
}
}
}
public static func createPluginLoader() async throws -> PluginLoader {
let installRootPath = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
// TODO: Remove when we convert PluginLoader to FilePath.
let installRootURL = URL(fileURLWithPath: installRootPath.string)
let pluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
var directoryExists: ObjCBool = false
_ = FileManager.default.fileExists(atPath: pluginsURL.path, isDirectory: &directoryExists)
let userPluginsURL = directoryExists.boolValue ? pluginsURL : nil
// plugins built into the application installed as a macOS app bundle
let appBundlePluginsURL = Bundle.main.resourceURL?.appending(path: "plugins")
// plugins built into the application installed as a Unix-like application
let installRootPluginsPath =
installRootPath
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let pluginDirectories = [
userPluginsURL,
appBundlePluginsURL,
installRootPluginsURL,
].compactMap { $0 }
let pluginFactories: [any PluginFactory] = [
DefaultPluginFactory(logger: bootstrapLogger),
AppBundlePluginFactory(logger: bootstrapLogger),
]
guard let systemHealth = try? await ClientHealthCheck.ping(timeout: .seconds(10)) else {
throw ContainerizationError(.timeout, message: "unable to retrieve application data root from API server")
}
return try PluginLoader(
appRoot: systemHealth.appRoot,
installRoot: systemHealth.installRoot,
logRoot: systemHealth.logRoot,
pluginDirectories: pluginDirectories,
pluginFactories: pluginFactories,
log: bootstrapLogger
)
}
/// Load the system configuration using `appRoot` / `installRoot` reported by the
/// daemon. `container system start` MUST have previously been run to start the daemon.
public static func loadContainerSystemConfig() async throws -> ContainerSystemConfig {
let health = try await ClientHealthCheck.ping(timeout: .seconds(10))
let appRoot = FilePath(health.appRoot.path(percentEncoded: false))
let installRoot = FilePath(health.installRoot.path(percentEncoded: false))
return try await ConfigurationLoader.load(
configurationFiles: [
ConfigurationLoader.configurationFile(in: appRoot, of: .appRoot),
ConfigurationLoader.configurationFile(in: installRoot, of: .installRoot),
]
)
}
public func validate() throws {
// Not really a "validation", but a cheat to run this before
// any of the commands do their business.
let debugEnvVar = ProcessInfo.processInfo.environment["CONTAINER_DEBUG"]
if self.logOptions.debug || debugEnvVar != nil {
bootstrapLogger.logLevel = .debug
}
// Ensure we're not running under Rosetta.
if try isTranslated() {
throw ValidationError(
"""
`container` is currently running under Rosetta Translation, which could be
caused by your terminal application. Please ensure this is turned off.
"""
)
}
}
private static func otherCommands() -> [any ParsableCommand.Type] {
guard #available(macOS 26, *) else {
return [
BuilderCommand.self,
SystemCommand.self,
]
}
return [
BuilderCommand.self,
NetworkCommand.self,
SystemCommand.self,
]
}
private static func restoreCursorAtExit() {
let signalHandler: @convention(c) (Int32) -> Void = { signal in
let exitCode = ExitCode(signal + 128)
Application.exit(withError: exitCode)
}
// Termination by Ctrl+C.
signal(SIGINT, signalHandler)
// Termination using `kill`.
signal(SIGTERM, signalHandler)
// Normal and explicit exit.
atexit {
if let progressConfig = try? ProgressConfig() {
let progressBar = ProgressBar(config: progressConfig)
progressBar.resetCursor()
}
}
}
}
extension Application {
// Because we support plugins, we need to modify the help text to display
// any if we found some.
static func printModifiedHelpText(pluginLoader: PluginLoader?) async {
let original = Application.helpMessage(for: Application.self)
guard let pluginLoader else {
print(addGroupSpacing(original))
print("\nPLUGINS: not available, run `container system start`")
return
}
let altered = pluginLoader.alterCLIHelpText(original: original)
print(addGroupSpacing(altered))
}
private static func addGroupSpacing(_ text: String) -> String {
text
.replacingOccurrences(of: "\n([A-Z].+SUBCOMMANDS:)", with: "\n\n$1", options: .regularExpression)
.replacingOccurrences(of: "\nPLUGINS:", with: "\n\nPLUGINS:")
}
func isTranslated() throws -> Bool {
do {
return try Sysctl.byName("sysctl.proc_translated") == 1
} catch let posixErr as POSIXError {
if posixErr.code == .ENOENT {
return false
}
throw posixErr
}
}
}
@@ -0,0 +1,35 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerLog
import Logging
public protocol AsyncLoggableCommand: AsyncParsableCommand {
var logOptions: Flags.Logging { get }
}
extension AsyncLoggableCommand {
/// A shared logger instance configured based on the command's options
public var log: Logger {
var logger = Logger(label: "container", factory: { _ in StderrLogHandler() })
logger.logLevel = logOptions.debug ? .debug : .info
return logger
}
}
@@ -0,0 +1,515 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerBuild
import ContainerImagesServiceClient
import ContainerPersistence
import ContainerPlugin
import Containerization
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import NIO
import TerminalProgress
extension Application {
public struct BuildCommand: AsyncLoggableCommand {
public init() {}
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "build"
config.abstract = "Build an image from a Dockerfile or Containerfile"
config._superCommandName = "container"
config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help"))
return config
}
enum ProgressType: String, ExpressibleByArgument {
case auto
case plain
case tty
}
enum SecretType: Decodable {
case data(Data)
case file(String)
}
@Option(
name: .shortAndLong,
help: ArgumentHelp("Add the architecture type to the build", valueName: "value"),
transform: { val in val.split(separator: ",").map { String($0) } }
)
var arch: [[String]] = {
[[Arch.hostArchitecture().rawValue]]
}()
@Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val"))
var buildArg: [String] = []
@Option(name: .long, help: ArgumentHelp("Cache imports for the build", valueName: "value", visibility: .hidden))
var cacheIn: [String] = {
[]
}()
@Option(name: .long, help: ArgumentHelp("Cache exports for the build", valueName: "value", visibility: .hidden))
var cacheOut: [String] = {
[]
}()
@Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container")
var cpus: Int64?
@Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path"))
var file: String?
var dockerfile: String = "-"
@Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val"))
var label: [String] = []
@Option(
name: .shortAndLong,
help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix"
)
var memory: String?
@Flag(name: .long, help: "Do not use cache")
var noCache: Bool = false
@Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build (format: type=<oci|tar|local>[,dest=])", valueName: "value"))
var output: [String] = {
["type=oci"]
}()
@Option(
name: .long,
help: ArgumentHelp("Add the OS type to the build", valueName: "value"),
transform: { val in val.split(separator: ",").map { String($0) } }
)
var os: [[String]] = {
[["linux"]]
}()
@Option(
name: .long,
help: "Add the platform to the build (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]",
transform: { val in val.split(separator: ",").map { String($0) } }
)
var platform: [[String]] = [[]]
@Option(name: .long, help: ArgumentHelp("Progress type (format: auto|plain|tty)", valueName: "type"))
var progress: ProgressType = .auto
@Flag(name: .shortAndLong, help: "Suppress build output")
var quiet: Bool = false
@Option(name: .long, help: ArgumentHelp("Set build-time secrets (format: id=<key>[,env=<ENV_VAR>|,src=<local/path>])", valueName: "id=key,..."))
var secret: [String] = []
var secrets: [String: SecretType] = [:]
@Option(name: [.short, .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name"))
var targetImageNames: [String] = {
[UUID().uuidString.lowercased()]
}()
@Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage"))
var target: String = ""
@Option(name: .long, help: ArgumentHelp("Builder shim vsock port", valueName: "port"))
var vsockPort: UInt32 = 8088
@OptionGroup
public var logOptions: Flags.Logging
@OptionGroup
public var dns: Flags.DNS
@Argument(help: "Build directory")
var contextDir: String = "."
@Flag(name: .long, help: "Pull latest image")
var pull: Bool = false
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
do {
let timeout: Duration = .seconds(300)
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
progress.set(description: "Dialing builder")
let dnsNameservers = self.dns.nameservers
let builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { [vsockPort, cpus, memory, dnsNameservers] group in
defer {
group.cancelAll()
}
group.addTask { [vsockPort, cpus, memory, log, dnsNameservers] in
let client = ContainerClient()
while true {
do {
let fh = try await client.dial(id: "buildkit", port: vsockPort)
let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount)
let b = try await Builder(socket: fh, group: threadGroup, logger: log)
// If this call succeeds, then BuildKit is running.
let _ = try await b.info()
return b
} catch {
// If we get here, "Dialing builder" is shown for such a short period
// of time that it's invisible to the user.
progress.set(tasks: 0)
progress.set(totalTasks: 3)
try await BuilderStart.start(
cpus: cpus,
memory: memory,
log: log,
dnsNameservers: dnsNameservers,
progressUpdate: progress.handler,
containerSystemConfig: containerSystemConfig,
)
// wait (seconds) for builder to start listening on vsock
try await Task.sleep(for: .seconds(5))
continue
}
}
}
group.addTask {
try await Task.sleep(for: timeout)
throw ValidationError(
"""
Timeout waiting for connection to builder
"""
)
}
return try await group.next()
}
guard let builder else {
throw ValidationError("builder is not running")
}
let buildFileData: Data
var ignoreFileData: Data? = nil
// Dockerfile should be read from stdin
if dockerfile == "-" {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("Dockerfile-\(UUID().uuidString)")
defer {
try? FileManager.default.removeItem(at: tempFile)
}
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
throw ContainerizationError(.internalError, message: "unable to create temporary file")
}
guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else {
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
}
let bufferSize = 4096
while true {
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
fileHandle.write(chunk)
}
try fileHandle.close()
buildFileData = try Data(contentsOf: URL(filePath: tempFile.path()))
} else {
let ignoreFileURL = URL(filePath: dockerfile + ".dockerignore")
buildFileData = try Data(contentsOf: URL(filePath: dockerfile))
ignoreFileData = try? Data(contentsOf: ignoreFileURL)
}
// BUG: See https://github.com/apple/container/issues/735.
// Reject dockerfiles larger than 16kb before attempting to build.
// TODO: Remove when #735 was been resolved.
let maxDockerfileSize = 16 * 1024 // 16 KiB
guard buildFileData.count < maxDockerfileSize else {
throw ContainerizationError(
.invalidArgument,
message: """
Dockerfile size (\(buildFileData.count) bytes) exceeds the maximum allowed size of \(maxDockerfileSize) bytes. \
See https://github.com/apple/container/issues/735.
"""
)
}
let secretsData: [String: Data] = try self.secrets.mapValues { secret in
switch secret {
case .data(let data):
return data
case .file(let path):
return try Data(contentsOf: URL(fileURLWithPath: path))
}
}
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
let exportPath = systemHealth.appRoot
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
let buildID = UUID().uuidString
let tempURL = exportPath.appendingPathComponent(buildID)
try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true, attributes: nil)
defer {
try? FileManager.default.removeItem(at: tempURL)
}
let imageNames: [String] = try targetImageNames.map { name in
let parsedReference = try Reference.parse(name)
parsedReference.normalize()
return parsedReference.description
}
var terminal: Terminal?
switch self.progress {
case .tty:
terminal = try Terminal(descriptor: STDERR_FILENO)
case .auto:
terminal = try? Terminal(descriptor: STDERR_FILENO)
case .plain:
terminal = nil
}
defer { terminal?.tryReset() }
let exports: [Builder.BuildExport] = try output.map { output in
var exp = try Builder.BuildExport(from: output)
if exp.destination == nil {
exp.destination = tempURL.appendingPathComponent("out.tar")
}
return exp
}
try await withThrowingTaskGroup(of: Void.self) { [terminal] group in
defer {
group.cancelAll()
}
group.addTask {
let handler = AsyncSignalHandler.create(notify: [SIGTERM, SIGINT, SIGUSR1, SIGUSR2])
for await sig in handler.signals {
throw ContainerizationError(.interrupted, message: "exiting on signal \(sig)")
}
}
let platforms: Set<Platform> = try {
var results: Set<Platform> = []
for platform in (self.platform.flatMap { $0 }) {
guard let p = try? Platform(from: platform) else {
throw ValidationError("invalid platform specified \(platform)")
}
results.insert(p)
}
if !results.isEmpty {
return results
}
if let envPlatform = try DefaultPlatform.fromEnvironment(log: log) {
return [envPlatform]
}
for o in (self.os.flatMap { $0 }) {
for a in (self.arch.flatMap { $0 }) {
guard let platform = try? Platform(from: "\(o)/\(a)") else {
throw ValidationError("invalid os/architecture combination \(o)/\(a)")
}
results.insert(platform)
}
}
return results
}()
group.addTask {
[
terminal, buildArg, secretsData, contextDir, ignoreFileData, label, noCache, target, quiet, cacheIn, cacheOut, pull, exports, imageNames, tempURL, log,
] in
let config = Builder.BuildConfig(
buildID: buildID,
contentStore: RemoteContentStoreClient(),
buildArgs: buildArg,
secrets: secretsData,
contextDir: contextDir,
dockerfile: buildFileData,
dockerignore: ignoreFileData,
labels: label,
noCache: noCache,
platforms: [Platform](platforms),
terminal: terminal,
tags: imageNames,
target: target,
quiet: quiet,
exports: exports,
cacheIn: cacheIn,
cacheOut: cacheOut,
pull: pull,
containerSystemConfig: containerSystemConfig,
)
progress.finish()
try await builder.build(config)
let unpackProgressConfig = try ProgressConfig(
description: "Unpacking built image",
itemsName: "entries",
showTasks: exports.count > 1,
totalTasks: exports.count
)
let unpackProgress = ProgressBar(config: unpackProgressConfig)
defer {
unpackProgress.finish()
}
unpackProgress.start()
var finalMessage = imageNames.joined(separator: "\n")
let taskManager = ProgressTaskCoordinator()
// Currently, only a single export can be specified.
for exp in exports {
unpackProgress.add(tasks: 1)
let unpackTask = await taskManager.startTask()
switch exp.type {
case "oci":
try Task.checkCancellation()
guard let dest = exp.destination else {
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
}
let result = try await ClientImage.load(from: dest.absolutePath(), force: false)
guard result.rejectedMembers.isEmpty else {
log.error("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
throw ContainerizationError(.internalError, message: "failed to load archive")
}
for image in result.images {
try Task.checkCancellation()
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler))
// Tag the unpacked image with all requested tags
for tagName in imageNames {
try Task.checkCancellation()
_ = try await image.tag(new: tagName)
}
}
case "tar":
guard let dest = exp.destination else {
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
}
let tarURL = tempURL.appendingPathComponent("out.tar")
try FileManager.default.moveItem(at: tarURL, to: dest)
finalMessage = dest.absolutePath()
case "local":
guard let dest = exp.destination else {
throw ContainerizationError(.invalidArgument, message: "dest is required \(exp.rawValue)")
}
let localDir = tempURL.appendingPathComponent("local")
guard FileManager.default.fileExists(atPath: localDir.path) else {
throw ContainerizationError(.invalidArgument, message: "expected local output not found")
}
try FileManager.default.copyItem(at: localDir, to: dest)
finalMessage = dest.absolutePath()
default:
throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)")
}
}
await taskManager.finish()
unpackProgress.finish()
print(finalMessage)
}
try await group.next()
}
} catch {
throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"])
}
}
public mutating func validate() throws {
// NOTE: Here we check the Dockerfile exists, and set `dockerfile` to point the valid Dockerfile path or stdin
guard FileManager.default.fileExists(atPath: contextDir) else {
throw ValidationError("context dir does not exist \(contextDir)")
}
for name in targetImageNames {
guard let _ = try? Reference.parse(name) else {
throw ValidationError("invalid reference \(name)")
}
}
switch file {
case "-":
dockerfile = "-"
break
case .some(let filepath):
let fileURL = URL(fileURLWithPath: filepath, relativeTo: .currentDirectory())
guard FileManager.default.fileExists(atPath: fileURL.path) else {
throw ValidationError("dockerfile does not exist \(filepath)")
}
dockerfile = fileURL.path
break
case .none:
guard let defaultDockerfile = try BuildFile.resolvePath(contextDir: contextDir) else {
throw ValidationError("dockerfile not found in context dir")
}
guard FileManager.default.fileExists(atPath: defaultDockerfile) else {
throw ValidationError("dockerfile does not exist \(defaultDockerfile)")
}
dockerfile = defaultDockerfile
break
}
// Parse --secret args
for secret in self.secret {
let parts = secret.split(separator: ",", maxSplits: 1, omittingEmptySubsequences: false)
guard parts[0].hasPrefix("id=") else {
throw ValidationError("secret must start with id=<key> \(secret)")
}
let key = String(parts[0].dropFirst(3))
guard !key.contains("=") else {
throw ValidationError("secret id cannot contain '=' \(key)")
}
if parts.count == 1 || parts[1].hasPrefix("env=") {
let env = parts.count == 1 ? key : String(parts[1].dropFirst(4))
// Using getenv/strlen over processInfo.environment to support
// non-UTF-8 env var data.
guard let ptr = getenv(env) else {
throw ValidationError("secret env var doesn't exist \(env)")
}
self.secrets[key] = .data(Data(bytes: ptr, count: strlen(ptr)))
} else if parts[1].hasPrefix("src=") {
let path = String(parts[1].dropFirst(4))
self.secrets[key] = .file(path)
} else {
throw ValidationError("secret bad value \(parts[1])")
}
}
}
}
}
@@ -0,0 +1,38 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
extension Application {
public struct BuilderCommand: AsyncLoggableCommand {
public init() {}
public static let builderResourceDir = "builder"
public static let configuration = CommandConfiguration(
commandName: "builder",
abstract: "Manage an image builder instance",
subcommands: [
BuilderStart.self,
BuilderStatus.self,
BuilderStop.self,
BuilderDelete.self,
])
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,61 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import Foundation
extension Application {
public struct BuilderDelete: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "delete"
config.aliases = ["rm"]
config.abstract = "Delete the builder container"
return config
}
@Flag(name: .shortAndLong, help: "Delete the builder even if it is running")
var force = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
do {
let client = ContainerClient()
let container = try await client.get(id: "buildkit")
if container.status != .stopped {
guard force else {
throw ContainerizationError(.invalidState, message: "BuildKit container is not stopped, use --force to override")
}
try await client.stop(id: container.id)
}
try await client.delete(id: container.id)
} catch {
if error is ContainerizationError {
if (error as? ContainerizationError)?.code == .notFound {
return
}
}
throw error
}
}
}
}
@@ -0,0 +1,340 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerBuild
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationExtras
import ContainerizationOCI
import Foundation
import Logging
import TerminalProgress
extension Application {
public struct BuilderStart: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "start"
config.abstract = "Start the builder container"
return config
}
@Option(name: .shortAndLong, help: "Number of CPUs to allocate to the builder container")
var cpus: Int64?
@Option(
name: .shortAndLong,
help: "Amount of builder container memory (1MiByte granularity), with optional K, M, G, T, or P suffix"
)
var memory: String?
@OptionGroup
public var dns: Flags.DNS
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
totalTasks: 4
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try await BuilderStart.start(
cpus: self.cpus,
memory: self.memory,
log: log,
dnsNameservers: self.dns.nameservers,
dnsDomain: self.dns.domain,
dnsSearchDomains: self.dns.searchDomains,
dnsOptions: self.dns.options,
progressUpdate: progress.handler,
containerSystemConfig: containerSystemConfig,
)
progress.finish()
}
static func start(
cpus: Int64?,
memory: String?,
log: Logger,
dnsNameservers: [String] = [],
dnsDomain: String? = nil,
dnsSearchDomains: [String] = [],
dnsOptions: [String] = [],
progressUpdate: @escaping ProgressUpdateHandler,
containerSystemConfig: ContainerSystemConfig,
) async throws {
await progressUpdate([
.setDescription("Fetching BuildKit image"),
.setItemsName("blobs"),
])
let taskManager = ProgressTaskCoordinator()
let fetchTask = await taskManager.startTask()
let builderImage: String = containerSystemConfig.build.image
let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10))
let exportsMount: String = systemHealth.appRoot
.appendingPathComponent(Application.BuilderCommand.builderResourceDir)
.absolutePath()
if !FileManager.default.fileExists(atPath: exportsMount) {
try FileManager.default.createDirectory(
atPath: exportsMount,
withIntermediateDirectories: true,
attributes: nil
)
}
let builderPlatform = ContainerizationOCI.Platform(arch: "arm64", os: "linux", variant: "v8")
var targetEnvVars: [String] = []
if let buildkitColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] {
targetEnvVars.append("BUILDKIT_COLORS=\(buildkitColors)")
}
if ProcessInfo.processInfo.environment["NO_COLOR"] != nil {
targetEnvVars.append("NO_COLOR=true")
}
targetEnvVars.sort()
let defaultBuildCPUs: Int = containerSystemConfig.build.cpus
let defaultBuildMemory = containerSystemConfig.build.memory
let resources = try Parser.resources(
cpus: cpus,
memory: memory,
defaultCPUs: defaultBuildCPUs,
defaultMemory: defaultBuildMemory,
)
let client = ContainerClient()
let existingContainer = try? await client.get(id: "buildkit")
if let existingContainer {
let existingImage = existingContainer.configuration.image.reference
let existingResources = existingContainer.configuration.resources
let existingEnv = existingContainer.configuration.initProcess.environment
let existingDNS = existingContainer.configuration.dns
let existingManagedEnv = existingEnv.filter { envVar in
envVar.hasPrefix("BUILDKIT_COLORS=") || envVar.hasPrefix("NO_COLOR=")
}.sorted()
let envChanged = existingManagedEnv != targetEnvVars
// Check if we need to recreate the builder due to different image
let imageChanged = existingImage != builderImage
let cpuChanged = existingResources.cpus != resources.cpus
let memChanged = existingResources.memoryInBytes != resources.memoryInBytes
let dnsChanged = {
if !dnsNameservers.isEmpty {
return existingDNS?.nameservers != dnsNameservers
}
if dnsDomain != nil {
return existingDNS?.domain != dnsDomain
}
if !dnsSearchDomains.isEmpty {
return existingDNS?.searchDomains != dnsSearchDomains
}
if !dnsOptions.isEmpty {
return existingDNS?.options != dnsOptions
}
return false
}()
switch existingContainer.status {
case .running:
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
// If image, mem, cpu, env, and DNS are the same, continue using the existing builder
return
}
// If they changed, stop and delete the existing builder
try await client.stop(id: existingContainer.id)
try await client.delete(id: existingContainer.id)
case .stopped:
// If the builder is stopped and matches our requirements, start it
// Otherwise, delete it and create a new one
guard imageChanged || cpuChanged || memChanged || envChanged || dnsChanged else {
try await startBuildKit(client: client, id: existingContainer.id, progressUpdate, nil)
return
}
try await client.delete(id: existingContainer.id)
case .stopping:
throw ContainerizationError(
.invalidState,
message: "builder is stopping, please wait until it is fully stopped before proceeding"
)
case .unknown:
break
}
}
let useRosetta = containerSystemConfig.build.rosetta
let shimArguments = [
"--debug",
"--vsock",
useRosetta ? nil : "--enable-qemu",
].compactMap { $0 }
try ContainerAPIClient.Utility.validEntityName(Builder.builderContainerId)
let image = try await ClientImage.fetch(
reference: builderImage,
platform: builderPlatform,
containerSystemConfig: containerSystemConfig,
progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate)
)
// Unpack fetched image before use
await progressUpdate([
.setDescription("Unpacking BuildKit image"),
.setItemsName("entries"),
])
let unpackTask = await taskManager.startTask()
_ = try await image.getCreateSnapshot(
platform: builderPlatform,
progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)
)
let imageDesc = ImageDescription(
reference: builderImage,
descriptor: image.descriptor
)
let imageConfig = try await image.config(for: builderPlatform).config
var environment = imageConfig?.env ?? []
environment.append(contentsOf: targetEnvVars)
let processConfig = ProcessConfiguration(
executable: "/usr/local/bin/container-builder-shim",
arguments: shimArguments,
environment: environment,
workingDirectory: "/",
terminal: false,
user: .id(uid: 0, gid: 0)
)
var config = ContainerConfiguration(id: Builder.builderContainerId, image: imageDesc, process: processConfig)
config.resources = resources
config.labels = [
ResourceLabelKeys.plugin: "builder",
ResourceLabelKeys.role: ResourceRoleValues.builder,
]
config.capAdd = ["ALL"]
config.mounts = [
.init(
type: .tmpfs,
source: "",
destination: "/run",
options: []
),
.init(
type: .virtiofs,
source: exportsMount,
destination: "/var/lib/container-builder-shim/exports",
options: []
),
]
// Enable Rosetta only if the user didn't ask to disable it
config.rosetta = useRosetta
let networkClient = NetworkClient()
guard let defaultNetwork = try await networkClient.builtin else {
throw ContainerizationError(.invalidState, message: "default network is not present")
}
config.networks = [
AttachmentConfiguration(network: defaultNetwork.id, options: AttachmentOptions(hostname: Builder.builderContainerId))
]
config.dns = ContainerConfiguration.DNSConfiguration(
nameservers: dnsNameservers,
domain: dnsDomain,
searchDomains: dnsSearchDomains,
options: dnsOptions
)
let kernel = try await {
await progressUpdate([
.setDescription("Fetching kernel"),
.setItemsName("binary"),
])
let kernel = try await ClientKernel.getDefaultKernel(for: .current)
return kernel
}()
await progressUpdate([
.setDescription("Starting BuildKit container")
])
try await client.create(
configuration: config,
options: .default,
kernel: kernel
)
try await startBuildKit(client: client, id: Builder.builderContainerId, progressUpdate, taskManager)
log.debug("starting BuildKit and BuildKit-shim")
}
}
}
// MARK: - BuildKit Start Helper
/// Starts the BuildKit process within the container
/// This function handles bootstrapping the container and starting the BuildKit process
private func startBuildKit(
client: ContainerClient,
id: String,
_ progress: @escaping ProgressUpdateHandler,
_ taskManager: ProgressTaskCoordinator? = nil
) async throws {
do {
let io = try ProcessIO.create(
tty: false,
interactive: false,
detach: true
)
defer { try? io.close() }
var dynamicEnv: [String: String] = [:]
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
}
let process = try await client.bootstrap(id: id, stdio: io.stdio, dynamicEnv: dynamicEnv)
try await process.start()
await taskManager?.finish()
try io.closeAfterStart()
} catch {
try? await client.stop(id: id)
try? await client.delete(id: id)
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)")
}
}
@@ -0,0 +1,93 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import ContainerizationExtras
import Foundation
extension Application {
public struct BuilderStatus: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "status"
config.abstract = "Display the builder container status"
return config
}
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the container ID")
var quiet = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
do {
let client = ContainerClient()
let container = try await client.get(id: "buildkit")
if format == .table && quiet && container.status != .running {
return
}
try Output.render(
payload: [ManagedContainer(container)],
display: [PrintableBuilder(container)],
format: format,
quiet: quiet
)
} catch let error as ContainerizationError where error.code == .notFound {
try Output.render(payload: [ManagedContainer](), format: format) {
quiet ? "" : "builder is not running"
}
}
}
}
}
private struct PrintableBuilder: ListDisplayable {
let snapshot: ContainerSnapshot
init(_ snapshot: ContainerSnapshot) {
self.snapshot = snapshot
}
static var tableHeader: [String] {
["ID", "IMAGE", "STATE", "IP", "CPUS", "MEMORY"]
}
var tableRow: [String] {
[
snapshot.id,
snapshot.configuration.image.reference,
snapshot.status.rawValue,
snapshot.networks.map { $0.ipv4Address.description }.joined(separator: ","),
"\(snapshot.configuration.resources.cpus)",
"\(snapshot.configuration.resources.memoryInBytes / (1024 * 1024)) MB",
]
}
var quietValue: String {
snapshot.id
}
}
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import Foundation
extension Application {
public struct BuilderStop: AsyncLoggableCommand {
public static var configuration: CommandConfiguration {
var config = CommandConfiguration()
config.commandName = "stop"
config.abstract = "Stop the builder container"
return config
}
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
do {
let client = ContainerClient()
try await client.stop(id: "buildkit")
} catch {
if error is ContainerizationError {
if (error as? ContainerizationError)?.code == .notFound {
log.warning("builder is not running")
return
}
}
throw error
}
}
}
}
@@ -0,0 +1,121 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import Foundation
import SystemPackage
extension Application {
public struct ContainerCopy: AsyncLoggableCommand {
enum PathRef {
case local(String)
case container(id: String, path: String)
}
static func parsePathRef(_ ref: String) throws -> PathRef {
let parts = ref.components(separatedBy: ":")
switch parts.count {
case 1:
return .local(ref)
case 2 where !parts[0].isEmpty && parts[1].starts(with: "/"):
return .container(id: parts[0], path: parts[1])
default:
throw ContainerizationError(.invalidArgument, message: "invalid path given: \(ref)")
}
}
public init() {}
public static let configuration = CommandConfiguration(
commandName: "copy",
abstract: "Copy files/folders between a container and the local filesystem",
aliases: ["cp"])
@OptionGroup()
public var logOptions: Flags.Logging
@Argument(help: "Source path (container:path or local path)")
var source: String
@Argument(help: "Destination path (container:path or local path)")
var destination: String
public func run() async throws {
let client = ContainerClient()
let srcRef = try Self.parsePathRef(source)
let dstRef = try Self.parsePathRef(destination)
switch (srcRef, dstRef) {
case (.container(let id, let path), .local(let localPath)):
let srcPath = FilePath(path)
let destPath = FilePath(URL(fileURLWithPath: localPath, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false))
var isDirectory: ObjCBool = false
let exists = FileManager.default.fileExists(atPath: destPath.string, isDirectory: &isDirectory)
var finalDestPath = destPath
if exists && isDirectory.boolValue {
guard let lastComponent = srcPath.lastComponent else {
throw ContainerizationError(.invalidArgument, message: "source path has no last component: \(path)")
}
finalDestPath = destPath.appending(lastComponent)
try await client.copyOut(id: id, source: path, destination: finalDestPath.string)
} else if localPath.hasSuffix("/") {
try await client.copyOut(id: id, source: path, destination: destPath.string)
var resultIsDir: ObjCBool = false
if FileManager.default.fileExists(atPath: destPath.string, isDirectory: &resultIsDir),
!resultIsDir.boolValue
{
try? FileManager.default.removeItem(atPath: destPath.string)
throw ContainerizationError(
.invalidArgument,
message: "destination is not a directory: \(localPath)")
}
} else {
try await client.copyOut(id: id, source: path, destination: destPath.string)
}
print(finalDestPath.string)
case (.local(let localPath), .container(let id, let path)):
let srcPath = FilePath(URL(fileURLWithPath: localPath, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false))
var isDirectory: ObjCBool = false
guard let lastComponent = srcPath.lastComponent else {
throw ContainerizationError(.invalidArgument, message: "source path has no last component: \(localPath)")
}
guard FileManager.default.fileExists(atPath: srcPath.string, isDirectory: &isDirectory) else {
throw ContainerizationError(.notFound, message: "source path does not exist: \(localPath)")
}
if localPath.hasSuffix("/") && !isDirectory.boolValue {
throw ContainerizationError(.invalidArgument, message: "source path is not a directory: \(localPath)")
}
try await client.copyIn(id: id, source: srcPath.string, destination: path, createParents: true)
let printedDest = path.hasSuffix("/") ? "\(id):\(path)\(lastComponent.string)" : "\(id):\(path)"
print(printedDest)
case (.container, .container):
throw ContainerizationError(.invalidArgument, message: "copying between containers is not supported")
case (.local, .local):
throw ContainerizationError(
.invalidArgument,
message: "one of source or destination must be a container reference (container_id:path)")
}
}
}
}
@@ -0,0 +1,113 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import ContainerizationError
import Foundation
import TerminalProgress
extension Application {
public struct ContainerCreate: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a new container")
@OptionGroup(title: "Process options")
var processFlags: Flags.Process
@OptionGroup(title: "Resource options")
var resourceFlags: Flags.Resource
@OptionGroup(title: "Management options")
var managementFlags: Flags.Management
@OptionGroup(title: "Registry options")
var registryFlags: Flags.Registry
@OptionGroup(title: "Image fetch options")
var imageFetchFlags: Flags.ImageFetch
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Image name")
var image: String
@Argument(parsing: .captureForPassthrough, help: "Container init process arguments")
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 3
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
let id = Utility.createContainerID(name: self.managementFlags.name)
try Utility.validEntityName(id)
let ck = try await Utility.containerConfigFromFlags(
id: id,
image: image,
arguments: arguments,
process: processFlags,
management: managementFlags,
resource: resourceFlags,
registry: registryFlags,
imageFetch: imageFetchFlags,
containerSystemConfig: containerSystemConfig,
progressUpdate: progress.handler,
log: log
)
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
let client = ContainerClient()
try await client.create(configuration: ck.0, options: options, kernel: ck.1, initImage: ck.2)
if !self.managementFlags.cidfile.isEmpty {
let path = self.managementFlags.cidfile
let data = id.data(using: .utf8)
var attributes = [FileAttributeKey: Any]()
attributes[.posixPermissions] = 0o644
let success = FileManager.default.createFile(
atPath: path,
contents: data,
attributes: attributes
)
guard success else {
throw ContainerizationError(
.internalError, message: "failed to create cidfile at \(path): \(errno)")
}
}
progress.finish()
print(id)
}
}
}
@@ -0,0 +1,100 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ContainerDelete: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete one or more containers",
aliases: ["rm"])
@Flag(name: .shortAndLong, help: "Delete all containers")
var all = false
@Flag(name: .shortAndLong, help: "Delete containers even if they are running")
var force = false
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs")
var containerIds: [String] = []
public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
}
if containerIds.count > 0 && all {
throw ContainerizationError(
.invalidArgument,
message: "explicitly supplied container ID(s) conflict with the --all flag"
)
}
}
public mutating func run() async throws {
let client = ContainerClient()
let force = self.force
let containers: [String]
if all {
let filters = ContainerListFilters().withoutMachines()
containers = try await client.list(filters: filters).compactMap { c in
// Skip running containers when using --all without --force
if c.status == .running && !force {
return nil
}
return c.id
}
} else {
containers = Array(Set(containerIds))
}
var errors: [any Error] = []
try await withThrowingTaskGroup(of: (any Error)?.self) { group in
for container in containers {
group.addTask {
do {
try await client.delete(id: container, force: force)
print(container)
return nil
} catch {
return error
}
}
}
for try await error in group {
if let error {
errors.append(error)
}
}
}
if !errors.isEmpty {
throw AggregateError(errors)
}
}
}
}
@@ -0,0 +1,120 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import ContainerizationOS
import Foundation
extension Application {
public struct ContainerExec: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "exec",
abstract: "Run a new command in a running container")
@OptionGroup(title: "Process options")
var processFlags: Flags.Process
@OptionGroup
public var logOptions: Flags.Logging
@Flag(name: .shortAndLong, help: "Run the process and detach from it")
var detach = false
@Argument(help: "Container ID")
var containerId: String
@Argument(parsing: .captureForPassthrough, help: "New process arguments")
var arguments: [String]
public func run() async throws {
var exitCode: Int32 = 127
let client = ContainerClient()
let container = try await client.get(id: containerId)
try ensureRunning(container: container)
let stdin = self.processFlags.interactive
let tty = self.processFlags.tty
guard let executable = arguments.first else {
throw ContainerizationError(.invalidArgument, message: "no command specified for exec")
}
var config = container.configuration.initProcess
config.executable = executable
config.arguments = [String](self.arguments.dropFirst())
config.terminal = tty
config.environment.append(
contentsOf: try Parser.allEnv(
imageEnvs: [],
envFiles: self.processFlags.envFile,
envs: self.processFlags.env
))
if let cwd = self.processFlags.cwd {
config.workingDirectory = cwd
}
let defaultUser = config.user
let (user, additionalGroups) = Parser.user(
user: processFlags.user, uid: processFlags.uid,
gid: processFlags.gid, defaultUser: defaultUser)
config.user = user
config.supplementalGroups.append(contentsOf: additionalGroups)
do {
let io = try ProcessIO.create(tty: tty, interactive: stdin, detach: self.detach)
defer {
try? io.close()
}
let process = try await client.createProcess(
containerId: container.id,
processId: UUID().uuidString.lowercased(),
configuration: config,
stdio: io.stdio
)
if self.detach {
try await process.start()
try io.closeAfterStart()
print(containerId)
return
}
if !self.processFlags.tty {
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
let log = self.log
handler.start {
log.warning("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
Darwin.exit(1)
}
}
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to exec process \(error)")
}
throw ArgumentParser.ExitCode(exitCode)
}
}
}
@@ -0,0 +1,74 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import Foundation
import TerminalProgress
extension Application {
public struct ContainerExport: AsyncLoggableCommand {
public init() {}
public static var configuration: CommandConfiguration {
CommandConfiguration(
commandName: "export",
abstract: "Export a container's filesystem as a tar archive",
)
}
@OptionGroup
public var logOptions: Flags.Logging
@Option(
name: .shortAndLong, help: "Pathname for the saved container filesystem (defaults to stdout)", completion: .file(),
transform: { str in
URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false)
})
var output: String?
@Argument(help: "container ID")
var id: String
public func run() async throws {
let client = ContainerClient()
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString)
try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
defer {
try? FileManager.default.removeItem(at: tempDir)
}
let archive = tempDir.appendingPathComponent("archive.tar")
try await client.export(id: id, archive: archive)
if output == nil {
guard let fileHandle = try? FileHandle(forReadingFrom: archive) else {
throw ContainerizationError(.internalError, message: "unable to open archive for reading")
}
let bufferSize = 4096
while true {
let chunk = fileHandle.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
FileHandle.standardOutput.write(chunk)
}
try fileHandle.close()
} else {
try FileManager.default.moveItem(at: archive, to: URL(fileURLWithPath: output!))
}
}
}
}
@@ -0,0 +1,56 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ContainerInspect: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display information about one or more containers")
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs to inspect")
var containerIds: [String]
public func run() async throws {
let client = ContainerClient()
let uniqueIds = Set(containerIds)
let containers = try await client.list().filter {
uniqueIds.contains($0.id)
}
if containers.count != uniqueIds.count {
let found = Set(containers.map { $0.id })
let missing = uniqueIds.subtracting(found).sorted()
throw ContainerizationError(
.notFound,
message: "container not found: \(missing.joined(separator: ", "))"
)
}
try Output.emit(Output.renderJSON(containers.map { ManagedContainer($0) }, options: .pretty))
}
}
}
@@ -0,0 +1,79 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOS
import Darwin
extension Application {
public struct ContainerKill: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "kill",
abstract: "Kill or signal one or more running containers")
@Flag(name: .shortAndLong, help: "Kill or signal all running containers")
var all = false
@Option(name: .shortAndLong, help: "Signal to send to the container(s)")
var signal: String = "KILL"
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs")
var containerIds: [String] = []
public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
}
if containerIds.count > 0 && all {
throw ContainerizationError(.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag")
}
}
public mutating func run() async throws {
let client = ContainerClient()
let containers: [String]
if self.all {
let filters = ContainerListFilters(status: .running).withoutMachines()
containers = try await client.list(filters: filters).map { $0.id }
} else {
containers = containerIds
}
var errors: [any Error] = []
for container in containers {
do {
try await client.kill(id: container, signal: signal)
print(container)
} catch {
errors.append(error)
}
}
if !errors.isEmpty {
throw AggregateError(errors)
}
}
}
}
@@ -0,0 +1,54 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationExtras
import Foundation
import SwiftProtobuf
extension Application {
public struct ContainerList: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List running containers",
aliases: ["ls"])
@Flag(name: .shortAndLong, help: "Include containers that are not running")
var all = false
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the container ID")
var quiet = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
let client = ContainerClient()
let filters = ContainerListFilters(status: self.all ? nil : .running).withoutMachines()
let containers = try await client.list(filters: filters)
let items = containers.map { ManagedContainer($0) }
try Output.render(payload: items, display: items, format: format, quiet: quiet)
}
}
}
@@ -0,0 +1,142 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import Darwin
import Dispatch
import Foundation
extension Application {
public struct ContainerLogs: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "logs",
abstract: "Fetch container logs"
)
@Flag(name: .long, help: "Display the boot log for the container instead of stdio")
var boot: Bool = false
@Flag(name: .shortAndLong, help: "Follow log output")
var follow: Bool = false
@Option(name: .short, help: "Number of lines to show from the end of the logs. If not provided this will print all of the logs")
var numLines: Int?
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container ID")
var containerId: String
public func run() async throws {
let client = ContainerClient()
let fhs = try await client.logs(id: containerId)
let fileHandle = boot ? fhs[1] : fhs[0]
try await Self.tail(
fh: fileHandle,
n: numLines,
follow: follow
)
}
private static func tail(
fh: FileHandle,
n: Int?,
follow: Bool
) async throws {
if let n {
var buffer = Data()
let size = try fh.seekToEnd()
var offset = size
var lines: [String] = []
while offset > 0, lines.count < n {
let readSize = min(1024, offset)
offset -= readSize
try fh.seek(toOffset: offset)
let data = fh.readData(ofLength: Int(readSize))
buffer.insert(contentsOf: data, at: 0)
if let chunk = String(data: buffer, encoding: .utf8) {
lines = chunk.components(separatedBy: .newlines)
lines = lines.filter { !$0.isEmpty }
}
}
lines = Array(lines.suffix(n))
for line in lines {
print(line)
}
} else {
// Fast path if all they want is the full file.
guard let data = try fh.readToEnd() else {
// Seems you get nil if it's a zero byte read, or you
// try and read from dev/null.
return
}
guard let str = String(data: data, encoding: .utf8) else {
throw ContainerizationError(
.internalError,
message: "failed to convert container logs to utf8"
)
}
print(str.trimmingCharacters(in: .newlines))
}
fflush(stdout)
if follow {
setbuf(stdout, nil)
try await Self.followFile(fh: fh)
}
}
private static func followFile(fh: FileHandle) async throws {
_ = try fh.seekToEnd()
let stream = AsyncStream<String> { cont in
fh.readabilityHandler = { handle in
let data = handle.availableData
if data.isEmpty {
// Triggers on container restart - can exit here as well
do {
_ = try fh.seekToEnd() // To continue streaming existing truncated log files
} catch {
fh.readabilityHandler = nil
cont.finish()
return
}
}
if let str = String(data: data, encoding: .utf8), !str.isEmpty {
var lines = str.components(separatedBy: .newlines)
lines = lines.filter { !$0.isEmpty }
for line in lines {
cont.yield(line)
}
}
}
}
for await line in stream {
print(line)
}
}
}
}
@@ -0,0 +1,68 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ContainerPrune: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "prune",
abstract: "Remove all stopped containers"
)
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let client = ContainerClient()
let filters = ContainerListFilters(status: .stopped).withoutMachines()
let containersToPrune = try await client.list(filters: filters)
var prunedContainerIds = [String]()
var totalSize: UInt64 = 0
for container in containersToPrune {
do {
let actualSize = try await client.diskUsage(id: container.id)
totalSize += actualSize
try await client.delete(id: container.id)
prunedContainerIds.append(container.id)
} catch {
log.error(
"failed to prune container",
metadata: [
"id": "\(container.id)",
"error": "\(error)",
])
}
}
let formatter = ByteCountFormatter()
let freed = formatter.string(fromByteCount: Int64(totalSize))
for name in prunedContainerIds {
print(name)
}
log.info("Reclaimed \(freed) in disk space")
}
}
}
@@ -0,0 +1,181 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationExtras
import ContainerizationOS
import Foundation
import NIOCore
import NIOPosix
import TerminalProgress
extension Application {
public struct ContainerRun: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "run",
abstract: "Run a container")
@OptionGroup(title: "Process options")
var processFlags: Flags.Process
@OptionGroup(title: "Resource options")
var resourceFlags: Flags.Resource
@OptionGroup(title: "Management options")
var managementFlags: Flags.Management
@OptionGroup(title: "Registry options")
var registryFlags: Flags.Registry
@OptionGroup(title: "Progress options")
var progressFlags: Flags.Progress
@OptionGroup(title: "Image fetch options")
var imageFetchFlags: Flags.ImageFetch
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Image name")
var image: String
@Argument(parsing: .captureForPassthrough, help: "Container init process arguments")
var arguments: [String] = []
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
var exitCode: Int32 = 127
let id = Utility.createContainerID(name: self.managementFlags.name)
let progressConfig = try self.progressFlags.makeConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 6
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try Utility.validEntityName(id)
// Check if container with id already exists.
let client = ContainerClient()
let existing = try? await client.get(id: id)
guard existing == nil else {
throw ContainerizationError(
.exists,
message: "container with id \(id) already exists"
)
}
let ck = try await Utility.containerConfigFromFlags(
id: id,
image: image,
arguments: arguments,
process: processFlags,
management: managementFlags,
resource: resourceFlags,
registry: registryFlags,
imageFetch: imageFetchFlags,
containerSystemConfig: containerSystemConfig,
progressUpdate: progress.handler,
log: log
)
progress.set(description: "Starting container")
let options = ContainerCreateOptions(autoRemove: managementFlags.remove)
try await client.create(
configuration: ck.0,
options: options,
kernel: ck.1,
initImage: ck.2
)
let detach = self.managementFlags.detach
do {
let io = try ProcessIO.create(
tty: self.processFlags.tty,
interactive: self.processFlags.interactive,
detach: detach
)
defer {
try? io.close()
}
var dynamicEnv: [String: String] = [:]
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
}
let process = try await client.bootstrap(id: id, stdio: io.stdio, dynamicEnv: dynamicEnv)
progress.finish()
if !self.managementFlags.cidfile.isEmpty {
let path = self.managementFlags.cidfile
let data = id.data(using: .utf8)
var attributes = [FileAttributeKey: Any]()
attributes[.posixPermissions] = 0o644
let success = FileManager.default.createFile(
atPath: path,
contents: data,
attributes: attributes
)
guard success else {
throw ContainerizationError(
.internalError, message: "failed to create cidfile at \(path): \(errno)")
}
}
if detach {
try await process.start()
try io.closeAfterStart()
print(id)
return
}
if !self.processFlags.tty {
var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM])
let log = self.log
handler.start {
log.warning("Received 3 SIGINT/SIGTERM's, forcefully exiting.")
Darwin.exit(1)
}
}
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
try? await client.delete(id: id)
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to run container: \(error)")
}
throw ArgumentParser.ExitCode(exitCode)
}
}
}
@@ -0,0 +1,117 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationError
import ContainerizationOS
import Foundation
import TerminalProgress
extension Application {
public struct ContainerStart: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "start",
abstract: "Start a container")
@Flag(name: .shortAndLong, help: "Attach stdout/stderr")
var attach = false
@Flag(name: .shortAndLong, help: "Attach stdin")
var interactive = false
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container ID")
var containerId: String
public func run() async throws {
var exitCode: Int32 = 127
let progressConfig = try ProgressConfig(
description: "Starting container"
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
let detach = !self.attach && !self.interactive
let client = ContainerClient()
let container = try await client.get(id: containerId)
// Bootstrap and process start are both idempotent and don't fail the second time
// around, however not doing an rpc is always faster :). The other bit is we don't
// support attach currently, so we can't do `start -a` a second time and have it succeed.
if container.status == .running {
if !detach {
throw ContainerizationError(
.invalidArgument,
message: "attach is currently unsupported on already running containers"
)
}
print(containerId)
return
}
for mount in container.configuration.mounts where mount.isVirtiofs {
if !FileManager.default.fileExists(atPath: mount.source) {
throw ContainerizationError(.invalidState, message: "mount source path '\(mount.source)' does not exist")
}
}
do {
let io = try ProcessIO.create(
tty: container.configuration.initProcess.terminal,
interactive: self.interactive,
detach: detach
)
defer {
try? io.close()
}
var env: [String: String] = [:]
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
env["SSH_AUTH_SOCK"] = sshAuthSock
}
let process = try await client.bootstrap(id: container.id, stdio: io.stdio, dynamicEnv: env)
progress.finish()
if detach {
try await process.start()
try io.closeAfterStart()
print(self.containerId)
return
}
exitCode = try await io.handleProcess(process: process, log: log)
} catch {
try? await client.stop(id: container.id)
if error is ContainerizationError {
throw error
}
throw ContainerizationError(.internalError, message: "failed to start container: \(error)")
}
throw ArgumentParser.ExitCode(exitCode)
}
}
}
@@ -0,0 +1,284 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import ContainerizationError
import ContainerizationExtras
import ContainerizationOS
import Foundation
extension Application {
public struct ContainerStats: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "stats",
abstract: "Display resource usage statistics for containers")
@Argument(help: "Container ID or name (optional, shows all running containers if not specified)")
var containers: [String] = []
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .long, help: "Disable streaming stats and only pull the first result")
var noStream = false
@OptionGroup
public var logOptions: Flags.Logging
public init() {}
public func run() async throws {
if format != .table || noStream {
// Static mode - get stats once and exit
try await runStatic()
} else {
// Streaming mode - continuously update like top
// Enter alternate screen buffer and hide cursor
print("\u{001B}[?1049h\u{001B}[?25l", terminator: "")
fflush(stdout)
defer {
// Exit alternate screen buffer and show cursor again
print("\u{001B}[?25h\u{001B}[?1049l", terminator: "")
fflush(stdout)
}
let containerIds = containers
try await withThrowingTaskGroup(of: Void.self) { group in
defer { group.cancelAll() }
group.addTask {
let handler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM])
for await _ in handler.signals {
throw CancellationError()
}
}
group.addTask { [containerIds] in
try await Self.runStreaming(containerIds: containerIds)
}
do {
try await group.next()
} catch is CancellationError {
// Normal exit on signal, defer will restore the terminal
}
}
}
}
private func runStatic() async throws {
let client = ContainerClient()
let containersToShow: [ContainerSnapshot]
if containers.isEmpty {
// No containers specified - show all running containers
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
} else {
// Fetch specified containers by ID
containersToShow = try await client.list(filters: ContainerListFilters(ids: containers))
// Validate all specified containers were found
for containerId in containers {
guard containersToShow.contains(where: { $0.id == containerId }) else {
throw ContainerizationError(
.notFound,
message: "no such container: \(containerId)"
)
}
}
}
let statsData = try await Self.collectStats(client: client, for: containersToShow)
try Output.render(payload: statsData.map { $0.stats2 }, format: format) {
Self.statsTable(statsData)
}
}
private static func runStreaming(containerIds: [String]) async throws {
let client = ContainerClient()
// If containers were specified, validate they all exist upfront
if !containerIds.isEmpty {
let specifiedContainers = try await client.list(filters: ContainerListFilters(ids: containerIds))
for containerId in containerIds {
guard specifiedContainers.contains(where: { $0.id == containerId }) else {
throw ContainerizationError(
.notFound,
message: "no such container: \(containerId)"
)
}
}
}
clearScreen()
// Show header right away.
print(statsTable([]))
while true {
do {
let containersToShow: [ContainerSnapshot]
if containerIds.isEmpty {
containersToShow = try await client.list(filters: ContainerListFilters(status: .running))
} else {
containersToShow = try await client.list(filters: ContainerListFilters(ids: containerIds))
}
let statsData = try await collectStats(client: client, for: containersToShow)
// Clear screen and reprint
clearScreen()
print(statsTable(statsData))
if statsData.isEmpty {
try await Task.sleep(for: .seconds(2))
}
} catch {
clearScreen()
print("error collecting stats: \(error)")
try await Task.sleep(for: .seconds(2))
}
}
}
private struct StatsSnapshot {
let container: ContainerSnapshot
let stats1: ContainerResource.ContainerStats
let stats2: ContainerResource.ContainerStats
}
private static func collectStats(client: ContainerClient, for containers: [ContainerSnapshot]) async throws -> [StatsSnapshot] {
var snapshots: [StatsSnapshot] = []
// First sample
for container in containers {
guard container.status == .running else { continue }
do {
let stats1 = try await client.stats(id: container.id)
snapshots.append(StatsSnapshot(container: container, stats1: stats1, stats2: stats1))
} catch {
// Skip containers that error out
continue
}
}
// Wait 2 seconds for CPU delta calculation
if !snapshots.isEmpty {
try await Task.sleep(for: .seconds(2))
// Second sample
for i in 0..<snapshots.count {
do {
let stats2 = try await client.stats(id: snapshots[i].container.id)
snapshots[i] = StatsSnapshot(
container: snapshots[i].container,
stats1: snapshots[i].stats1,
stats2: stats2
)
} catch {
// Keep the original stats if second sample fails
continue
}
}
}
return snapshots
}
/// Calculate CPU percentage from two stat snapshots
/// - Parameters:
/// - cpuUsageUsec1: CPU usage in microseconds from first sample
/// - cpuUsageUsec2: CPU usage in microseconds from second sample
/// - timeDeltaUsec: Time delta between samples in microseconds
/// - Returns: CPU percentage where 100% = one fully utilized core
static func calculateCPUPercent(
cpuUsage1: Duration,
cpuUsage2: Duration,
timeInterval: Duration
) -> Double {
let cpuDelta =
cpuUsage2 > cpuUsage1
? cpuUsage2 - cpuUsage1
: .seconds(0)
return (cpuDelta / timeInterval) * 100.0
}
static func formatBytes(_ bytes: UInt64) -> String {
let kib = 1024.0
let mib = kib * 1024.0
let gib = mib * 1024.0
let value = Double(bytes)
if value >= gib {
return String(format: "%.2f GiB", value / gib)
} else if value >= mib {
return String(format: "%.2f MiB", value / mib)
} else {
return String(format: "%.2f KiB", value / kib)
}
}
private static func statsTable(_ statsData: [StatsSnapshot]) -> String {
let headerRow = ["Container ID", "Cpu %", "Memory Usage", "Net Rx/Tx", "Block I/O", "Pids"]
let notAvailable = "--"
var rows = [headerRow]
for snapshot in statsData {
var row = [snapshot.container.id]
let stats1 = snapshot.stats1
let stats2 = snapshot.stats2
if let cpuUsageUsec1 = stats1.cpuUsageUsec, let cpuUsageUsec2 = stats2.cpuUsageUsec {
let cpuPercent = Self.calculateCPUPercent(
cpuUsage1: .microseconds(cpuUsageUsec1),
cpuUsage2: .microseconds(cpuUsageUsec2),
timeInterval: .seconds(2)
)
let cpuStr = String(format: "%.2f%%", cpuPercent)
row.append(cpuStr)
} else {
row.append(notAvailable)
}
let memUsageStr = stats2.memoryUsageBytes.map { Self.formatBytes($0) } ?? notAvailable
let memLimitStr = stats2.memoryLimitBytes.map { Self.formatBytes($0) } ?? notAvailable
row.append("\(memUsageStr) / \(memLimitStr)")
let netRxStr = stats2.networkRxBytes.map { Self.formatBytes($0) } ?? notAvailable
let netTxStr = stats2.networkTxBytes.map { Self.formatBytes($0) } ?? notAvailable
row.append("\(netRxStr) / \(netTxStr)")
let blkReadStr = stats2.blockReadBytes.map { Self.formatBytes($0) } ?? notAvailable
let blkWriteStr = stats2.blockWriteBytes.map { Self.formatBytes($0) } ?? notAvailable
row.append("\(blkReadStr) / \(blkWriteStr)")
let pidsStr = stats2.numProcesses.map { "\($0)" } ?? notAvailable
row.append(pidsStr)
rows.append(row)
}
// Always print header, even if no containers
return TableOutput(rows: rows).format()
}
private static func clearScreen() {
// Move cursor to home position and clear from cursor to end of screen
print("\u{001B}[H\u{001B}[J", terminator: "")
fflush(stdout)
}
}
}
@@ -0,0 +1,107 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import Foundation
import Logging
extension Application {
public struct ContainerStop: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "stop",
abstract: "Stop one or more running containers")
@Flag(name: .shortAndLong, help: "Stop all running containers")
var all = false
@Option(name: .shortAndLong, help: "Signal to send to the containers")
var signal: String?
@Option(name: .shortAndLong, help: "Seconds to wait before killing the containers")
var time: Int32 = 5
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container IDs")
var containerIds: [String] = []
public func validate() throws {
if containerIds.count == 0 && !all {
throw ContainerizationError(.invalidArgument, message: "no containers specified and --all not supplied")
}
if containerIds.count > 0 && all {
throw ContainerizationError(
.invalidArgument, message: "explicitly supplied container IDs conflict with the --all flag")
}
}
public mutating func run() async throws {
let client = ContainerClient()
let containers: [String]
if self.all {
let filters = ContainerListFilters().withoutMachines()
containers = try await client.list(filters: filters).map { $0.id }
} else {
containers = containerIds
}
let opts = ContainerStopOptions(
timeoutInSeconds: self.time,
signal: self.signal
)
try await Self.stopContainers(
client: client,
containers: containers,
stopOptions: opts
)
}
static func stopContainers(client: ContainerClient, containers: [String], stopOptions: ContainerStopOptions) async throws {
var errors: [any Error] = []
await withTaskGroup(of: (any Error)?.self) { group in
for container in containers {
group.addTask {
do {
try await client.stop(id: container, opts: stopOptions)
print(container)
return nil
} catch {
return error
}
}
}
for await error in group {
if let error {
errors.append(error)
}
}
}
if !errors.isEmpty {
throw AggregateError(errors)
}
}
}
}
@@ -0,0 +1,40 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 ContainerResource
import Foundation
extension ManagedContainer: ListDisplayable {
public static var tableHeader: [String] {
["ID", "IMAGE", "OS", "ARCH", "STATE", "IP", "CPUS", "MEMORY", "STARTED"]
}
public var tableRow: [String] {
[
configuration.id,
configuration.image.reference,
configuration.platform.os,
configuration.platform.architecture,
status.state.rawValue,
status.networks.map { $0.ipv4Address.description }.joined(separator: ","),
"\(configuration.resources.cpus)",
"\(configuration.resources.memoryInBytes / (1024 * 1024)) MB",
status.startedDate?.ISO8601Format() ?? "",
]
}
public var quietValue: String { configuration.id }
}
@@ -0,0 +1,30 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container 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 ContainerAPIClient
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
extension Application {
static func ensureRunning(container: ContainerSnapshot) throws {
if container.status != .running {
throw ContainerizationError(.invalidState, message: "container \(container.id) is not running")
}
}
}
@@ -0,0 +1,111 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPlugin
import Darwin
import Foundation
import SystemPackage
struct DefaultCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: nil,
shouldDisplay: false
)
@OptionGroup(visibility: .hidden)
public var logOptions: Flags.Logging
@Argument(parsing: .captureForPassthrough)
var remaining: [String] = []
func run() async throws {
// See if we have a possible plugin command.
let pluginLoader = try? await Application.createPluginLoader()
guard let command = remaining.first else {
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
// Check for edge cases and unknown options to match the behavior in the absence of plugins.
if command.isEmpty {
throw ValidationError("unknown argument '\(command)'")
} else if command.starts(with: "-") {
throw ValidationError("unknown option '\(command)'")
}
// Compute canonical plugin directories to show in helpful errors (avoid hard-coded paths)
let installRoot = CommandLine.executablePath
.removingLastComponent()
.removingLastComponent()
// TODO: Remove when we convert PluginLoader to FilePath
let installRootURL = URL(fileURLWithPath: installRoot.string)
let userPluginsURL = PluginLoader.userPluginsDir(installRoot: installRootURL)
let installRootPluginsPath =
installRoot
.appending(FilePath.Component("libexec"))
.appending(FilePath.Component("container"))
.appending(FilePath.Component("plugins"))
let installRootPluginsURL = URL(fileURLWithPath: installRootPluginsPath.string)
let hintPaths = [userPluginsURL, installRootPluginsURL]
.map { $0.appendingPathComponent(command).path(percentEncoded: false) }
.joined(separator: "\n - ")
// If plugin loader couldn't be created, the system/APIServer likely isn't running.
if pluginLoader == nil {
throw ValidationError(
"""
Plugins are unavailable. Start the container system services and retry:
container system start
Check to see that the plugin exists under:
- \(hintPaths)
"""
)
}
guard let plugin = pluginLoader?.findPlugin(name: command), plugin.config.isCLI else {
throw ValidationError(
"""
Plugin 'container-\(command)' not found.
- If system services are not running, start them with: container system start
- If the plugin isn't installed, ensure it exists under:
Check to see that the plugin exists under:
- \(hintPaths)
"""
)
}
// Before execing into the plugin, restore default SIGINT/SIGTERM so the plugin can manage signals.
Self.resetSignalsForPluginExec()
// Exec performs execvp (with no fork).
try plugin.exec(args: remaining)
}
}
extension DefaultCommand {
// Exposed for tests to verify signal reset semantics.
static func resetSignalsForPluginExec() {
signal(SIGINT, SIG_DFL)
signal(SIGTERM, SIG_DFL)
}
}
@@ -0,0 +1,76 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 ContainerAPIClient
import Foundation
import TerminalProgress
extension Flags.Progress {
/// Resolves `.auto` into `.ansi` or `.plain` based on whether stderr is a TTY.
private func resolvedProgress() -> ProgressType {
switch progress {
case .auto:
return isatty(FileHandle.standardError.fileDescriptor) == 1 ? .ansi : .plain
case .none, .ansi, .plain, .color:
return progress
}
}
/// Creates a `ProgressConfig` based on the selected progress type.
///
/// For `.none`, progress updates are disabled. For `.ansi`, the given parameters
/// are used as-is. For `.plain`, ANSI-incompatible features (spinner, clear on finish)
/// are disabled and the output mode is set to `.plain`. For `.color`, behavior matches
/// `.ansi` but the output mode is set to `.color` to enable color-coded output.
/// For `.auto`, the type is resolved by checking whether stderr is a TTY.
public func makeConfig(
description: String = "",
itemsName: String = "it",
showTasks: Bool = false,
showItems: Bool = false,
showSpeed: Bool = true,
ignoreSmallSize: Bool = false,
totalTasks: Int? = nil
) throws -> ProgressConfig {
let resolved = resolvedProgress()
switch resolved {
case .none:
return try ProgressConfig(disableProgressUpdates: true)
case .ansi, .plain, .color:
let isPlain = resolved == .plain
let outputMode: ProgressConfig.OutputMode
switch resolved {
case .plain: outputMode = .plain
case .color: outputMode = .color
default: outputMode = .ansi
}
return try ProgressConfig(
description: description,
itemsName: itemsName,
showSpinner: !isPlain,
showTasks: showTasks,
showItems: showItems,
showSpeed: showSpeed,
ignoreSmallSize: ignoreSmallSize,
totalTasks: totalTasks,
clearOnFinish: !isPlain,
outputMode: outputMode
)
case .auto:
fatalError("unreachable: .auto should have been resolved")
}
}
}
@@ -0,0 +1,68 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
struct HelpCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "help",
shouldDisplay: false
)
@OptionGroup(visibility: .hidden)
public var logOptions: Flags.Logging
@Argument(parsing: .captureForPassthrough)
var subcommandPath: [String] = []
func run() async throws {
if subcommandPath.isEmpty {
let pluginLoader = try? await Application.createPluginLoader()
await Application.printModifiedHelpText(pluginLoader: pluginLoader)
return
}
guard let target = Self.resolveSubcommand(path: subcommandPath) else {
throw ValidationError("unknown command '\(subcommandPath.joined(separator: " "))'")
}
print(Application.helpMessage(for: target))
}
static func resolveSubcommand(path: [String]) -> ParsableCommand.Type? {
var current: ParsableCommand.Type = Application.self
for name in path {
guard let next = childSubcommands(of: current).first(where: { matches($0, name: name) }) else {
return nil
}
current = next
}
return current
}
private static func childSubcommands(of command: ParsableCommand.Type) -> [ParsableCommand.Type] {
var all = command.configuration.subcommands
for group in command.configuration.groupedSubcommands {
all.append(contentsOf: group.subcommands)
}
return all
}
private static func matches(_ command: ParsableCommand.Type, name: String) -> Bool {
let cfg = command.configuration
if cfg.commandName == name { return true }
return cfg.aliases.contains(name)
}
}
@@ -0,0 +1,44 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
extension Application {
public struct ImageCommand: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "image",
abstract: "Manage images",
subcommands: [
ImageDelete.self,
ImageInspect.self,
ImageList.self,
ImageLoad.self,
ImagePrune.self,
ImagePull.self,
ImagePush.self,
ImageSave.self,
ImageTag.self,
],
aliases: ["i"]
)
@OptionGroup
public var logOptions: Flags.Logging
}
}
@@ -0,0 +1,116 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import Containerization
import ContainerizationError
import Foundation
import Logging
extension Application {
public struct RemoveImageOptions: ParsableArguments {
public init() {}
@Flag(name: .shortAndLong, help: "Delete all images")
var all: Bool = false
@Flag(name: .shortAndLong, help: "Ignore errors for images that are not found")
var force: Bool = false
@Argument
var images: [String] = []
}
struct DeleteImageImplementation {
static func validate(options: RemoveImageOptions) throws {
if options.images.count == 0 && !options.all {
throw ContainerizationError(.invalidArgument, message: "no images specified and --all not supplied")
}
if options.images.count > 0 && options.all {
throw ContainerizationError(.invalidArgument, message: "explicitly supplied images conflict with the --all flag")
}
}
static func removeImage(options: RemoveImageOptions, containerSystemConfig: ContainerSystemConfig, log: Logger) async throws {
let (found, notFound) = try await {
if options.all {
let found = try await ClientImage.list()
let notFound: [String] = []
return (found, notFound)
}
return try await ClientImage.get(names: options.images, containerSystemConfig: containerSystemConfig)
}()
var failures: [String] = options.force ? [] : notFound
var didDeleteAnyImage = false
for image in found {
guard
!Utility.isInfraImage(
name: image.reference,
builderImage: containerSystemConfig.build.image,
initImage: containerSystemConfig.vminit.image
)
else {
continue
}
do {
try await ClientImage.delete(reference: image.reference, garbageCollect: false)
print(image.reference)
didDeleteAnyImage = true
} catch {
log.error("failed to delete \(image.reference): \(error)")
failures.append(image.reference)
}
}
let (_, size) = try await ClientImage.cleanUpOrphanedBlobs()
let formatter = ByteCountFormatter()
let freed = formatter.string(fromByteCount: Int64(size))
if didDeleteAnyImage {
log.info("Reclaimed \(freed) in disk space")
}
if failures.count > 0 {
throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)")
}
}
}
public struct ImageDelete: AsyncLoggableCommand {
@OptionGroup
var options: RemoveImageOptions
@OptionGroup
public var logOptions: Flags.Logging
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete one or more images",
aliases: ["rm"])
public init() {}
public func validate() throws {
try DeleteImageImplementation.validate(options: options)
}
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
try await DeleteImageImplementation.removeImage(options: options, containerSystemConfig: containerSystemConfig, log: log)
}
}
}
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerResource
import ContainerizationError
import Foundation
extension Application {
public struct ImageInspect: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display information about one or more images")
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Images to inspect")
var images: [String]
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let uniqueNames = Set(images)
let result = try await ClientImage.get(
names: Array(uniqueNames), containerSystemConfig: containerSystemConfig
)
if !result.error.isEmpty {
let missing = result.error.sorted()
throw ContainerizationError(
.notFound,
message: "image not found: \(missing.joined(separator: ", "))"
)
}
var printable: [ImageResource] = []
for image in result.images {
guard
!Utility.isInfraImage(
name: image.reference,
builderImage: containerSystemConfig.build.image,
initImage: containerSystemConfig.vminit.image
)
else { continue }
printable.append(
try await image.toImageResource(containerSystemConfig: containerSystemConfig)
)
}
try Output.emit(Output.renderJSON(printable, options: .pretty))
}
}
}
@@ -0,0 +1,145 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOCI
import Foundation
extension Application {
public struct ImageList: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List images",
aliases: ["ls"])
@Option(name: .long, help: "Format of the output")
var format: ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the image name")
var quiet = false
@Flag(name: .shortAndLong, help: "Verbose output")
var verbose = false
@OptionGroup
public var logOptions: Flags.Logging
public mutating func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
try Self.validate(quiet: quiet, verbose: verbose)
var images = try await ClientImage.list().filter { img in
!Utility.isInfraImage(name: img.reference, builderImage: containerSystemConfig.build.image, initImage: containerSystemConfig.vminit.image)
}
images.sort { $0.reference < $1.reference }
// Quiet mode prints references directly and skips the more expensive
// per-image manifest resolution.
if quiet && format == .table {
for image in images {
let processedReferenceString = try ClientImage.denormalizeReference(image.reference, containerSystemConfig: containerSystemConfig)
print(processedReferenceString)
}
return
}
let resources = try await Self.buildResources(images: images, containerSystemConfig: containerSystemConfig)
try Output.render(payload: resources, format: format) {
if verbose {
return Output.renderTable(resources.flatMap { VerboseImageRow.rows(for: $0) })
}
return Output.renderTable(resources)
}
}
private static func validate(quiet: Bool, verbose: Bool) throws {
if quiet && verbose {
throw ContainerizationError(.invalidArgument, message: "cannot use flag --quiet and --verbose together")
}
}
/// Builds the resource for each image, denormalizing the reference so the
/// display name omits the default registry.
private static func buildResources(images: [ClientImage], containerSystemConfig: ContainerSystemConfig) async throws -> [ImageResource] {
var resources: [ImageResource] = []
for image in images {
resources.append(
try await image.toImageResource(containerSystemConfig: containerSystemConfig)
)
}
return resources
}
}
}
/// A single row of the verbose image listing one per platform variant.
private struct VerboseImageRow: ListDisplayable {
let name: String
let tag: String
let indexDigest: String
let os: String
let arch: String
let variant: String
let fullSize: String
let created: String
let manifestDigest: String
static var tableHeader: [String] {
["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "FULL SIZE", "CREATED", "MANIFEST DIGEST"]
}
var tableRow: [String] {
[name, tag, indexDigest, os, arch, variant, fullSize, created, manifestDigest]
}
var quietValue: String {
name
}
/// Flattens an ImageResource into one verbose image row entry per platform variant.
static func rows(for resource: ImageResource) -> [VerboseImageRow] {
let formatter = ByteCountFormatter()
let reference = try? ContainerizationOCI.Reference.parse(resource.displayReference)
let name = reference?.name ?? resource.displayReference
let tag = reference?.tag ?? "<none>"
let indexDigest = Utility.trimDigest(digest: resource.configuration.descriptor.digest)
return
resource.variants
// Skip attestation manifests, which use the `unknown/unknown` platform.
.filter { !($0.platform.os == "unknown" && $0.platform.architecture == "unknown") }
.map { variant in
VerboseImageRow(
name: name,
tag: tag,
indexDigest: indexDigest,
os: variant.platform.os,
arch: variant.platform.architecture,
variant: variant.platform.variant ?? "",
fullSize: formatter.string(fromByteCount: variant.size),
created: variant.config.created ?? "",
manifestDigest: Utility.trimDigest(digest: variant.digest)
)
}
}
}
@@ -0,0 +1,113 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import Containerization
import ContainerizationError
import ContainerizationOS
import Foundation
import SystemPackage
import TerminalProgress
extension Application {
public struct ImageLoad: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "load",
abstract: "Load images from an OCI compatible tar archive"
)
@Option(
name: .shortAndLong, help: "Path to the image tar archive", completion: .file(),
transform: { str in
FilePathOps.absolutePath(FilePath(str))
})
var input: FilePath?
@Flag(name: .shortAndLong, help: "Load images even if the archive contains invalid files")
public var force = false
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
defer {
try? FileManager.default.removeItem(at: tempFile)
}
// Read from stdin; otherwise read from the input file
let resolvedPath: FilePath
if let input {
guard FileManager.default.fileExists(atPath: input.string) else {
log.error("file does not exist", metadata: ["path": "\(input)"])
Application.exit(withError: ArgumentParser.ExitCode(1))
}
resolvedPath = input
} else {
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
throw ContainerizationError(.internalError, message: "unable to create temporary file")
}
guard let fileHandle = try? FileHandle(forWritingTo: tempFile) else {
throw ContainerizationError(.internalError, message: "unable to open temporary file for writing")
}
let bufferSize = 4096
while true {
let chunk = FileHandle.standardInput.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
fileHandle.write(chunk)
}
try fileHandle.close()
resolvedPath = FilePath(tempFile.path())
}
let progressConfig = try ProgressConfig(
showTasks: true,
showItems: true,
totalTasks: 2
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
progress.set(description: "Loading tar archive")
let result = try await ClientImage.load(
from: resolvedPath.string,
force: force)
if !result.rejectedMembers.isEmpty {
log.warning("archive contains invalid members", metadata: ["paths": "\(result.rejectedMembers)"])
}
let taskManager = ProgressTaskCoordinator()
let unpackTask = await taskManager.startTask()
progress.set(description: "Unpacking image")
progress.set(itemsName: "entries")
for image in result.images {
try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
}
await taskManager.finish()
progress.finish()
for image in result.images {
print(image.reference)
}
}
}
}
@@ -0,0 +1,97 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerizationOCI
import Foundation
extension Application {
public struct ImagePrune: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "prune",
abstract: "Remove unused or all images")
@OptionGroup
public var logOptions: Flags.Logging
@Flag(name: .shortAndLong, help: "Remove all unused images, not just dangling ones")
var all: Bool = false
public func run() async throws {
let allImages = try await ClientImage.list()
let imagesToPrune: [ClientImage]
if all {
// Find all images not used by any container
let client = ContainerClient()
let containers = try await client.list()
var imagesInUse = Set<String>()
for container in containers {
imagesInUse.insert(container.configuration.image.reference)
}
imagesToPrune = allImages.filter { image in
!imagesInUse.contains(image.reference)
}
} else {
// Find dangling images (images with no tag)
imagesToPrune = allImages.filter { image in
!hasTag(image.reference)
}
}
var prunedImages = [String]()
for image in imagesToPrune {
do {
try await ClientImage.delete(reference: image.reference, garbageCollect: false)
prunedImages.append(image.reference)
} catch {
log.error(
"failed to prune image",
metadata: [
"ref": "\(image.reference)",
"error": "\(error)",
])
}
}
let (deletedDigests, size) = try await ClientImage.cleanUpOrphanedBlobs()
for image in imagesToPrune {
print("untagged \(image.reference)")
}
for digest in deletedDigests {
print("deleted \(digest)")
}
let formatter = ByteCountFormatter()
formatter.countStyle = .file
let freed = formatter.string(fromByteCount: Int64(size))
log.info("Reclaimed \(freed) in disk space")
}
private func hasTag(_ reference: String) -> Bool {
do {
let ref = try ContainerizationOCI.Reference.parse(reference)
return ref.tag != nil && !ref.tag!.isEmpty
} catch {
return false
}
}
}
}
@@ -0,0 +1,110 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import Containerization
import ContainerizationOCI
import TerminalProgress
extension Application {
public struct ImagePull: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "pull",
abstract: "Pull an image"
)
@OptionGroup
var registry: Flags.Registry
@OptionGroup
var progressFlags: Flags.Progress
@OptionGroup
var imageFetchFlags: Flags.ImageFetch
@Option(
name: .shortAndLong,
help: "Limit the pull to the specified architecture"
)
var arch: String?
@Option(
help: "Limit the pull to the specified OS"
)
var os: String?
@Option(
help: "Limit the pull to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]"
)
var platform: String?
@OptionGroup
public var logOptions: Flags.Logging
@Argument var reference: String
public init() {}
public init(platform: String? = nil, scheme: String = "auto", reference: String) {
self.logOptions = Flags.Logging()
self.registry = Flags.Registry(scheme: scheme)
self.platform = platform
self.reference = reference
}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
let processedReference = try ClientImage.normalizeReference(reference, containerSystemConfig: containerSystemConfig)
let progressConfig = try self.progressFlags.makeConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 2
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
progress.set(description: "Fetching image")
progress.set(itemsName: "blobs")
let taskManager = ProgressTaskCoordinator()
let fetchTask = await taskManager.startTask()
let image = try await ClientImage.pull(
reference: processedReference, platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig,
progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler),
maxConcurrentDownloads: self.imageFetchFlags.maxConcurrentDownloads
)
progress.set(description: "Unpacking image")
progress.set(itemsName: "entries")
let unpackTask = await taskManager.startTask()
try await image.unpack(platform: p, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler))
await taskManager.finish()
progress.finish()
}
}
}
@@ -0,0 +1,84 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import Containerization
import ContainerizationOCI
import TerminalProgress
extension Application {
public struct ImagePush: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "push",
abstract: "Push an image"
)
@OptionGroup
var registry: Flags.Registry
@OptionGroup
var progressFlags: Flags.Progress
@Option(
name: .shortAndLong,
help: "Limit the push to the specified architecture"
)
var arch: String?
@Option(
help: "Limit the push to the specified OS"
)
var os: String?
@Option(help: "Limit the push to the specified platform (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]")
var platform: String?
@OptionGroup
public var logOptions: Flags.Logging
@Argument var reference: String
public init() {}
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let scheme = try RequestScheme(registry.scheme)
let image = try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig)
let progressConfig = try self.progressFlags.makeConfig(
description: "Pushing image \(image.reference)",
itemsName: "blobs",
showItems: true,
showSpeed: false,
ignoreSmallSize: true
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
_ = try await image.push(platform: p, scheme: scheme, containerSystemConfig: containerSystemConfig, progressUpdate: progress.handler)
progress.finish()
print(image.reference)
}
}
}
@@ -0,0 +1,39 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 ContainerAPIClient
import ContainerResource
import ContainerizationOCI
extension ImageResource: ListDisplayable {
public static var tableHeader: [String] {
["NAME", "TAG", "DIGEST"]
}
public var tableRow: [String] {
// `displayReference` is already denormalized by the caller.
let reference = try? ContainerizationOCI.Reference.parse(displayReference)
return [
reference?.name ?? displayReference,
reference?.tag ?? "<none>",
Utility.trimDigest(digest: configuration.descriptor.digest),
]
}
public var quietValue: String {
name
}
}
@@ -0,0 +1,155 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
import ContainerResource
import Containerization
import ContainerizationError
import ContainerizationOCI
import ContainerizationOS
import Foundation
import SystemPackage
import TerminalProgress
extension Application {
public struct ImageSave: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "save",
abstract: "Save one or more images as an OCI compatible tar archive"
)
@Option(
name: .shortAndLong,
help: "Architecture for the saved image"
)
var arch: String?
@Option(
help: "OS for the saved image"
)
var os: String?
@Option(
name: .shortAndLong, help: "Pathname for the saved image", completion: .file(),
transform: { str in
FilePathOps.absolutePath(FilePath(str))
})
var output: FilePath?
@Option(
help: "Platform for the saved image (format: os/arch[/variant], takes precedence over --os and --arch) [environment: CONTAINER_DEFAULT_PLATFORM]"
)
var platform: String?
@OptionGroup
public var logOptions: Flags.Logging
@Argument var references: [String]
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let p = try DefaultPlatform.resolve(platform: platform, os: os, arch: arch, log: log)
let progressConfig = try ProgressConfig(
description: "Saving image(s)"
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
var images: [ImageDescription] = []
for reference in references {
do {
images.append(try await ClientImage.get(reference: reference, containerSystemConfig: containerSystemConfig).description)
} catch {
log.error("failed to get image for reference \(reference): \(error)")
}
}
guard images.count == references.count else {
throw ContainerizationError(.invalidArgument, message: "failed to save image(s)")
}
if let p {
for (reference, description) in zip(references, images) {
let image = ClientImage(description: description)
do {
_ = try await image.manifest(for: p)
} catch {
var available: [String] = []
if let index = try? await image.index() {
available = index.manifests
.compactMap { $0.platform?.description }
.filter { $0 != "unknown/unknown" }
}
let availableStr = available.isEmpty ? "none" : available.joined(separator: ", ")
throw ContainerizationError(
.invalidArgument,
message: "image \(reference) has no content for platform \(p.description); available platforms: \(availableStr)"
)
}
}
}
// Write to stdout; otherwise write to the output file
if let output {
try await ClientImage.save(references: references, out: output.string, platform: p, containerSystemConfig: containerSystemConfig)
} else {
let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("\(UUID().uuidString).tar")
defer {
try? FileManager.default.removeItem(at: tempFile)
}
guard FileManager.default.createFile(atPath: tempFile.path(), contents: nil) else {
throw ContainerizationError(.internalError, message: "unable to create temporary file")
}
try await ClientImage.save(references: references, out: tempFile.path(), platform: p, containerSystemConfig: containerSystemConfig)
guard let fileHandle = try? FileHandle(forReadingFrom: tempFile) else {
throw ContainerizationError(.internalError, message: "unable to open temporary file for reading")
}
let bufferSize = 4096
while true {
let chunk = fileHandle.readData(ofLength: bufferSize)
if chunk.isEmpty { break }
FileHandle.standardOutput.write(chunk)
}
try fileHandle.close()
}
progress.finish()
for reference in references {
if output == nil {
// stdout is carrying the OCI archive in this branch, so the
// saved-reference list goes to stderr via the logger. Printing
// it to stdout appends non-archive bytes after the tar EOF and
// corrupts the stream for redirection and pipelines (#1801).
log.info("\(reference)")
} else {
print(reference)
}
}
}
}
}
@@ -0,0 +1,46 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025-2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerPlugin
extension Application {
public struct ImageTag: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "tag",
abstract: "Create a new reference for an existing image")
@Argument(help: "The existing image reference (format: image-name[:tag])")
var source: String
@Argument(help: "The new image reference")
var target: String
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig()
let existing = try await ClientImage.get(reference: source, containerSystemConfig: containerSystemConfig)
let targetReference = try ClientImage.normalizeReference(target, containerSystemConfig: containerSystemConfig)
try await existing.tag(new: targetReference)
print(target)
}
}
}
@@ -0,0 +1,29 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 type that can be rendered as a table row or quiet-mode output.
///
/// Conformers provide the column headers, row values, and a primary identifier
/// for quiet mode. JSON encoding is handled separately by each command using
/// its own data model.
public protocol ListDisplayable {
/// Column headers for table output (e.g., `["ID", "IMAGE", "STATE"]`).
static var tableHeader: [String] { get }
/// The values for each column, matching the order of ``tableHeader``.
var tableRow: [String] { get }
/// The primary identifier shown in `--quiet` mode (typically ID or name).
var quietValue: String { get }
}
@@ -0,0 +1,24 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
public enum ListFormat: String, CaseIterable, ExpressibleByArgument, Sendable {
case json
case table
case yaml
case toml
}
@@ -0,0 +1,32 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 Virtualization
/// Pre-flight checks for container machine settings that depend on host capabilities.
enum MachineCapabilities {
/// Throws when the host can't run a VM with `isNestedVirtualizationEnabled = true`.
/// Apple Silicon M3+ with macOS 15+ is required.
static func requireNestedVirtualizationSupported() throws {
guard VZGenericPlatformConfiguration.isNestedVirtualizationSupported else {
throw ContainerizationError(
.unsupported,
message: "nested virtualization is not supported on this host (requires Apple Silicon M3+ and macOS 15+)"
)
}
}
}
@@ -0,0 +1,69 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
extension Application {
public struct MachineCommand: AsyncLoggableCommand {
public static let configuration = CommandConfiguration(
commandName: "machine",
abstract: "Manage container machines",
discussion: """
EXAMPLES:
List available images and create a container machine:
$ container machine create alpine:3.22 --name my-machine
Run commands in the container machine:
$ container machine run -n my-machine uname
$ container machine run -n my-machine -- cat /proc/cpuinfo
Change the container machine configuration (takes effect after restart):
$ container machine set -n my-machine cpus=4 memory=8G home-mount=ro
$ container machine stop my-machine
$ container machine run -n my-machine -- nproc
Stop and delete the container machine:
$ container machine stop my-machine
$ container machine delete my-machine
""",
subcommands: [
MachineCreate.self,
MachineDelete.self,
MachineInspect.self,
MachineList.self,
MachineLogs.self,
MachineRun.self,
MachineSet.self,
MachineSetDefault.self,
MachineStop.self,
],
aliases: ["m"]
)
public init() {}
@OptionGroup
public var logOptions: Flags.Logging
}
}
extension Application.MachineCommand {
public enum ListFormat: String, CaseIterable, ExpressibleByArgument {
case json
case table
}
}
@@ -0,0 +1,153 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerizationError
import ContainerizationOCI
import Foundation
import MachineAPIClient
import TerminalProgress
extension Application {
public struct MachineCreate: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "create",
abstract: "Create a new container machine and boot it")
@OptionGroup(title: "Management options")
var managementFlags: Flags.MachineManagement
@OptionGroup(title: "Registry options")
var registryFlags: Flags.Registry
@OptionGroup(title: "Progress options")
var progressFlags: Flags.Progress
@OptionGroup(title: "Image fetch options")
var imageFetchFlags: Flags.ImageFetch
@OptionGroup
public var logOptions: Flags.Logging
@Option(name: [.short, .long], help: "Name for the container machine")
public var name: String?
@Flag(name: .long, help: "Set this container machine as the default")
public var setDefault: Bool = false
@Flag(name: .long, help: "Create the container machine without booting it")
public var noBoot: Bool = false
@Option(name: .long, help: "Number of virtual CPUs")
public var cpus: Int?
@Option(name: .long, help: "Memory allocation (e.g., 2G, 8G). Default: half of system memory")
public var memory: String?
@Option(name: .long, help: "User's home directory mount option (ro, rw, none). Default: rw")
public var homeMount: String?
@Flag(name: .long, help: "Enable nested virtualization (requires Apple Silicon M3+ and macOS 15+ and kernel with CONFIG_KVM=y)")
public var virtualization: Bool = false
@Option(name: .long, help: "Path to a custom kernel binary (e.g. vmlinux).")
public var kernel: String?
@Argument(help: "Container image reference (e.g., alpine:3.22)")
var image: String
public func run() async throws {
if virtualization {
try MachineCapabilities.requireNestedVirtualizationSupported()
}
let resolvedKernel = try kernel.map { try MachineConfig.validateKernelPath($0) }
let progressConfig = try self.progressFlags.makeConfig(
showTasks: true,
showItems: true,
ignoreSmallSize: true,
totalTasks: 3
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load()
let defaultConfig = containerSystemConfig.machine
let bootConfig = try defaultConfig.with(
[
"cpus": cpus.map { "\($0)" },
"memory": memory,
"home-mount": homeMount,
"virtualization": virtualization ? "true" : nil,
"kernel": resolvedKernel?.string,
].compactMapValues { $0 }
)
let id: String
if let name {
id = name
} else {
let reference = try Reference.parse(image)
reference.normalize()
let imageName = reference.name.components(separatedBy: "/").last!
let suffix = reference.tag ?? reference.digest ?? "latest"
id = "\(imageName)-\(suffix)"
}
try Utility.validEntityName(id)
let client = MachineClient()
let (config, resources) = try await MachineClient.machineConfigFromFlags(
id: id,
image: image,
management: managementFlags,
registry: registryFlags,
imageFetch: imageFetchFlags,
containerSystemConfig: containerSystemConfig,
progressUpdate: progress.handler
)
do {
try await client.create(configuration: config, resources: resources, bootConfig: bootConfig)
progress.finish() // Finish before subsequent output to avoid mangling
} catch let error as ContainerizationError {
if let cause = error.cause as? ContainerizationError, cause.isCode(.exists) {
let append = name == nil ? " (missing '-n/--name' flag)" : ""
throw ContainerizationError(.exists, message: cause.message + append)
}
throw error
}
if setDefault {
try await client.setDefault(id: id)
}
if !noBoot {
try await bootMachine(id: id, client: client, log: log, interactive: false)
}
print(id)
}
}
}
@@ -0,0 +1,66 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import MachineAPIClient
import TerminalProgress
extension Application {
public struct MachineDelete: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "delete",
abstract: "Delete a container machine",
aliases: ["rm"]
)
@OptionGroup
public var logOptions: Flags.Logging
@OptionGroup(visibility: .hidden)
var progressFlags: Flags.Progress
@Argument(help: "Container machine ID")
var id: String
public func run() async throws {
let client = MachineClient()
let wasDefault = try await client.getDefault() == id
let progressConfig = try self.progressFlags.makeConfig(
description: "Deleting container machine"
)
let progress = ProgressBar(config: progressConfig)
defer {
progress.finish()
}
progress.start()
try? await client.stop(id: id)
try await client.delete(id: id)
progress.finish()
print(id)
if wasDefault {
log.info("Deleted default container '\(id)'. Set a new default with 'container machine set-default <id>'.")
}
}
}
}
@@ -0,0 +1,106 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container 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 ContainerAPIClient
import ContainerResource
import ContainerizationError
import Foundation
import Logging
import MachineAPIClient
/// Resolves a container machine ID from an optional argument, falling back to the default machine.
func resolveMachineId(_ id: String?, client: MachineClient) async throws -> String {
if let id {
return id
}
guard let defaultId = try await client.getDefault() else {
throw ContainerizationError(
.invalidArgument,
message: "no container machine specified and no default set"
)
}
return defaultId
}
/// Boots a container machine and, on first ever boot, runs the in-VM init script
/// to set up the host user. Returns the resulting snapshot.
///
/// When `interactive` is true the init script is wired to the host's terminal
/// (used by `machine run`); otherwise it runs detached so non-TTY callers like
/// `machine create` don't require a TTY or pollute host stdout.
///
/// On any failure during user setup the machine is stopped to leave it in a clean state.
@discardableResult
func bootMachine(
id: String?,
client: MachineClient,
log: Logger,
interactive: Bool
) async throws -> MachineSnapshot {
var dynamicEnv: [String: String] = [:]
if let sshAuthSock = ProcessInfo.processInfo.environment["SSH_AUTH_SOCK"] {
dynamicEnv["SSH_AUTH_SOCK"] = sshAuthSock
}
let snapshot = try await client.boot(id: id, dynamicEnv: dynamicEnv)
guard !snapshot.initialized else {
return snapshot
}
do {
guard let containerId = snapshot.containerId else {
throw ContainerizationError(
.invalidState,
message: "container machine is running but has no container ID"
)
}
let io = try ProcessIO.create(
tty: interactive,
interactive: interactive,
detach: !interactive
)
defer {
try? io.close()
}
let processConfig = ProcessConfiguration(
executable: "/\(MachineBundle.sbinDirectory)/\(MachineBundle.initFile)",
arguments: ["-u"],
environment: snapshot.configuration.processEnvironment,
terminal: interactive
)
let process = try await ContainerClient().createProcess(
containerId: containerId,
processId: UUID().uuidString.lowercased(),
configuration: processConfig,
stdio: io.stdio)
let exitCode = try await io.handleProcess(process: process, log: log)
guard exitCode == 0 else {
throw ContainerizationError(
.invalidState,
message: "container machine failed to create user"
)
}
} catch {
try? await client.stop(id: snapshot.id)
throw error
}
return snapshot
}
@@ -0,0 +1,85 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerPersistence
import ContainerResource
import ContainerizationOCI
import Foundation
import MachineAPIClient
extension Application {
public struct MachineInspect: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "inspect",
abstract: "Display detailed information about a container machine"
)
@OptionGroup
public var logOptions: Flags.Logging
@Argument(help: "Container machine ID (uses default if not specified)")
var id: String?
public func run() async throws {
let client = MachineClient()
let machineId = try await resolveMachineId(id, client: client)
let snapshot = try await client.inspect(id: machineId)
let output = InspectOutput(snapshot)
let encoder = JSONEncoder()
encoder.outputFormatting = [.prettyPrinted, .sortedKeys]
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode([output])
print(String(decoding: data, as: UTF8.self))
}
}
}
private struct InspectOutput: Codable {
let id: String
let image: ImageDescription
let platform: ContainerizationOCI.Platform
let userSetup: UserSetup
let status: RuntimeStatus
let startedDate: Date?
let createdDate: Date?
let containerId: String?
let cpus: Int
let memory: UInt64
let homeMount: MachineConfig.HomeMountOption
let diskSize: UInt64?
let ipAddress: String?
init(_ snapshot: MachineSnapshot) {
self.id = snapshot.id
self.image = snapshot.configuration.image
self.platform = snapshot.platform
self.userSetup = snapshot.configuration.userSetup
self.status = snapshot.status
self.startedDate = snapshot.startedDate
self.createdDate = snapshot.createdDate
self.containerId = snapshot.containerId
self.cpus = snapshot.bootConfig.cpus
self.memory = snapshot.bootConfig.memory.toUInt64(unit: .bytes)
self.homeMount = snapshot.bootConfig.homeMount
self.diskSize = snapshot.diskSize
self.ipAddress = snapshot.ipAddress
}
}
@@ -0,0 +1,137 @@
//===----------------------------------------------------------------------===//
// Copyright © 2026 Apple Inc. and the container project authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//===----------------------------------------------------------------------===//
import ArgumentParser
import ContainerAPIClient
import ContainerResource
import Foundation
import MachineAPIClient
extension Application {
public struct MachineList: AsyncLoggableCommand {
public init() {}
public static let configuration = CommandConfiguration(
commandName: "list",
abstract: "List container machines",
aliases: ["ls"]
)
@Option(name: .long, help: "Format of the output")
var format: MachineCommand.ListFormat = .table
@Flag(name: .shortAndLong, help: "Only output the container machine ID")
var quiet = false
@OptionGroup
public var logOptions: Flags.Logging
public func run() async throws {
let client = MachineClient()
let machines = try await client.list()
if self.quiet {
machines.forEach { print($0.id) }
return
}
let defaultMachine = try await client.getDefault()
try printMachines(machines: machines, format: format, defaultMachine: defaultMachine)
}
private func printMachines(
machines: [MachineSnapshot],
format: MachineCommand.ListFormat,
defaultMachine: String?
) throws {
if format == .json {
let printables = machines.map {
PrintableMachine($0, isDefault: $0.id == defaultMachine)
}
let encoder = JSONEncoder()
encoder.dateEncodingStrategy = .iso8601
let data = try encoder.encode(printables)
print(String(decoding: data, as: UTF8.self))
return
}
var rows: [[String]] = [["NAME", "CREATED", "IP", "CPUS", "MEMORY", "DISK", "STATE", "DEFAULT"]]
for machine in machines {
rows.append([
machine.id,
machine.createdDate.map { formatDate($0) } ?? "-",
machine.ipAddress ?? "-",
"\(machine.bootConfig.cpus)",
formatMemory(machine.bootConfig.memory.toUInt64(unit: .bytes)),
machine.diskSize.map { formatMemory($0) } ?? "-",
machine.status.rawValue,
machine.id == defaultMachine ? "*" : "",
])
}
let formatter = TableOutput(rows: rows)
print(formatter.format())
}
}
}
private func formatMemory(_ bytes: UInt64) -> String {
let gib: UInt64 = 1024 * 1024 * 1024
if bytes >= gib {
if bytes % gib == 0 {
return "\(bytes / gib)G"
}
let formatted = String(format: "%.1fG", Double(bytes) / Double(gib))
if formatted.hasSuffix(".0G") {
return "\(bytes / gib)G"
}
return formatted
}
return "\(bytes / (1024 * 1024))M"
}
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.dateFormat = "yyyy-MM-dd HH:mm:ss"
return formatter
}()
private func formatDate(_ date: Date) -> String {
dateFormatter.string(from: date)
}
private struct PrintableMachine: Codable {
let id: String
let status: RuntimeStatus
let `default`: Bool
let ipAddress: String?
let cpus: Int
let memory: UInt64
let diskSize: UInt64?
let createdDate: Date?
init(_ machine: MachineSnapshot, isDefault: Bool) {
self.id = machine.id
self.status = machine.status
self.default = isDefault
self.ipAddress = machine.ipAddress
self.cpus = machine.bootConfig.cpus
self.memory = machine.bootConfig.memory.toUInt64(unit: .bytes)
self.diskSize = machine.diskSize
self.createdDate = machine.createdDate
}
}

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