chore: import upstream snapshot with attribution
container project - merge build / Invoke build (push) Successful in 1s
container project - merge build / Invoke build (push) Successful in 1s
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -0,0 +1,12 @@
|
||||
version: 2
|
||||
updates:
|
||||
- package-ecosystem: "github-actions"
|
||||
directory: "/"
|
||||
schedule:
|
||||
interval: "weekly"
|
||||
groups:
|
||||
github-actions:
|
||||
patterns:
|
||||
- "*"
|
||||
commit-message:
|
||||
prefix: "ci"
|
||||
@@ -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/**'
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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/
|
||||
@@ -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'
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"fileScopedDeclarationPrivacy" : {
|
||||
"accessLevel" : "private"
|
||||
},
|
||||
"indentation" : {
|
||||
"spaces" : 4
|
||||
},
|
||||
"indentConditionalCompilationBlocks" : false,
|
||||
"indentSwitchCaseLabels" : false,
|
||||
"lineBreakAroundMultilineExpressionChainComponents" : false,
|
||||
"lineBreakBeforeControlFlowKeywords" : false,
|
||||
"lineBreakBeforeEachArgument" : false,
|
||||
"lineBreakBeforeEachGenericRequirement" : false,
|
||||
"lineLength" : 180,
|
||||
"maximumBlankLines" : 1,
|
||||
"multiElementCollectionTrailingCommas" : true,
|
||||
"noAssignmentInExpressions" : {
|
||||
"allowedFunctions" : [
|
||||
"XCTAssertNoThrow"
|
||||
]
|
||||
},
|
||||
"prioritizeKeepingFunctionOutputTogether" : false,
|
||||
"respectsExistingLineBreaks" : true,
|
||||
"rules" : {
|
||||
"AllPublicDeclarationsHaveDocumentation" : false,
|
||||
"AlwaysUseLowerCamelCase" : true,
|
||||
"AmbiguousTrailingClosureOverload" : false,
|
||||
"BeginDocumentationCommentWithOneLineSummary" : false,
|
||||
"DoNotUseSemicolons" : true,
|
||||
"DontRepeatTypeInStaticProperties" : true,
|
||||
"FileScopedDeclarationPrivacy" : true,
|
||||
"FullyIndirectEnum" : true,
|
||||
"GroupNumericLiterals" : true,
|
||||
"IdentifiersMustBeASCII" : true,
|
||||
"NeverForceUnwrap" : true,
|
||||
"NeverUseForceTry" : true,
|
||||
"NeverUseImplicitlyUnwrappedOptionals" : true,
|
||||
"NoAccessLevelOnExtensionDeclaration" : true,
|
||||
"NoAssignmentInExpressions" : true,
|
||||
"NoBlockComments" : false,
|
||||
"NoCasesWithOnlyFallthrough" : true,
|
||||
"NoEmptyTrailingClosureParentheses" : true,
|
||||
"NoLabelsInCasePatterns" : true,
|
||||
"NoLeadingUnderscores" : false,
|
||||
"NoParensAroundConditions" : true,
|
||||
"NoPlaygroundLiterals" : true,
|
||||
"NoVoidReturnOnFunctionSignature" : true,
|
||||
"OmitExplicitReturns" : true,
|
||||
"OneCasePerLine" : true,
|
||||
"OneVariableDeclarationPerLine" : true,
|
||||
"OnlyOneTrailingClosureArgument" : true,
|
||||
"OrderedImports" : true,
|
||||
"ReplaceForEachWithForLoop" : true,
|
||||
"ReturnVoidInsteadOfEmptyTuple" : true,
|
||||
"TypeNamesShouldBeCapitalized" : true,
|
||||
"UseEarlyExits" : true,
|
||||
"UseLetInEveryBoundCaseVariable" : true,
|
||||
"UseShorthandTypeNames" : true,
|
||||
"UseSingleLinePropertyGetter" : true,
|
||||
"UseSynthesizedInitializer" : true,
|
||||
"UseTripleSlashForDocumentationComments" : true,
|
||||
"UseWhereClausesInForLoops" : false,
|
||||
"ValidateDocumentationComments" : true
|
||||
},
|
||||
"spacesAroundRangeFormationOperators" : false,
|
||||
"tabWidth" : 2,
|
||||
"version" : 1
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"fileScopedDeclarationPrivacy" : {
|
||||
"accessLevel" : "private"
|
||||
},
|
||||
"indentation" : {
|
||||
"spaces" : 4
|
||||
},
|
||||
"indentConditionalCompilationBlocks" : false,
|
||||
"indentSwitchCaseLabels" : false,
|
||||
"lineBreakAroundMultilineExpressionChainComponents" : false,
|
||||
"lineBreakBeforeControlFlowKeywords" : false,
|
||||
"lineBreakBeforeEachArgument" : false,
|
||||
"lineBreakBeforeEachGenericRequirement" : false,
|
||||
"lineLength" : 180,
|
||||
"maximumBlankLines" : 1,
|
||||
"multiElementCollectionTrailingCommas" : true,
|
||||
"noAssignmentInExpressions" : {
|
||||
"allowedFunctions" : [
|
||||
"XCTAssertNoThrow"
|
||||
]
|
||||
},
|
||||
"prioritizeKeepingFunctionOutputTogether" : false,
|
||||
"respectsExistingLineBreaks" : true,
|
||||
"rules" : {
|
||||
"AllPublicDeclarationsHaveDocumentation" : false,
|
||||
"AlwaysUseLowerCamelCase" : false,
|
||||
"AmbiguousTrailingClosureOverload" : false,
|
||||
"BeginDocumentationCommentWithOneLineSummary" : false,
|
||||
"DoNotUseSemicolons" : true,
|
||||
"DontRepeatTypeInStaticProperties" : false,
|
||||
"FileScopedDeclarationPrivacy" : false,
|
||||
"FullyIndirectEnum" : false,
|
||||
"GroupNumericLiterals" : false,
|
||||
"IdentifiersMustBeASCII" : false,
|
||||
"NeverForceUnwrap" : false,
|
||||
"NeverUseForceTry" : false,
|
||||
"NeverUseImplicitlyUnwrappedOptionals" : false,
|
||||
"NoAccessLevelOnExtensionDeclaration" : false,
|
||||
"NoAssignmentInExpressions" : false,
|
||||
"NoBlockComments" : false,
|
||||
"NoCasesWithOnlyFallthrough" : false,
|
||||
"NoEmptyTrailingClosureParentheses" : true,
|
||||
"NoLabelsInCasePatterns" : false,
|
||||
"NoLeadingUnderscores" : false,
|
||||
"NoParensAroundConditions" : true,
|
||||
"NoPlaygroundLiterals" : false,
|
||||
"NoVoidReturnOnFunctionSignature" : true,
|
||||
"OmitExplicitReturns" : false,
|
||||
"OneCasePerLine" : true,
|
||||
"OneVariableDeclarationPerLine" : true,
|
||||
"OnlyOneTrailingClosureArgument" : false,
|
||||
"OrderedImports" : true,
|
||||
"ReplaceForEachWithForLoop" : false,
|
||||
"ReturnVoidInsteadOfEmptyTuple" : false,
|
||||
"TypeNamesShouldBeCapitalized" : false,
|
||||
"UseEarlyExits" : false,
|
||||
"UseLetInEveryBoundCaseVariable" : false,
|
||||
"UseShorthandTypeNames" : true,
|
||||
"UseSingleLinePropertyGetter" : true,
|
||||
"UseSynthesizedInitializer" : false,
|
||||
"UseTripleSlashForDocumentationComments" : true,
|
||||
"UseWhereClausesInForLoops" : false,
|
||||
"ValidateDocumentationComments" : false
|
||||
},
|
||||
"spacesAroundRangeFormationOperators" : false,
|
||||
"tabWidth" : 2,
|
||||
"version" : 1
|
||||
}
|
||||
+195
@@ -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`.
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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!
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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"]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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
|
||||
@@ -0,0 +1,92 @@
|
||||
<h1>
|
||||
<img alt="Containerization logo" src="./assets/Containerization-Logo.png" width="70" valign="middle">
|
||||
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.
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`apple/container`
|
||||
- 原始仓库:https://github.com/apple/container
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
# Security disclosure process
|
||||
|
||||
If you believe that you have discovered a security or privacy vulnerability in our open source software, please report it to us using the [GitHub private vulnerability feature](https://github.com/apple/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.
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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],
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
@@ -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);
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: "="))
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user