commit e84fb4a79e470c9547fe9f9bd9058c0203c6147a Author: wehub-resource-sync Date: Mon Jul 13 12:06:18 2026 +0800 chore: import upstream snapshot with attribution diff --git a/.github/ISSUE_TEMPLATE/01-bug.yml b/.github/ISSUE_TEMPLATE/01-bug.yml new file mode 100644 index 0000000..767923c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/01-bug.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/02-feature.yml b/.github/ISSUE_TEMPLATE/02-feature.yml new file mode 100644 index 0000000..81d330a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/02-feature.yml @@ -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 diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..4a3a318 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -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. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b6b49db --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,12 @@ +version: 2 +updates: + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + github-actions: + patterns: + - "*" + commit-message: + prefix: "ci" diff --git a/.github/labeler.yml b/.github/labeler.yml new file mode 100644 index 0000000..1edb1e8 --- /dev/null +++ b/.github/labeler.yml @@ -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/**' \ No newline at end of file diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..6954a85 --- /dev/null +++ b/.github/pull_request_template.md @@ -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 diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml new file mode 100644 index 0000000..0a33702 --- /dev/null +++ b/.github/workflows/common.yml @@ -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" diff --git a/.github/workflows/docs-release.yml b/.github/workflows/docs-release.yml new file mode 100644 index 0000000..d623928 --- /dev/null +++ b/.github/workflows/docs-release.yml @@ -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 diff --git a/.github/workflows/merge-build.yml b/.github/workflows/merge-build.yml new file mode 100644 index 0000000..2933f29 --- /dev/null +++ b/.github/workflows/merge-build.yml @@ -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 diff --git a/.github/workflows/pr-build.yml b/.github/workflows/pr-build.yml new file mode 100644 index 0000000..256799b --- /dev/null +++ b/.github/workflows/pr-build.yml @@ -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 diff --git a/.github/workflows/pr-coverage-comment.yml b/.github/workflows/pr-coverage-comment.yml new file mode 100644 index 0000000..c189e9f --- /dev/null +++ b/.github/workflows/pr-coverage-comment.yml @@ -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="" + BODY=$(cat < ./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 \ No newline at end of file diff --git a/.github/workflows/pr-label-apply.yml b/.github/workflows/pr-label-apply.yml new file mode 100644 index 0000000..f3f4dd7 --- /dev/null +++ b/.github/workflows/pr-label-apply.yml @@ -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 \ No newline at end of file diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..5051c1e --- /dev/null +++ b/.github/workflows/release.yml @@ -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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..429f000 --- /dev/null +++ b/.gitignore @@ -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/ diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..d7f94f9 --- /dev/null +++ b/.spi.yml @@ -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' diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..dcd6005 --- /dev/null +++ b/.swift-format @@ -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 +} diff --git a/.swift-format-nolint b/.swift-format-nolint new file mode 100644 index 0000000..8f600e5 --- /dev/null +++ b/.swift-format-nolint @@ -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 +} diff --git a/BUILDING.md b/BUILDING.md new file mode 100644 index 0000000..8a7e1ce --- /dev/null +++ b/BUILDING.md @@ -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`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..108e28d --- /dev/null +++ b/CONTRIBUTING.md @@ -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. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/MAINTAINERS.txt b/MAINTAINERS.txt new file mode 100644 index 0000000..76326cc --- /dev/null +++ b/MAINTAINERS.txt @@ -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! diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..1a25241 --- /dev/null +++ b/Makefile @@ -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 diff --git a/NOTICE.md b/NOTICE.md new file mode 100644 index 0000000..0fd5343 --- /dev/null +++ b/NOTICE.md @@ -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. diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..1394a26 --- /dev/null +++ b/Package.resolved @@ -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 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..a6ddfb9 --- /dev/null +++ b/Package.swift @@ -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"] + ), + ] +) diff --git a/Protobuf.Makefile b/Protobuf.Makefile new file mode 100644 index 0000000..4884409 --- /dev/null +++ b/Protobuf.Makefile @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f507e3c --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +

+ Containerization logo +  container +

+ +`container` is a tool that you can use to create and run Linux containers as lightweight virtual machines on your Mac. It's written in Swift, and optimized for Apple silicon. + +The tool consumes and produces [OCI-compatible container images](https://github.com/opencontainers/image-spec), so you can pull and run images from any standard container registry. You can push images that you build to those registries as well, and run the images in any other OCI-compatible application. + +`container` uses the [Containerization](https://github.com/apple/containerization) Swift package for low-level container, image, and process management. + +![introductory movie showing some basic commands](./docs/assets/landing-movie.gif) + +## Get started + +### Requirements + +You need a Mac with Apple silicon to run `container`. To build it, see the [BUILDING](./BUILDING.md) document. + +`container` is supported on macOS 26, since it takes advantage of new features and enhancements to virtualization and networking in this release. We do not support older versions of macOS and the `container` maintainers typically will not address issues that cannot be reproduced on macOS 26. + +### Initial install + +Download the latest signed installer package for `container` from the [GitHub release page](https://github.com/apple/container/releases). + +To install the tool, double-click the package file and follow the instructions. Enter your administrator password when prompted, to give the installer permission to place the installed files under `/usr/local`. + +Start the system service with: + +```bash +container system start +``` + +### Upgrade or downgrade + +For both upgrading and downgrading, you can manually download and install the signed installer package by following the steps from [initial install](#initial-install) or use the `update-container.sh` script (installed to `/usr/local/bin`). + +If you're upgrading or downgrading, you must stop your existing `container`: + +```bash +container system stop +``` + +To upgrade to the latest release, simply run the command below: + +```bash +/usr/local/bin/update-container.sh +``` + +To downgrade, you must uninstall your existing `container` (the `-k` flag keeps your user data, while `-d` removes it): + +```bash +/usr/local/bin/uninstall-container.sh -k +/usr/local/bin/update-container.sh -v 0.3.0 +``` + +Start the system service with: + +```bash +container system start +``` + +### Uninstall + +Use the `uninstall-container.sh` script (installed to `/usr/local/bin`) to remove `container` from your system. To remove your user data along with the tool, run: + +```bash +/usr/local/bin/uninstall-container.sh -d +``` + +To retain your user data so that it is available should you reinstall later, run: + +```bash +/usr/local/bin/uninstall-container.sh -k +``` + +## Next steps + +- Take [a guided tour of `container`](./docs/tutorials/start-here.md) by building, running, and publishing a simple web server image. +- Learn how to [use various `container` features](./docs/how-to.md). +- Read a brief description and [technical overview](./docs/technical-overview.md) of `container`. +- Browse the [full command reference](./docs/command-reference.md). +- [Build and run](./BUILDING.md) `container` on your own development system. +- View the project [API documentation](https://apple.github.io/container/documentation/). + +## Contributing + +Contributions to `container` are welcome and encouraged. Please see our [main contributing guide](https://github.com/apple/containerization/blob/main/CONTRIBUTING.md) for more information. + +## Project Status + +The container project is currently under active development. Its stability, both for consuming the project as a Swift package and the `container` tool, is only guaranteed within patch versions, such as between 0.1.1 and 0.1.2. Minor version releases may include breaking changes until we reach a 1.0.0 release. diff --git a/README.wehub.md b/README.wehub.md new file mode 100644 index 0000000..1a33e11 --- /dev/null +++ b/README.wehub.md @@ -0,0 +1,7 @@ +# WeHub 来源说明 + +- 原始项目:`apple/container` +- 原始仓库:https://github.com/apple/container +- 导入方式:上游默认分支的最新快照 +- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准 +- 本文件仅用于记录来源,不代表 WeHub 是原项目作者 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..bb230b0 --- /dev/null +++ b/SECURITY.md @@ -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. diff --git a/Sources/APIServer/APIServer+Start.swift b/Sources/APIServer/APIServer+Start.swift new file mode 100644 index 0000000..b6038b5 --- /dev/null +++ b/Sources/APIServer/APIServer+Start.swift @@ -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.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) + } + } +} diff --git a/Sources/APIServer/APIServer.swift b/Sources/APIServer/APIServer.swift new file mode 100644 index 0000000..fa6a4a6 --- /dev/null +++ b/Sources/APIServer/APIServer.swift @@ -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], + ) +} diff --git a/Sources/APIServer/ContainerDNSHandler.swift b/Sources/APIServer/ContainerDNSHandler.swift new file mode 100644 index 0000000..78a2074 --- /dev/null +++ b/Sources/APIServer/ContainerDNSHandler.swift @@ -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(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(name: question.name, ttl: ttl, ip: ip), true) + } +} diff --git a/Sources/APIServer/LocalhostDNSHandler.swift b/Sources/APIServer/LocalhostDNSHandler.swift new file mode 100644 index 0000000..7d3c5f8 --- /dev/null +++ b/Sources/APIServer/LocalhostDNSHandler.swift @@ -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(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 + } +} diff --git a/Sources/CAuditToken/AuditToken.c b/Sources/CAuditToken/AuditToken.c new file mode 100644 index 0000000..4820b9e --- /dev/null +++ b/Sources/CAuditToken/AuditToken.c @@ -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`. diff --git a/Sources/CAuditToken/include/AuditToken.h b/Sources/CAuditToken/include/AuditToken.h new file mode 100644 index 0000000..378d379 --- /dev/null +++ b/Sources/CAuditToken/include/AuditToken.h @@ -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 +#include + +void xpc_dictionary_get_audit_token(xpc_object_t xdict, audit_token_t *token); diff --git a/Sources/CLI/ContainerCLI.swift b/Sources/CLI/ContainerCLI.swift new file mode 100644 index 0000000..9646d9d --- /dev/null +++ b/Sources/CLI/ContainerCLI.swift @@ -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() + } +} diff --git a/Sources/CVersion/Version.c b/Sources/CVersion/Version.c new file mode 100644 index 0000000..42694b8 --- /dev/null +++ b/Sources/CVersion/Version.c @@ -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; +} diff --git a/Sources/CVersion/include/Version.h b/Sources/CVersion/include/Version.h new file mode 100644 index 0000000..e8f1e71 --- /dev/null +++ b/Sources/CVersion/include/Version.h @@ -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(); diff --git a/Sources/ContainerBuild/BuildAPI+Extensions.swift b/Sources/ContainerBuild/BuildAPI+Extensions.swift new file mode 100644 index 0000000..b052b10 --- /dev/null +++ b/Sources/ContainerBuild/BuildAPI+Extensions.swift @@ -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 + } +} diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift new file mode 100644 index 0000000..7b4fc44 --- /dev/null +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -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.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.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.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.Continuation, + _ packet: BuildTransfer, + _ buildID: String + ) async throws { + let wantsTar = packet.mode() == "tar" + + var entries: [String: Set] = [:] + 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], + 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 + } +} diff --git a/Sources/ContainerBuild/BuildFile.swift b/Sources/ContainerBuild/BuildFile.swift new file mode 100644 index 0000000..98f704c --- /dev/null +++ b/Sources/ContainerBuild/BuildFile.swift @@ -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 + } +} diff --git a/Sources/ContainerBuild/BuildImageResolver.swift b/Sources/ContainerBuild/BuildImageResolver.swift new file mode 100644 index 0000000..77f5b49 --- /dev/null +++ b/Sources/ContainerBuild/BuildImageResolver.swift @@ -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.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" + } + } + } +} diff --git a/Sources/ContainerBuild/BuildPipelineHandler.swift b/Sources/ContainerBuild/BuildPipelineHandler.swift new file mode 100644 index 0000000..6ee36e8 --- /dev/null +++ b/Sources/ContainerBuild/BuildPipelineHandler.swift @@ -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.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( + sender: AsyncStream.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>.Continuation? + let tasks = AsyncStream> { 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 { 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! + } + } +} diff --git a/Sources/ContainerBuild/BuildRemoteContentProxy.swift b/Sources/ContainerBuild/BuildRemoteContentProxy.swift new file mode 100644 index 0000000..e6cb1cb --- /dev/null +++ b/Sources/ContainerBuild/BuildRemoteContentProxy.swift @@ -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.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.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.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.Continuation, _ packet: ImageTransfer) async throws { + throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.delete)"]) + } + + func update(_ sender: AsyncStream.Continuation, _ packet: ImageTransfer) async throws { + throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.update)"]) + } + + func walk(_ sender: AsyncStream.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)" + } + } + } + +} diff --git a/Sources/ContainerBuild/BuildStdio.swift b/Sources/ContainerBuild/BuildStdio.swift new file mode 100644 index 0000000..3242948 --- /dev/null +++ b/Sources/ContainerBuild/BuildStdio.swift @@ -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.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" + } + } + } +} diff --git a/Sources/ContainerBuild/Builder.grpc.swift b/Sources/ContainerBuild/Builder.grpc.swift new file mode 100644 index 0000000..d0bdd97 --- /dev/null +++ b/Sources/ContainerBuild/Builder.grpc.swift @@ -0,0 +1,1219 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the gRPC Swift generator plugin for the protocol buffer compiler. +// Source: Builder.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/grpc/grpc-swift + +import GRPCCore +import GRPCProtobuf + +// MARK: - com.apple.container.build.v1.Builder + +/// Namespace containing generated types for the "com.apple.container.build.v1.Builder" service. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +public enum Com_Apple_Container_Build_V1_Builder: Sendable { + /// Service descriptor for the "com.apple.container.build.v1.Builder" service. + public static let descriptor = GRPCCore.ServiceDescriptor(fullyQualifiedService: "com.apple.container.build.v1.Builder") + /// Namespace for method metadata. + public enum Method: Sendable { + /// Namespace for "CreateBuild" metadata. + public enum CreateBuild: Sendable { + /// Request type for "CreateBuild". + public typealias Input = Com_Apple_Container_Build_V1_CreateBuildRequest + /// Response type for "CreateBuild". + public typealias Output = Com_Apple_Container_Build_V1_CreateBuildResponse + /// Descriptor for "CreateBuild". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "com.apple.container.build.v1.Builder"), + method: "CreateBuild", + type: .unary + ) + } + /// Namespace for "PerformBuild" metadata. + public enum PerformBuild: Sendable { + /// Request type for "PerformBuild". + public typealias Input = Com_Apple_Container_Build_V1_ClientStream + /// Response type for "PerformBuild". + public typealias Output = Com_Apple_Container_Build_V1_ServerStream + /// Descriptor for "PerformBuild". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "com.apple.container.build.v1.Builder"), + method: "PerformBuild", + type: .bidirectionalStreaming + ) + } + /// Namespace for "Info" metadata. + public enum Info: Sendable { + /// Request type for "Info". + public typealias Input = Com_Apple_Container_Build_V1_InfoRequest + /// Response type for "Info". + public typealias Output = Com_Apple_Container_Build_V1_InfoResponse + /// Descriptor for "Info". + public static let descriptor = GRPCCore.MethodDescriptor( + service: GRPCCore.ServiceDescriptor(fullyQualifiedService: "com.apple.container.build.v1.Builder"), + method: "Info", + type: .unary + ) + } + /// Descriptors for all methods in the "com.apple.container.build.v1.Builder" service. + public static let descriptors: [GRPCCore.MethodDescriptor] = [ + CreateBuild.descriptor, + PerformBuild.descriptor, + Info.descriptor + ] + } +} + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension GRPCCore.ServiceDescriptor { + /// Service descriptor for the "com.apple.container.build.v1.Builder" service. + public static let com_apple_container_build_v1_Builder = GRPCCore.ServiceDescriptor(fullyQualifiedService: "com.apple.container.build.v1.Builder") +} + +// MARK: com.apple.container.build.v1.Builder (server) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Com_Apple_Container_Build_V1_Builder { + /// Streaming variant of the service protocol for the "com.apple.container.build.v1.Builder" service. + /// + /// This protocol is the lowest-level of the service protocols generated for this service + /// giving you the most flexibility over the implementation of your service. This comes at + /// the cost of more verbose and less strict APIs. Each RPC requires you to implement it in + /// terms of a request stream and response stream. Where only a single request or response + /// message is expected, you are responsible for enforcing this invariant is maintained. + /// + /// Where possible, prefer using the stricter, less-verbose ``ServiceProtocol`` + /// or ``SimpleServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Builder service implements APIs for performing an image build with + /// > Container image builder agent. + /// > + /// > To perform a build: + /// > + /// > 1. CreateBuild to create a new build + /// > 2. StartBuild to start the build execution where client and server + /// > both have a stream for exchanging data during the build. + /// > + /// > The client may send: + /// > a) signal packet to signal to the build process (e.g. SIGINT) + /// > + /// > b) command packet for executing a command in the build file on the + /// > server + /// > NOTE: the server will need to switch on the command to determine the + /// > type of command to execute (e.g. RUN, ENV, etc.) + /// > + /// > c) transfer build data either to or from the server + /// > - INTO direction is for sending build data to the server at specific + /// > location (e.g. COPY) + /// > - OUTOF direction is for copying build data from the server to be + /// > used in subsequent build stages + /// > + /// > d) transfer image content data either to or from the server + /// > - INTO direction is for sending inherited image content data to the + /// > server's local content store + /// > - OUTOF direction is for copying successfully built OCI image from + /// > the server to the client + /// > + /// > The server may send: + /// > a) stdio packet for the build progress + /// > + /// > b) build error indicating unsuccessful build + /// > + /// > c) command complete packet indicating a command has finished executing + /// > + /// > d) handle transfer build data either to or from the client + /// > + /// > e) handle transfer image content data either to or from the client + /// > + /// > + /// > NOTE: The build data and image content data transfer is ALWAYS initiated + /// > by the client. + /// > + /// > Sequence for transferring from the client to the server: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'INTO', + /// > destination path, and first chunk of data + /// > 2. server starts to receive the data and stream to a temporary file + /// > 3. client continues to send all chunks of data until last chunk, which + /// > client will + /// > send with 'complete' set to true + /// > 4. server continues to receive until the last chunk with 'complete' set + /// > to true, + /// > server will finish writing the last chunk and un-archive the + /// > temporary file to the destination path + /// > 5. server completes the transfer by sending a last + /// > BuildTransfer/ImageTransfer with + /// > 'complete' set to true + /// > 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' + /// > set to true + /// > before proceeding with the rest of the commands + /// > + /// > Sequence for transferring from the server to the client: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'OUTOF', + /// > source path, and empty data + /// > 2. server archives the data at source path, and starts to send chunks to + /// > the client + /// > 3. server continues to send all chunks until last chunk, which server + /// > will send with + /// > 'complete' set to true + /// > 4. client starts to receive the data and stream to a temporary file + /// > 5. client continues to receive until the last chunk with 'complete' set + /// > to true, + /// > client will finish writing last chunk and un-archive the temporary + /// > file to the destination path + /// > 6. client MAY choose to send one last BuildTransfer/ImageTransfer with + /// > 'complete' + /// > set to true, but NOT required. + /// > + /// > + /// > NOTE: the client should close the send stream once it has finished + /// > receiving the build output or abandon the current build due to error. + /// > Server should keep the stream open until it receives the EOF that client + /// > has closed the stream, which the server should then close its send stream. + public protocol StreamingServiceProtocol: GRPCCore.RegistrableRPCService { + /// Handle the "CreateBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Create a build request. + /// + /// - Parameters: + /// - request: A streaming request of `Com_Apple_Container_Build_V1_CreateBuildRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Com_Apple_Container_Build_V1_CreateBuildResponse` messages. + func createBuild( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "PerformBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform the build. + /// > Executes the entire build sequence with attaching input/output + /// > to handling data exchange with the server during the build. + /// + /// - Parameters: + /// - request: A streaming request of `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Com_Apple_Container_Build_V1_ServerStream` messages. + func performBuild( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "Info" method. + /// + /// - Parameters: + /// - request: A streaming request of `Com_Apple_Container_Build_V1_InfoRequest` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Com_Apple_Container_Build_V1_InfoResponse` messages. + func info( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + } + + /// Service protocol for the "com.apple.container.build.v1.Builder" service. + /// + /// This protocol is higher level than ``StreamingServiceProtocol`` but lower level than + /// the ``SimpleServiceProtocol``, it provides access to request and response metadata and + /// trailing response metadata. If you don't need these then consider using + /// the ``SimpleServiceProtocol``. If you need fine grained control over your RPCs then + /// use ``StreamingServiceProtocol``. + /// + /// > Source IDL Documentation: + /// > + /// > Builder service implements APIs for performing an image build with + /// > Container image builder agent. + /// > + /// > To perform a build: + /// > + /// > 1. CreateBuild to create a new build + /// > 2. StartBuild to start the build execution where client and server + /// > both have a stream for exchanging data during the build. + /// > + /// > The client may send: + /// > a) signal packet to signal to the build process (e.g. SIGINT) + /// > + /// > b) command packet for executing a command in the build file on the + /// > server + /// > NOTE: the server will need to switch on the command to determine the + /// > type of command to execute (e.g. RUN, ENV, etc.) + /// > + /// > c) transfer build data either to or from the server + /// > - INTO direction is for sending build data to the server at specific + /// > location (e.g. COPY) + /// > - OUTOF direction is for copying build data from the server to be + /// > used in subsequent build stages + /// > + /// > d) transfer image content data either to or from the server + /// > - INTO direction is for sending inherited image content data to the + /// > server's local content store + /// > - OUTOF direction is for copying successfully built OCI image from + /// > the server to the client + /// > + /// > The server may send: + /// > a) stdio packet for the build progress + /// > + /// > b) build error indicating unsuccessful build + /// > + /// > c) command complete packet indicating a command has finished executing + /// > + /// > d) handle transfer build data either to or from the client + /// > + /// > e) handle transfer image content data either to or from the client + /// > + /// > + /// > NOTE: The build data and image content data transfer is ALWAYS initiated + /// > by the client. + /// > + /// > Sequence for transferring from the client to the server: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'INTO', + /// > destination path, and first chunk of data + /// > 2. server starts to receive the data and stream to a temporary file + /// > 3. client continues to send all chunks of data until last chunk, which + /// > client will + /// > send with 'complete' set to true + /// > 4. server continues to receive until the last chunk with 'complete' set + /// > to true, + /// > server will finish writing the last chunk and un-archive the + /// > temporary file to the destination path + /// > 5. server completes the transfer by sending a last + /// > BuildTransfer/ImageTransfer with + /// > 'complete' set to true + /// > 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' + /// > set to true + /// > before proceeding with the rest of the commands + /// > + /// > Sequence for transferring from the server to the client: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'OUTOF', + /// > source path, and empty data + /// > 2. server archives the data at source path, and starts to send chunks to + /// > the client + /// > 3. server continues to send all chunks until last chunk, which server + /// > will send with + /// > 'complete' set to true + /// > 4. client starts to receive the data and stream to a temporary file + /// > 5. client continues to receive until the last chunk with 'complete' set + /// > to true, + /// > client will finish writing last chunk and un-archive the temporary + /// > file to the destination path + /// > 6. client MAY choose to send one last BuildTransfer/ImageTransfer with + /// > 'complete' + /// > set to true, but NOT required. + /// > + /// > + /// > NOTE: the client should close the send stream once it has finished + /// > receiving the build output or abandon the current build due to error. + /// > Server should keep the stream open until it receives the EOF that client + /// > has closed the stream, which the server should then close its send stream. + public protocol ServiceProtocol: Com_Apple_Container_Build_V1_Builder.StreamingServiceProtocol { + /// Handle the "CreateBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Create a build request. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_CreateBuildRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Com_Apple_Container_Build_V1_CreateBuildResponse` message. + func createBuild( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + + /// Handle the "PerformBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform the build. + /// > Executes the entire build sequence with attaching input/output + /// > to handling data exchange with the server during the build. + /// + /// - Parameters: + /// - request: A streaming request of `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A streaming response of `Com_Apple_Container_Build_V1_ServerStream` messages. + func performBuild( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse + + /// Handle the "Info" method. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_InfoRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A response containing a single `Com_Apple_Container_Build_V1_InfoResponse` message. + func info( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse + } + + /// Simple service protocol for the "com.apple.container.build.v1.Builder" service. + /// + /// This is the highest level protocol for the service. The API is the easiest to use but + /// doesn't provide access to request or response metadata. If you need access to these + /// then use ``ServiceProtocol`` instead. + /// + /// > Source IDL Documentation: + /// > + /// > Builder service implements APIs for performing an image build with + /// > Container image builder agent. + /// > + /// > To perform a build: + /// > + /// > 1. CreateBuild to create a new build + /// > 2. StartBuild to start the build execution where client and server + /// > both have a stream for exchanging data during the build. + /// > + /// > The client may send: + /// > a) signal packet to signal to the build process (e.g. SIGINT) + /// > + /// > b) command packet for executing a command in the build file on the + /// > server + /// > NOTE: the server will need to switch on the command to determine the + /// > type of command to execute (e.g. RUN, ENV, etc.) + /// > + /// > c) transfer build data either to or from the server + /// > - INTO direction is for sending build data to the server at specific + /// > location (e.g. COPY) + /// > - OUTOF direction is for copying build data from the server to be + /// > used in subsequent build stages + /// > + /// > d) transfer image content data either to or from the server + /// > - INTO direction is for sending inherited image content data to the + /// > server's local content store + /// > - OUTOF direction is for copying successfully built OCI image from + /// > the server to the client + /// > + /// > The server may send: + /// > a) stdio packet for the build progress + /// > + /// > b) build error indicating unsuccessful build + /// > + /// > c) command complete packet indicating a command has finished executing + /// > + /// > d) handle transfer build data either to or from the client + /// > + /// > e) handle transfer image content data either to or from the client + /// > + /// > + /// > NOTE: The build data and image content data transfer is ALWAYS initiated + /// > by the client. + /// > + /// > Sequence for transferring from the client to the server: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'INTO', + /// > destination path, and first chunk of data + /// > 2. server starts to receive the data and stream to a temporary file + /// > 3. client continues to send all chunks of data until last chunk, which + /// > client will + /// > send with 'complete' set to true + /// > 4. server continues to receive until the last chunk with 'complete' set + /// > to true, + /// > server will finish writing the last chunk and un-archive the + /// > temporary file to the destination path + /// > 5. server completes the transfer by sending a last + /// > BuildTransfer/ImageTransfer with + /// > 'complete' set to true + /// > 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' + /// > set to true + /// > before proceeding with the rest of the commands + /// > + /// > Sequence for transferring from the server to the client: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'OUTOF', + /// > source path, and empty data + /// > 2. server archives the data at source path, and starts to send chunks to + /// > the client + /// > 3. server continues to send all chunks until last chunk, which server + /// > will send with + /// > 'complete' set to true + /// > 4. client starts to receive the data and stream to a temporary file + /// > 5. client continues to receive until the last chunk with 'complete' set + /// > to true, + /// > client will finish writing last chunk and un-archive the temporary + /// > file to the destination path + /// > 6. client MAY choose to send one last BuildTransfer/ImageTransfer with + /// > 'complete' + /// > set to true, but NOT required. + /// > + /// > + /// > NOTE: the client should close the send stream once it has finished + /// > receiving the build output or abandon the current build due to error. + /// > Server should keep the stream open until it receives the EOF that client + /// > has closed the stream, which the server should then close its send stream. + public protocol SimpleServiceProtocol: Com_Apple_Container_Build_V1_Builder.ServiceProtocol { + /// Handle the "CreateBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Create a build request. + /// + /// - Parameters: + /// - request: A `Com_Apple_Container_Build_V1_CreateBuildRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Com_Apple_Container_Build_V1_CreateBuildResponse` to respond with. + func createBuild( + request: Com_Apple_Container_Build_V1_CreateBuildRequest, + context: GRPCCore.ServerContext + ) async throws -> Com_Apple_Container_Build_V1_CreateBuildResponse + + /// Handle the "PerformBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform the build. + /// > Executes the entire build sequence with attaching input/output + /// > to handling data exchange with the server during the build. + /// + /// - Parameters: + /// - request: A stream of `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - response: A response stream of `Com_Apple_Container_Build_V1_ServerStream` messages. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + func performBuild( + request: GRPCCore.RPCAsyncSequence, + response: GRPCCore.RPCWriter, + context: GRPCCore.ServerContext + ) async throws + + /// Handle the "Info" method. + /// + /// - Parameters: + /// - request: A `Com_Apple_Container_Build_V1_InfoRequest` message. + /// - context: Context providing information about the RPC. + /// - Throws: Any error which occurred during the processing of the request. Thrown errors + /// of type `RPCError` are mapped to appropriate statuses. All other errors are converted + /// to an internal error. + /// - Returns: A `Com_Apple_Container_Build_V1_InfoResponse` to respond with. + func info( + request: Com_Apple_Container_Build_V1_InfoRequest, + context: GRPCCore.ServerContext + ) async throws -> Com_Apple_Container_Build_V1_InfoResponse + } +} + +// Default implementation of 'registerMethods(with:)'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Com_Apple_Container_Build_V1_Builder.StreamingServiceProtocol { + public func registerMethods(with router: inout GRPCCore.RPCRouter) where Transport: GRPCCore.ServerTransport { + router.registerHandler( + forMethod: Com_Apple_Container_Build_V1_Builder.Method.CreateBuild.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.createBuild( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Com_Apple_Container_Build_V1_Builder.Method.PerformBuild.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.performBuild( + request: request, + context: context + ) + } + ) + router.registerHandler( + forMethod: Com_Apple_Container_Build_V1_Builder.Method.Info.descriptor, + deserializer: GRPCProtobuf.ProtobufDeserializer(), + serializer: GRPCProtobuf.ProtobufSerializer(), + handler: { request, context in + try await self.info( + request: request, + context: context + ) + } + ) + } +} + +// Default implementation of streaming methods from 'StreamingServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Com_Apple_Container_Build_V1_Builder.ServiceProtocol { + public func createBuild( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.createBuild( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } + + public func info( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + let response = try await self.info( + request: GRPCCore.ServerRequest(stream: request), + context: context + ) + return GRPCCore.StreamingServerResponse(single: response) + } +} + +// Default implementation of methods from 'ServiceProtocol'. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Com_Apple_Container_Build_V1_Builder.SimpleServiceProtocol { + public func createBuild( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.createBuild( + request: request.message, + context: context + ), + metadata: [:] + ) + } + + public func performBuild( + request: GRPCCore.StreamingServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.StreamingServerResponse { + return GRPCCore.StreamingServerResponse( + metadata: [:], + producer: { writer in + try await self.performBuild( + request: request.messages, + response: writer, + context: context + ) + return [:] + } + ) + } + + public func info( + request: GRPCCore.ServerRequest, + context: GRPCCore.ServerContext + ) async throws -> GRPCCore.ServerResponse { + return GRPCCore.ServerResponse( + message: try await self.info( + request: request.message, + context: context + ), + metadata: [:] + ) + } +} + +// MARK: com.apple.container.build.v1.Builder (client) + +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Com_Apple_Container_Build_V1_Builder { + /// Generated client protocol for the "com.apple.container.build.v1.Builder" service. + /// + /// You don't need to implement this protocol directly, use the generated + /// implementation, ``Client``. + /// + /// > Source IDL Documentation: + /// > + /// > Builder service implements APIs for performing an image build with + /// > Container image builder agent. + /// > + /// > To perform a build: + /// > + /// > 1. CreateBuild to create a new build + /// > 2. StartBuild to start the build execution where client and server + /// > both have a stream for exchanging data during the build. + /// > + /// > The client may send: + /// > a) signal packet to signal to the build process (e.g. SIGINT) + /// > + /// > b) command packet for executing a command in the build file on the + /// > server + /// > NOTE: the server will need to switch on the command to determine the + /// > type of command to execute (e.g. RUN, ENV, etc.) + /// > + /// > c) transfer build data either to or from the server + /// > - INTO direction is for sending build data to the server at specific + /// > location (e.g. COPY) + /// > - OUTOF direction is for copying build data from the server to be + /// > used in subsequent build stages + /// > + /// > d) transfer image content data either to or from the server + /// > - INTO direction is for sending inherited image content data to the + /// > server's local content store + /// > - OUTOF direction is for copying successfully built OCI image from + /// > the server to the client + /// > + /// > The server may send: + /// > a) stdio packet for the build progress + /// > + /// > b) build error indicating unsuccessful build + /// > + /// > c) command complete packet indicating a command has finished executing + /// > + /// > d) handle transfer build data either to or from the client + /// > + /// > e) handle transfer image content data either to or from the client + /// > + /// > + /// > NOTE: The build data and image content data transfer is ALWAYS initiated + /// > by the client. + /// > + /// > Sequence for transferring from the client to the server: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'INTO', + /// > destination path, and first chunk of data + /// > 2. server starts to receive the data and stream to a temporary file + /// > 3. client continues to send all chunks of data until last chunk, which + /// > client will + /// > send with 'complete' set to true + /// > 4. server continues to receive until the last chunk with 'complete' set + /// > to true, + /// > server will finish writing the last chunk and un-archive the + /// > temporary file to the destination path + /// > 5. server completes the transfer by sending a last + /// > BuildTransfer/ImageTransfer with + /// > 'complete' set to true + /// > 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' + /// > set to true + /// > before proceeding with the rest of the commands + /// > + /// > Sequence for transferring from the server to the client: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'OUTOF', + /// > source path, and empty data + /// > 2. server archives the data at source path, and starts to send chunks to + /// > the client + /// > 3. server continues to send all chunks until last chunk, which server + /// > will send with + /// > 'complete' set to true + /// > 4. client starts to receive the data and stream to a temporary file + /// > 5. client continues to receive until the last chunk with 'complete' set + /// > to true, + /// > client will finish writing last chunk and un-archive the temporary + /// > file to the destination path + /// > 6. client MAY choose to send one last BuildTransfer/ImageTransfer with + /// > 'complete' + /// > set to true, but NOT required. + /// > + /// > + /// > NOTE: the client should close the send stream once it has finished + /// > receiving the build output or abandon the current build due to error. + /// > Server should keep the stream open until it receives the EOF that client + /// > has closed the stream, which the server should then close its send stream. + public protocol ClientProtocol: Sendable { + /// Call the "CreateBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Create a build request. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_CreateBuildRequest` message. + /// - serializer: A serializer for `Com_Apple_Container_Build_V1_CreateBuildRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Container_Build_V1_CreateBuildResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func createBuild( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "PerformBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform the build. + /// > Executes the entire build sequence with attaching input/output + /// > to handling data exchange with the server during the build. + /// + /// - Parameters: + /// - request: A streaming request producing `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - serializer: A serializer for `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - deserializer: A deserializer for `Com_Apple_Container_Build_V1_ServerStream` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func performBuild( + request: GRPCCore.StreamingClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + + /// Call the "Info" method. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_InfoRequest` message. + /// - serializer: A serializer for `Com_Apple_Container_Build_V1_InfoRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Container_Build_V1_InfoResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + func info( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable + } + + /// Generated client for the "com.apple.container.build.v1.Builder" service. + /// + /// The ``Client`` provides an implementation of ``ClientProtocol`` which wraps + /// a `GRPCCore.GRPCCClient`. The underlying `GRPCClient` provides the long-lived + /// means of communication with the remote peer. + /// + /// > Source IDL Documentation: + /// > + /// > Builder service implements APIs for performing an image build with + /// > Container image builder agent. + /// > + /// > To perform a build: + /// > + /// > 1. CreateBuild to create a new build + /// > 2. StartBuild to start the build execution where client and server + /// > both have a stream for exchanging data during the build. + /// > + /// > The client may send: + /// > a) signal packet to signal to the build process (e.g. SIGINT) + /// > + /// > b) command packet for executing a command in the build file on the + /// > server + /// > NOTE: the server will need to switch on the command to determine the + /// > type of command to execute (e.g. RUN, ENV, etc.) + /// > + /// > c) transfer build data either to or from the server + /// > - INTO direction is for sending build data to the server at specific + /// > location (e.g. COPY) + /// > - OUTOF direction is for copying build data from the server to be + /// > used in subsequent build stages + /// > + /// > d) transfer image content data either to or from the server + /// > - INTO direction is for sending inherited image content data to the + /// > server's local content store + /// > - OUTOF direction is for copying successfully built OCI image from + /// > the server to the client + /// > + /// > The server may send: + /// > a) stdio packet for the build progress + /// > + /// > b) build error indicating unsuccessful build + /// > + /// > c) command complete packet indicating a command has finished executing + /// > + /// > d) handle transfer build data either to or from the client + /// > + /// > e) handle transfer image content data either to or from the client + /// > + /// > + /// > NOTE: The build data and image content data transfer is ALWAYS initiated + /// > by the client. + /// > + /// > Sequence for transferring from the client to the server: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'INTO', + /// > destination path, and first chunk of data + /// > 2. server starts to receive the data and stream to a temporary file + /// > 3. client continues to send all chunks of data until last chunk, which + /// > client will + /// > send with 'complete' set to true + /// > 4. server continues to receive until the last chunk with 'complete' set + /// > to true, + /// > server will finish writing the last chunk and un-archive the + /// > temporary file to the destination path + /// > 5. server completes the transfer by sending a last + /// > BuildTransfer/ImageTransfer with + /// > 'complete' set to true + /// > 6. client waits for the last BuildTransfer/ImageTransfer with 'complete' + /// > set to true + /// > before proceeding with the rest of the commands + /// > + /// > Sequence for transferring from the server to the client: + /// > 1. client send a BuildTransfer/ImageTransfer request with ID, direction + /// > of 'OUTOF', + /// > source path, and empty data + /// > 2. server archives the data at source path, and starts to send chunks to + /// > the client + /// > 3. server continues to send all chunks until last chunk, which server + /// > will send with + /// > 'complete' set to true + /// > 4. client starts to receive the data and stream to a temporary file + /// > 5. client continues to receive until the last chunk with 'complete' set + /// > to true, + /// > client will finish writing last chunk and un-archive the temporary + /// > file to the destination path + /// > 6. client MAY choose to send one last BuildTransfer/ImageTransfer with + /// > 'complete' + /// > set to true, but NOT required. + /// > + /// > + /// > NOTE: the client should close the send stream once it has finished + /// > receiving the build output or abandon the current build due to error. + /// > Server should keep the stream open until it receives the EOF that client + /// > has closed the stream, which the server should then close its send stream. + public struct Client: ClientProtocol where Transport: GRPCCore.ClientTransport { + private let client: GRPCCore.GRPCClient + + /// Creates a new client wrapping the provided `GRPCCore.GRPCClient`. + /// + /// - Parameters: + /// - client: A `GRPCCore.GRPCClient` providing a communication channel to the service. + public init(wrapping client: GRPCCore.GRPCClient) { + self.client = client + } + + /// Call the "CreateBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Create a build request. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_CreateBuildRequest` message. + /// - serializer: A serializer for `Com_Apple_Container_Build_V1_CreateBuildRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Container_Build_V1_CreateBuildResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func createBuild( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Com_Apple_Container_Build_V1_Builder.Method.CreateBuild.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "PerformBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform the build. + /// > Executes the entire build sequence with attaching input/output + /// > to handling data exchange with the server during the build. + /// + /// - Parameters: + /// - request: A streaming request producing `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - serializer: A serializer for `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - deserializer: A deserializer for `Com_Apple_Container_Build_V1_ServerStream` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func performBuild( + request: GRPCCore.StreamingClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.client.bidirectionalStreaming( + request: request, + descriptor: Com_Apple_Container_Build_V1_Builder.Method.PerformBuild.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Info" method. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_InfoRequest` message. + /// - serializer: A serializer for `Com_Apple_Container_Build_V1_InfoRequest` messages. + /// - deserializer: A deserializer for `Com_Apple_Container_Build_V1_InfoResponse` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func info( + request: GRPCCore.ClientRequest, + serializer: some GRPCCore.MessageSerializer, + deserializer: some GRPCCore.MessageDeserializer, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.client.unary( + request: request, + descriptor: Com_Apple_Container_Build_V1_Builder.Method.Info.descriptor, + serializer: serializer, + deserializer: deserializer, + options: options, + onResponse: handleResponse + ) + } + } +} + +// Helpers providing default arguments to 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Com_Apple_Container_Build_V1_Builder.ClientProtocol { + /// Call the "CreateBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Create a build request. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_CreateBuildRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func createBuild( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.createBuild( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "PerformBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform the build. + /// > Executes the entire build sequence with attaching input/output + /// > to handling data exchange with the server during the build. + /// + /// - Parameters: + /// - request: A streaming request producing `Com_Apple_Container_Build_V1_ClientStream` messages. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func performBuild( + request: GRPCCore.StreamingClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + try await self.performBuild( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Info" method. + /// + /// - Parameters: + /// - request: A request containing a single `Com_Apple_Container_Build_V1_InfoRequest` message. + /// - options: Options to apply to this RPC. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func info( + request: GRPCCore.ClientRequest, + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + try await self.info( + request: request, + serializer: GRPCProtobuf.ProtobufSerializer(), + deserializer: GRPCProtobuf.ProtobufDeserializer(), + options: options, + onResponse: handleResponse + ) + } +} + +// Helpers providing sugared APIs for 'ClientProtocol' methods. +@available(macOS 15.0, iOS 18.0, watchOS 11.0, tvOS 18.0, visionOS 2.0, *) +extension Com_Apple_Container_Build_V1_Builder.ClientProtocol { + /// Call the "CreateBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Create a build request. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func createBuild( + _ message: Com_Apple_Container_Build_V1_CreateBuildRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.createBuild( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "PerformBuild" method. + /// + /// > Source IDL Documentation: + /// > + /// > Perform the build. + /// > Executes the entire build sequence with attaching input/output + /// > to handling data exchange with the server during the build. + /// + /// - Parameters: + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - producer: A closure producing request messages to send to the server. The request + /// stream is closed when the closure returns. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func performBuild( + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + requestProducer producer: @Sendable @escaping (GRPCCore.RPCWriter) async throws -> Void, + onResponse handleResponse: @Sendable @escaping (GRPCCore.StreamingClientResponse) async throws -> Result + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.StreamingClientRequest( + metadata: metadata, + producer: producer + ) + return try await self.performBuild( + request: request, + options: options, + onResponse: handleResponse + ) + } + + /// Call the "Info" method. + /// + /// - Parameters: + /// - message: request message to send. + /// - metadata: Additional metadata to send, defaults to empty. + /// - options: Options to apply to this RPC, defaults to `.defaults`. + /// - handleResponse: A closure which handles the response, the result of which is + /// returned to the caller. Returning from the closure will cancel the RPC if it + /// hasn't already finished. + /// - Returns: The result of `handleResponse`. + public func info( + _ message: Com_Apple_Container_Build_V1_InfoRequest, + metadata: GRPCCore.Metadata = [:], + options: GRPCCore.CallOptions = .defaults, + onResponse handleResponse: @Sendable @escaping (GRPCCore.ClientResponse) async throws -> Result = { response in + try response.message + } + ) async throws -> Result where Result: Sendable { + let request = GRPCCore.ClientRequest( + message: message, + metadata: metadata + ) + return try await self.info( + request: request, + options: options, + onResponse: handleResponse + ) + } +} \ No newline at end of file diff --git a/Sources/ContainerBuild/Builder.pb.swift b/Sources/ContainerBuild/Builder.pb.swift new file mode 100644 index 0000000..6b6c116 --- /dev/null +++ b/Sources/ContainerBuild/Builder.pb.swift @@ -0,0 +1,1422 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +// DO NOT EDIT. +// swift-format-ignore-file +// swiftlint:disable all +// +// Generated by the Swift generator plugin for the protocol buffer compiler. +// Source: Builder.proto +// +// For information on using the generated types, please see the documentation: +// https://github.com/apple/swift-protobuf/ + +#if canImport(FoundationEssentials) +import FoundationEssentials +#else +import Foundation +#endif +import SwiftProtobuf + +// If the compiler emits an error on this type, it is because this file +// was generated by a version of the `protoc` Swift plug-in that is +// incompatible with the version of SwiftProtobuf to which you are linking. +// Please ensure that you are building against the same version of the API +// that was used to generate this file. +fileprivate struct _GeneratedWithProtocGenSwiftVersion: SwiftProtobuf.ProtobufAPIVersionCheck { + struct _2: SwiftProtobuf.ProtobufAPIVersion_2 {} + typealias Version = _2 +} + +public enum Com_Apple_Container_Build_V1_TransferDirection: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case into // = 0 + case outof // = 1 + case UNRECOGNIZED(Int) + + public init() { + self = .into + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .into + case 1: self = .outof + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .into: return 0 + case .outof: return 1 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Com_Apple_Container_Build_V1_TransferDirection] = [ + .into, + .outof, + ] + +} + +/// Standard input/output. +public enum Com_Apple_Container_Build_V1_Stdio: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case stdin // = 0 + case stdout // = 1 + case stderr // = 2 + case UNRECOGNIZED(Int) + + public init() { + self = .stdin + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .stdin + case 1: self = .stdout + case 2: self = .stderr + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .stdin: return 0 + case .stdout: return 1 + case .stderr: return 2 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Com_Apple_Container_Build_V1_Stdio] = [ + .stdin, + .stdout, + .stderr, + ] + +} + +/// Build error type. +public enum Com_Apple_Container_Build_V1_BuildErrorType: SwiftProtobuf.Enum, Swift.CaseIterable { + public typealias RawValue = Int + case buildFailed // = 0 + case `internal` // = 1 + case UNRECOGNIZED(Int) + + public init() { + self = .buildFailed + } + + public init?(rawValue: Int) { + switch rawValue { + case 0: self = .buildFailed + case 1: self = .internal + default: self = .UNRECOGNIZED(rawValue) + } + } + + public var rawValue: Int { + switch self { + case .buildFailed: return 0 + case .internal: return 1 + case .UNRECOGNIZED(let i): return i + } + } + + // The compiler won't synthesize support with the UNRECOGNIZED case. + public static let allCases: [Com_Apple_Container_Build_V1_BuildErrorType] = [ + .buildFailed, + .internal, + ] + +} + +public struct Com_Apple_Container_Build_V1_InfoRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_InfoResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_CreateBuildRequest: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// The name of the build stage. + public var stageName: String = String() + + /// The tag of the image to be created. + public var tag: String = String() + + /// Any additional metadata to be associated with the build. + public var metadata: Dictionary = [:] + + /// Additional build arguments. + public var buildArgs: [String] = [] + + /// Enable debug logging. + public var debug: Bool = false + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_CreateBuildResponse: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A unique ID for the build. + public var buildID: String = String() + + /// Any additional metadata to be associated with the build. + public var metadata: Dictionary = [:] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_ClientStream: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A unique ID for the build. + public var buildID: String { + get {_storage._buildID} + set {_uniqueStorage()._buildID = newValue} + } + + /// The packet type. + public var packetType: OneOf_PacketType? { + get {return _storage._packetType} + set {_uniqueStorage()._packetType = newValue} + } + + public var signal: Com_Apple_Container_Build_V1_Signal { + get { + if case .signal(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_Signal() + } + set {_uniqueStorage()._packetType = .signal(newValue)} + } + + public var command: Com_Apple_Container_Build_V1_Run { + get { + if case .command(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_Run() + } + set {_uniqueStorage()._packetType = .command(newValue)} + } + + public var buildTransfer: Com_Apple_Container_Build_V1_BuildTransfer { + get { + if case .buildTransfer(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_BuildTransfer() + } + set {_uniqueStorage()._packetType = .buildTransfer(newValue)} + } + + public var imageTransfer: Com_Apple_Container_Build_V1_ImageTransfer { + get { + if case .imageTransfer(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_ImageTransfer() + } + set {_uniqueStorage()._packetType = .imageTransfer(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + /// The packet type. + public enum OneOf_PacketType: Equatable, Sendable { + case signal(Com_Apple_Container_Build_V1_Signal) + case command(Com_Apple_Container_Build_V1_Run) + case buildTransfer(Com_Apple_Container_Build_V1_BuildTransfer) + case imageTransfer(Com_Apple_Container_Build_V1_ImageTransfer) + + } + + public init() {} + + fileprivate var _storage = _StorageClass.defaultInstance +} + +public struct Com_Apple_Container_Build_V1_Signal: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A POSIX signal to send to the build process. + /// Can be used for cancelling builds. + public var signal: Int32 = 0 + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_Run: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A unique ID for the execution. + public var id: String = String() + + /// The type of command to execute. + public var command: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_RunComplete: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A unique ID for the execution. + public var id: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_BuildTransfer: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A unique ID for the transfer. + public var id: String = String() + + /// The direction for transferring data (either to the server or from the + /// server). + public var direction: Com_Apple_Container_Build_V1_TransferDirection = .into + + /// The absolute path to the source from the server perspective. + public var source: String { + get {_source ?? String()} + set {_source = newValue} + } + /// Returns true if `source` has been explicitly set. + public var hasSource: Bool {self._source != nil} + /// Clears the value of `source`. Subsequent reads from it will return its default value. + public mutating func clearSource() {self._source = nil} + + /// The absolute path for the destination from the server perspective. + public var destination: String { + get {_destination ?? String()} + set {_destination = newValue} + } + /// Returns true if `destination` has been explicitly set. + public var hasDestination: Bool {self._destination != nil} + /// Clears the value of `destination`. Subsequent reads from it will return its default value. + public mutating func clearDestination() {self._destination = nil} + + /// The actual data bytes to be transferred. + public var data: Data = Data() + + /// Signal to indicate that the transfer of data for the request has finished. + public var complete: Bool = false + + /// Boolean to indicate if the content is a directory. + public var isDirectory: Bool = false + + /// Metadata for the transfer. + public var metadata: Dictionary = [:] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _source: String? = nil + fileprivate var _destination: String? = nil +} + +public struct Com_Apple_Container_Build_V1_ImageTransfer: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A unique ID for the transfer. + public var id: String = String() + + /// The direction for transferring data (either to the server or from the + /// server). + public var direction: Com_Apple_Container_Build_V1_TransferDirection = .into + + /// The tag for the image. + public var tag: String = String() + + /// The descriptor for the image content. + public var descriptor: Com_Apple_Container_Build_V1_Descriptor { + get {_descriptor ?? Com_Apple_Container_Build_V1_Descriptor()} + set {_descriptor = newValue} + } + /// Returns true if `descriptor` has been explicitly set. + public var hasDescriptor: Bool {self._descriptor != nil} + /// Clears the value of `descriptor`. Subsequent reads from it will return its default value. + public mutating func clearDescriptor() {self._descriptor = nil} + + /// The actual data bytes to be transferred. + public var data: Data = Data() + + /// Signal to indicate that the transfer of data for the request has finished. + public var complete: Bool = false + + /// Metadata for the image. + public var metadata: Dictionary = [:] + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _descriptor: Com_Apple_Container_Build_V1_Descriptor? = nil +} + +public struct Com_Apple_Container_Build_V1_ServerStream: @unchecked Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// A unique ID for the build. + public var buildID: String { + get {_storage._buildID} + set {_uniqueStorage()._buildID = newValue} + } + + /// The packet type. + public var packetType: OneOf_PacketType? { + get {return _storage._packetType} + set {_uniqueStorage()._packetType = newValue} + } + + public var io: Com_Apple_Container_Build_V1_IO { + get { + if case .io(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_IO() + } + set {_uniqueStorage()._packetType = .io(newValue)} + } + + public var buildError: Com_Apple_Container_Build_V1_BuildError { + get { + if case .buildError(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_BuildError() + } + set {_uniqueStorage()._packetType = .buildError(newValue)} + } + + public var commandComplete: Com_Apple_Container_Build_V1_RunComplete { + get { + if case .commandComplete(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_RunComplete() + } + set {_uniqueStorage()._packetType = .commandComplete(newValue)} + } + + public var buildTransfer: Com_Apple_Container_Build_V1_BuildTransfer { + get { + if case .buildTransfer(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_BuildTransfer() + } + set {_uniqueStorage()._packetType = .buildTransfer(newValue)} + } + + public var imageTransfer: Com_Apple_Container_Build_V1_ImageTransfer { + get { + if case .imageTransfer(let v)? = _storage._packetType {return v} + return Com_Apple_Container_Build_V1_ImageTransfer() + } + set {_uniqueStorage()._packetType = .imageTransfer(newValue)} + } + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + /// The packet type. + public enum OneOf_PacketType: Equatable, Sendable { + case io(Com_Apple_Container_Build_V1_IO) + case buildError(Com_Apple_Container_Build_V1_BuildError) + case commandComplete(Com_Apple_Container_Build_V1_RunComplete) + case buildTransfer(Com_Apple_Container_Build_V1_BuildTransfer) + case imageTransfer(Com_Apple_Container_Build_V1_ImageTransfer) + + } + + public init() {} + + fileprivate var _storage = _StorageClass.defaultInstance +} + +public struct Com_Apple_Container_Build_V1_IO: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// The type of IO. + public var type: Com_Apple_Container_Build_V1_Stdio = .stdin + + /// The IO data bytes. + public var data: Data = Data() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +public struct Com_Apple_Container_Build_V1_BuildError: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + /// The type of build error. + public var type: Com_Apple_Container_Build_V1_BuildErrorType = .buildFailed + + /// Additional message for the build failure. + public var message: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// OCI Platform metadata. +public struct Com_Apple_Container_Build_V1_Platform: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var architecture: String = String() + + public var os: String = String() + + public var osVersion: String = String() + + public var osFeatures: [String] = [] + + public var variant: String = String() + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} +} + +/// OCI Descriptor metadata. +public struct Com_Apple_Container_Build_V1_Descriptor: Sendable { + // SwiftProtobuf.Message conformance is added in an extension below. See the + // `Message` and `Message+*Additions` files in the SwiftProtobuf library for + // methods supported on all messages. + + public var mediaType: String = String() + + public var digest: String = String() + + public var size: Int64 = 0 + + public var urls: [String] = [] + + public var annotations: Dictionary = [:] + + public var platform: Com_Apple_Container_Build_V1_Platform { + get {_platform ?? Com_Apple_Container_Build_V1_Platform()} + set {_platform = newValue} + } + /// Returns true if `platform` has been explicitly set. + public var hasPlatform: Bool {self._platform != nil} + /// Clears the value of `platform`. Subsequent reads from it will return its default value. + public mutating func clearPlatform() {self._platform = nil} + + public var unknownFields = SwiftProtobuf.UnknownStorage() + + public init() {} + + fileprivate var _platform: Com_Apple_Container_Build_V1_Platform? = nil +} + +// MARK: - Code below here is support for the SwiftProtobuf runtime. + +fileprivate let _protobuf_package = "com.apple.container.build.v1" + +extension Com_Apple_Container_Build_V1_TransferDirection: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0INTO\0\u{1}OUTOF\0") +} + +extension Com_Apple_Container_Build_V1_Stdio: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0STDIN\0\u{1}STDOUT\0\u{1}STDERR\0") +} + +extension Com_Apple_Container_Build_V1_BuildErrorType: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{2}\0BUILD_FAILED\0\u{1}INTERNAL\0") +} + +extension Com_Apple_Container_Build_V1_InfoRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".InfoRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_InfoRequest, rhs: Com_Apple_Container_Build_V1_InfoRequest) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_InfoResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".InfoResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap() + + public mutating func decodeMessage(decoder: inout D) throws { + // Load everything into unknown fields + while try decoder.nextFieldNumber() != nil {} + } + + public func traverse(visitor: inout V) throws { + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_InfoResponse, rhs: Com_Apple_Container_Build_V1_InfoResponse) -> Bool { + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_CreateBuildRequest: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CreateBuildRequest" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}stage_name\0\u{1}tag\0\u{1}metadata\0\u{3}build_args\0\u{1}debug\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.stageName) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.tag) }() + case 3: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() + case 4: try { try decoder.decodeRepeatedStringField(value: &self.buildArgs) }() + case 5: try { try decoder.decodeSingularBoolField(value: &self.debug) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.stageName.isEmpty { + try visitor.visitSingularStringField(value: self.stageName, fieldNumber: 1) + } + if !self.tag.isEmpty { + try visitor.visitSingularStringField(value: self.tag, fieldNumber: 2) + } + if !self.metadata.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 3) + } + if !self.buildArgs.isEmpty { + try visitor.visitRepeatedStringField(value: self.buildArgs, fieldNumber: 4) + } + if self.debug != false { + try visitor.visitSingularBoolField(value: self.debug, fieldNumber: 5) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_CreateBuildRequest, rhs: Com_Apple_Container_Build_V1_CreateBuildRequest) -> Bool { + if lhs.stageName != rhs.stageName {return false} + if lhs.tag != rhs.tag {return false} + if lhs.metadata != rhs.metadata {return false} + if lhs.buildArgs != rhs.buildArgs {return false} + if lhs.debug != rhs.debug {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_CreateBuildResponse: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".CreateBuildResponse" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}build_id\0\u{1}metadata\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.buildID) }() + case 2: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.buildID.isEmpty { + try visitor.visitSingularStringField(value: self.buildID, fieldNumber: 1) + } + if !self.metadata.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_CreateBuildResponse, rhs: Com_Apple_Container_Build_V1_CreateBuildResponse) -> Bool { + if lhs.buildID != rhs.buildID {return false} + if lhs.metadata != rhs.metadata {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_ClientStream: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ClientStream" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}build_id\0\u{1}signal\0\u{1}command\0\u{3}build_transfer\0\u{3}image_transfer\0") + + fileprivate class _StorageClass { + var _buildID: String = String() + var _packetType: Com_Apple_Container_Build_V1_ClientStream.OneOf_PacketType? + + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _buildID = source._buildID + _packetType = source._packetType + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + + public mutating func decodeMessage(decoder: inout D) throws { + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &_storage._buildID) }() + case 2: try { + var v: Com_Apple_Container_Build_V1_Signal? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .signal(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .signal(v) + } + }() + case 3: try { + var v: Com_Apple_Container_Build_V1_Run? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .command(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .command(v) + } + }() + case 4: try { + var v: Com_Apple_Container_Build_V1_BuildTransfer? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .buildTransfer(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .buildTransfer(v) + } + }() + case 5: try { + var v: Com_Apple_Container_Build_V1_ImageTransfer? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .imageTransfer(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .imageTransfer(v) + } + }() + default: break + } + } + } + } + + public func traverse(visitor: inout V) throws { + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !_storage._buildID.isEmpty { + try visitor.visitSingularStringField(value: _storage._buildID, fieldNumber: 1) + } + switch _storage._packetType { + case .signal?: try { + guard case .signal(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .command?: try { + guard case .command(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .buildTransfer?: try { + guard case .buildTransfer(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case .imageTransfer?: try { + guard case .imageTransfer(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + }() + case nil: break + } + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_ClientStream, rhs: Com_Apple_Container_Build_V1_ClientStream) -> Bool { + if lhs._storage !== rhs._storage { + let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let rhs_storage = _args.1 + if _storage._buildID != rhs_storage._buildID {return false} + if _storage._packetType != rhs_storage._packetType {return false} + return true + } + if !storagesAreEqual {return false} + } + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_Signal: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Signal" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}signal\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularInt32Field(value: &self.signal) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.signal != 0 { + try visitor.visitSingularInt32Field(value: self.signal, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_Signal, rhs: Com_Apple_Container_Build_V1_Signal) -> Bool { + if lhs.signal != rhs.signal {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_Run: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Run" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}command\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.command) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.id.isEmpty { + try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) + } + if !self.command.isEmpty { + try visitor.visitSingularStringField(value: self.command, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_Run, rhs: Com_Apple_Container_Build_V1_Run) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.command != rhs.command {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_RunComplete: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".RunComplete" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.id.isEmpty { + try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_RunComplete, rhs: Com_Apple_Container_Build_V1_RunComplete) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_BuildTransfer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".BuildTransfer" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}direction\0\u{1}source\0\u{1}destination\0\u{1}data\0\u{1}complete\0\u{3}is_directory\0\u{1}metadata\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.direction) }() + case 3: try { try decoder.decodeSingularStringField(value: &self._source) }() + case 4: try { try decoder.decodeSingularStringField(value: &self._destination) }() + case 5: try { try decoder.decodeSingularBytesField(value: &self.data) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.complete) }() + case 7: try { try decoder.decodeSingularBoolField(value: &self.isDirectory) }() + case 8: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.id.isEmpty { + try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) + } + if self.direction != .into { + try visitor.visitSingularEnumField(value: self.direction, fieldNumber: 2) + } + try { if let v = self._source { + try visitor.visitSingularStringField(value: v, fieldNumber: 3) + } }() + try { if let v = self._destination { + try visitor.visitSingularStringField(value: v, fieldNumber: 4) + } }() + if !self.data.isEmpty { + try visitor.visitSingularBytesField(value: self.data, fieldNumber: 5) + } + if self.complete != false { + try visitor.visitSingularBoolField(value: self.complete, fieldNumber: 6) + } + if self.isDirectory != false { + try visitor.visitSingularBoolField(value: self.isDirectory, fieldNumber: 7) + } + if !self.metadata.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 8) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_BuildTransfer, rhs: Com_Apple_Container_Build_V1_BuildTransfer) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.direction != rhs.direction {return false} + if lhs._source != rhs._source {return false} + if lhs._destination != rhs._destination {return false} + if lhs.data != rhs.data {return false} + if lhs.complete != rhs.complete {return false} + if lhs.isDirectory != rhs.isDirectory {return false} + if lhs.metadata != rhs.metadata {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_ImageTransfer: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ImageTransfer" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}id\0\u{1}direction\0\u{1}tag\0\u{1}descriptor\0\u{1}data\0\u{1}complete\0\u{1}metadata\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.id) }() + case 2: try { try decoder.decodeSingularEnumField(value: &self.direction) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.tag) }() + case 4: try { try decoder.decodeSingularMessageField(value: &self._descriptor) }() + case 5: try { try decoder.decodeSingularBytesField(value: &self.data) }() + case 6: try { try decoder.decodeSingularBoolField(value: &self.complete) }() + case 7: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.metadata) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.id.isEmpty { + try visitor.visitSingularStringField(value: self.id, fieldNumber: 1) + } + if self.direction != .into { + try visitor.visitSingularEnumField(value: self.direction, fieldNumber: 2) + } + if !self.tag.isEmpty { + try visitor.visitSingularStringField(value: self.tag, fieldNumber: 3) + } + try { if let v = self._descriptor { + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + } }() + if !self.data.isEmpty { + try visitor.visitSingularBytesField(value: self.data, fieldNumber: 5) + } + if self.complete != false { + try visitor.visitSingularBoolField(value: self.complete, fieldNumber: 6) + } + if !self.metadata.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.metadata, fieldNumber: 7) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_ImageTransfer, rhs: Com_Apple_Container_Build_V1_ImageTransfer) -> Bool { + if lhs.id != rhs.id {return false} + if lhs.direction != rhs.direction {return false} + if lhs.tag != rhs.tag {return false} + if lhs._descriptor != rhs._descriptor {return false} + if lhs.data != rhs.data {return false} + if lhs.complete != rhs.complete {return false} + if lhs.metadata != rhs.metadata {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_ServerStream: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".ServerStream" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}build_id\0\u{1}io\0\u{3}build_error\0\u{3}command_complete\0\u{3}build_transfer\0\u{3}image_transfer\0") + + fileprivate class _StorageClass { + var _buildID: String = String() + var _packetType: Com_Apple_Container_Build_V1_ServerStream.OneOf_PacketType? + + // This property is used as the initial default value for new instances of the type. + // The type itself is protecting the reference to its storage via CoW semantics. + // This will force a copy to be made of this reference when the first mutation occurs; + // hence, it is safe to mark this as `nonisolated(unsafe)`. + static nonisolated(unsafe) let defaultInstance = _StorageClass() + + private init() {} + + init(copying source: _StorageClass) { + _buildID = source._buildID + _packetType = source._packetType + } + } + + fileprivate mutating func _uniqueStorage() -> _StorageClass { + if !isKnownUniquelyReferenced(&_storage) { + _storage = _StorageClass(copying: _storage) + } + return _storage + } + + public mutating func decodeMessage(decoder: inout D) throws { + _ = _uniqueStorage() + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &_storage._buildID) }() + case 2: try { + var v: Com_Apple_Container_Build_V1_IO? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .io(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .io(v) + } + }() + case 3: try { + var v: Com_Apple_Container_Build_V1_BuildError? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .buildError(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .buildError(v) + } + }() + case 4: try { + var v: Com_Apple_Container_Build_V1_RunComplete? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .commandComplete(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .commandComplete(v) + } + }() + case 5: try { + var v: Com_Apple_Container_Build_V1_BuildTransfer? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .buildTransfer(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .buildTransfer(v) + } + }() + case 6: try { + var v: Com_Apple_Container_Build_V1_ImageTransfer? + var hadOneofValue = false + if let current = _storage._packetType { + hadOneofValue = true + if case .imageTransfer(let m) = current {v = m} + } + try decoder.decodeSingularMessageField(value: &v) + if let v = v { + if hadOneofValue {try decoder.handleConflictingOneOf()} + _storage._packetType = .imageTransfer(v) + } + }() + default: break + } + } + } + } + + public func traverse(visitor: inout V) throws { + try withExtendedLifetime(_storage) { (_storage: _StorageClass) in + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !_storage._buildID.isEmpty { + try visitor.visitSingularStringField(value: _storage._buildID, fieldNumber: 1) + } + switch _storage._packetType { + case .io?: try { + guard case .io(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 2) + }() + case .buildError?: try { + guard case .buildError(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 3) + }() + case .commandComplete?: try { + guard case .commandComplete(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 4) + }() + case .buildTransfer?: try { + guard case .buildTransfer(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 5) + }() + case .imageTransfer?: try { + guard case .imageTransfer(let v)? = _storage._packetType else { preconditionFailure() } + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + }() + case nil: break + } + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_ServerStream, rhs: Com_Apple_Container_Build_V1_ServerStream) -> Bool { + if lhs._storage !== rhs._storage { + let storagesAreEqual: Bool = withExtendedLifetime((lhs._storage, rhs._storage)) { (_args: (_StorageClass, _StorageClass)) in + let _storage = _args.0 + let rhs_storage = _args.1 + if _storage._buildID != rhs_storage._buildID {return false} + if _storage._packetType != rhs_storage._packetType {return false} + return true + } + if !storagesAreEqual {return false} + } + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_IO: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".IO" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}data\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 2: try { try decoder.decodeSingularBytesField(value: &self.data) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.type != .stdin { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) + } + if !self.data.isEmpty { + try visitor.visitSingularBytesField(value: self.data, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_IO, rhs: Com_Apple_Container_Build_V1_IO) -> Bool { + if lhs.type != rhs.type {return false} + if lhs.data != rhs.data {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_BuildError: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".BuildError" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}type\0\u{1}message\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularEnumField(value: &self.type) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.message) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if self.type != .buildFailed { + try visitor.visitSingularEnumField(value: self.type, fieldNumber: 1) + } + if !self.message.isEmpty { + try visitor.visitSingularStringField(value: self.message, fieldNumber: 2) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_BuildError, rhs: Com_Apple_Container_Build_V1_BuildError) -> Bool { + if lhs.type != rhs.type {return false} + if lhs.message != rhs.message {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_Platform: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Platform" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{1}architecture\0\u{1}os\0\u{3}os_version\0\u{3}os_features\0\u{1}variant\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.architecture) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.os) }() + case 3: try { try decoder.decodeSingularStringField(value: &self.osVersion) }() + case 4: try { try decoder.decodeRepeatedStringField(value: &self.osFeatures) }() + case 5: try { try decoder.decodeSingularStringField(value: &self.variant) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + if !self.architecture.isEmpty { + try visitor.visitSingularStringField(value: self.architecture, fieldNumber: 1) + } + if !self.os.isEmpty { + try visitor.visitSingularStringField(value: self.os, fieldNumber: 2) + } + if !self.osVersion.isEmpty { + try visitor.visitSingularStringField(value: self.osVersion, fieldNumber: 3) + } + if !self.osFeatures.isEmpty { + try visitor.visitRepeatedStringField(value: self.osFeatures, fieldNumber: 4) + } + if !self.variant.isEmpty { + try visitor.visitSingularStringField(value: self.variant, fieldNumber: 5) + } + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_Platform, rhs: Com_Apple_Container_Build_V1_Platform) -> Bool { + if lhs.architecture != rhs.architecture {return false} + if lhs.os != rhs.os {return false} + if lhs.osVersion != rhs.osVersion {return false} + if lhs.osFeatures != rhs.osFeatures {return false} + if lhs.variant != rhs.variant {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} + +extension Com_Apple_Container_Build_V1_Descriptor: SwiftProtobuf.Message, SwiftProtobuf._MessageImplementationBase, SwiftProtobuf._ProtoNameProviding { + public static let protoMessageName: String = _protobuf_package + ".Descriptor" + public static let _protobuf_nameMap = SwiftProtobuf._NameMap(bytecode: "\0\u{3}media_type\0\u{1}digest\0\u{1}size\0\u{1}urls\0\u{1}annotations\0\u{1}platform\0") + + public mutating func decodeMessage(decoder: inout D) throws { + while let fieldNumber = try decoder.nextFieldNumber() { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every case branch when no optimizations are + // enabled. https://github.com/apple/swift-protobuf/issues/1034 + switch fieldNumber { + case 1: try { try decoder.decodeSingularStringField(value: &self.mediaType) }() + case 2: try { try decoder.decodeSingularStringField(value: &self.digest) }() + case 3: try { try decoder.decodeSingularInt64Field(value: &self.size) }() + case 4: try { try decoder.decodeRepeatedStringField(value: &self.urls) }() + case 5: try { try decoder.decodeMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: &self.annotations) }() + case 6: try { try decoder.decodeSingularMessageField(value: &self._platform) }() + default: break + } + } + } + + public func traverse(visitor: inout V) throws { + // The use of inline closures is to circumvent an issue where the compiler + // allocates stack space for every if/case branch local when no optimizations + // are enabled. https://github.com/apple/swift-protobuf/issues/1034 and + // https://github.com/apple/swift-protobuf/issues/1182 + if !self.mediaType.isEmpty { + try visitor.visitSingularStringField(value: self.mediaType, fieldNumber: 1) + } + if !self.digest.isEmpty { + try visitor.visitSingularStringField(value: self.digest, fieldNumber: 2) + } + if self.size != 0 { + try visitor.visitSingularInt64Field(value: self.size, fieldNumber: 3) + } + if !self.urls.isEmpty { + try visitor.visitRepeatedStringField(value: self.urls, fieldNumber: 4) + } + if !self.annotations.isEmpty { + try visitor.visitMapField(fieldType: SwiftProtobuf._ProtobufMap.self, value: self.annotations, fieldNumber: 5) + } + try { if let v = self._platform { + try visitor.visitSingularMessageField(value: v, fieldNumber: 6) + } }() + try unknownFields.traverse(visitor: &visitor) + } + + public static func ==(lhs: Com_Apple_Container_Build_V1_Descriptor, rhs: Com_Apple_Container_Build_V1_Descriptor) -> Bool { + if lhs.mediaType != rhs.mediaType {return false} + if lhs.digest != rhs.digest {return false} + if lhs.size != rhs.size {return false} + if lhs.urls != rhs.urls {return false} + if lhs.annotations != rhs.annotations {return false} + if lhs._platform != rhs._platform {return false} + if lhs.unknownFields != rhs.unknownFields {return false} + return true + } +} diff --git a/Sources/ContainerBuild/Builder.swift b/Sources/ContainerBuild/Builder.swift new file mode 100644 index 0000000..c43b5ea --- /dev/null +++ b/Sources/ContainerBuild/Builder.swift @@ -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 + let grpcClient: GRPCClient + let group: EventLoopGroup + let builderShimSocket: FileHandle + let clientTask: Task + 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.Continuation? + let reqStream = AsyncStream { (cont: AsyncStream.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.size + ) { raw in + #if canImport(Darwin) + return setsockopt( + self.fileDescriptor, + level, name, + raw, + socklen_t(MemoryLayout.size)) + #else + fatalError("unsupported platform") + #endif + } + } + if res == -1 { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } + } +} diff --git a/Sources/ContainerBuild/Globber.swift b/Sources/ContainerBuild/Globber.swift new file mode 100644 index 0000000..baecf60 --- /dev/null +++ b/Sources/ContainerBuild/Globber.swift @@ -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 = .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()) + } +} diff --git a/Sources/ContainerBuild/TerminalCommand.swift b/Sources/ContainerBuild/TerminalCommand.swift new file mode 100644 index 0000000..24b255c --- /dev/null +++ b/Sources/ContainerBuild/TerminalCommand.swift @@ -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: "=")) + } +} diff --git a/Sources/ContainerBuild/URL+Extensions.swift b/Sources/ContainerBuild/URL+Extensions.swift new file mode 100644 index 0000000..4f0bd7e --- /dev/null +++ b/Sources/ContainerBuild/URL+Extensions.swift @@ -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.Continuation.BufferingPolicy = .unbounded + ) throws -> AsyncStream { + + 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 + + /// 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.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() + } +} diff --git a/Sources/ContainerCommands/AggregateError.swift b/Sources/ContainerCommands/AggregateError.swift new file mode 100644 index 0000000..2b90682 --- /dev/null +++ b/Sources/ContainerCommands/AggregateError.swift @@ -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") + } +} diff --git a/Sources/ContainerCommands/Application.swift b/Sources/ContainerCommands/Application.swift new file mode 100644 index 0000000..c77f51a --- /dev/null +++ b/Sources/ContainerCommands/Application.swift @@ -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 + } + } +} diff --git a/Sources/ContainerCommands/AsyncLoggableCommand.swift b/Sources/ContainerCommands/AsyncLoggableCommand.swift new file mode 100644 index 0000000..5390f85 --- /dev/null +++ b/Sources/ContainerCommands/AsyncLoggableCommand.swift @@ -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 + } +} diff --git a/Sources/ContainerCommands/BuildCommand.swift b/Sources/ContainerCommands/BuildCommand.swift new file mode 100644 index 0000000..34ec435 --- /dev/null +++ b/Sources/ContainerCommands/BuildCommand.swift @@ -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=[,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=[,env=|,src=])", 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 = try { + var results: Set = [] + 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= \(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])") + } + } + } + } +} diff --git a/Sources/ContainerCommands/Builder/Builder.swift b/Sources/ContainerCommands/Builder/Builder.swift new file mode 100644 index 0000000..a12e8fa --- /dev/null +++ b/Sources/ContainerCommands/Builder/Builder.swift @@ -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 + } +} diff --git a/Sources/ContainerCommands/Builder/BuilderDelete.swift b/Sources/ContainerCommands/Builder/BuilderDelete.swift new file mode 100644 index 0000000..09063d9 --- /dev/null +++ b/Sources/ContainerCommands/Builder/BuilderDelete.swift @@ -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 + } + } + } +} diff --git a/Sources/ContainerCommands/Builder/BuilderStart.swift b/Sources/ContainerCommands/Builder/BuilderStart.swift new file mode 100644 index 0000000..cf1d4e1 --- /dev/null +++ b/Sources/ContainerCommands/Builder/BuilderStart.swift @@ -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)") + } +} diff --git a/Sources/ContainerCommands/Builder/BuilderStatus.swift b/Sources/ContainerCommands/Builder/BuilderStatus.swift new file mode 100644 index 0000000..a7b102f --- /dev/null +++ b/Sources/ContainerCommands/Builder/BuilderStatus.swift @@ -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 + } +} diff --git a/Sources/ContainerCommands/Builder/BuilderStop.swift b/Sources/ContainerCommands/Builder/BuilderStop.swift new file mode 100644 index 0000000..fd8409a --- /dev/null +++ b/Sources/ContainerCommands/Builder/BuilderStop.swift @@ -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 + } + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerCopy.swift b/Sources/ContainerCommands/Container/ContainerCopy.swift new file mode 100644 index 0000000..8302331 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerCopy.swift @@ -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)") + } + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerCreate.swift b/Sources/ContainerCommands/Container/ContainerCreate.swift new file mode 100644 index 0000000..92f0c02 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerCreate.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerDelete.swift b/Sources/ContainerCommands/Container/ContainerDelete.swift new file mode 100644 index 0000000..1eddc6b --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerDelete.swift @@ -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) + } + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerExec.swift b/Sources/ContainerCommands/Container/ContainerExec.swift new file mode 100644 index 0000000..f8d0355 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerExec.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerExport.swift b/Sources/ContainerCommands/Container/ContainerExport.swift new file mode 100644 index 0000000..a7394f2 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerExport.swift @@ -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!)) + } + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerInspect.swift b/Sources/ContainerCommands/Container/ContainerInspect.swift new file mode 100644 index 0000000..1c1ef16 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerInspect.swift @@ -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)) + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerKill.swift b/Sources/ContainerCommands/Container/ContainerKill.swift new file mode 100644 index 0000000..dbaa4f0 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerKill.swift @@ -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) + } + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerList.swift b/Sources/ContainerCommands/Container/ContainerList.swift new file mode 100644 index 0000000..a61cedc --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerList.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerLogs.swift b/Sources/ContainerCommands/Container/ContainerLogs.swift new file mode 100644 index 0000000..e0eb351 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerLogs.swift @@ -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 { 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) + } + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerPrune.swift b/Sources/ContainerCommands/Container/ContainerPrune.swift new file mode 100644 index 0000000..13bfbe7 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerPrune.swift @@ -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") + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerRun.swift b/Sources/ContainerCommands/Container/ContainerRun.swift new file mode 100644 index 0000000..431279a --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerRun.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerStart.swift b/Sources/ContainerCommands/Container/ContainerStart.swift new file mode 100644 index 0000000..00bc47a --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerStart.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerStats.swift b/Sources/ContainerCommands/Container/ContainerStats.swift new file mode 100644 index 0000000..769a288 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerStats.swift @@ -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.. 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) + } + } +} diff --git a/Sources/ContainerCommands/Container/ContainerStop.swift b/Sources/ContainerCommands/Container/ContainerStop.swift new file mode 100644 index 0000000..442b327 --- /dev/null +++ b/Sources/ContainerCommands/Container/ContainerStop.swift @@ -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) + } + } + } +} diff --git a/Sources/ContainerCommands/Container/ManagedContainer+ListDisplayable.swift b/Sources/ContainerCommands/Container/ManagedContainer+ListDisplayable.swift new file mode 100644 index 0000000..f70791c --- /dev/null +++ b/Sources/ContainerCommands/Container/ManagedContainer+ListDisplayable.swift @@ -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 } +} diff --git a/Sources/ContainerCommands/Container/ProcessUtils.swift b/Sources/ContainerCommands/Container/ProcessUtils.swift new file mode 100644 index 0000000..016ab6d --- /dev/null +++ b/Sources/ContainerCommands/Container/ProcessUtils.swift @@ -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") + } + } +} diff --git a/Sources/ContainerCommands/DefaultCommand.swift b/Sources/ContainerCommands/DefaultCommand.swift new file mode 100644 index 0000000..3df8f57 --- /dev/null +++ b/Sources/ContainerCommands/DefaultCommand.swift @@ -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) + } +} diff --git a/Sources/ContainerCommands/Flags+ProgressConfig.swift b/Sources/ContainerCommands/Flags+ProgressConfig.swift new file mode 100644 index 0000000..0edf44f --- /dev/null +++ b/Sources/ContainerCommands/Flags+ProgressConfig.swift @@ -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") + } + } +} diff --git a/Sources/ContainerCommands/HelpCommand.swift b/Sources/ContainerCommands/HelpCommand.swift new file mode 100644 index 0000000..43e9107 --- /dev/null +++ b/Sources/ContainerCommands/HelpCommand.swift @@ -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) + } +} diff --git a/Sources/ContainerCommands/Image/ImageCommand.swift b/Sources/ContainerCommands/Image/ImageCommand.swift new file mode 100644 index 0000000..9c94bd4 --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageCommand.swift @@ -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 + } +} diff --git a/Sources/ContainerCommands/Image/ImageDelete.swift b/Sources/ContainerCommands/Image/ImageDelete.swift new file mode 100644 index 0000000..fdc20ac --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageDelete.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Image/ImageInspect.swift b/Sources/ContainerCommands/Image/ImageInspect.swift new file mode 100644 index 0000000..475a587 --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageInspect.swift @@ -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)) + } + } +} diff --git a/Sources/ContainerCommands/Image/ImageList.swift b/Sources/ContainerCommands/Image/ImageList.swift new file mode 100644 index 0000000..6061e70 --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageList.swift @@ -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 ?? "" + 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) + ) + } + } +} diff --git a/Sources/ContainerCommands/Image/ImageLoad.swift b/Sources/ContainerCommands/Image/ImageLoad.swift new file mode 100644 index 0000000..a70d127 --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageLoad.swift @@ -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) + } + } + } +} diff --git a/Sources/ContainerCommands/Image/ImagePrune.swift b/Sources/ContainerCommands/Image/ImagePrune.swift new file mode 100644 index 0000000..420a4ee --- /dev/null +++ b/Sources/ContainerCommands/Image/ImagePrune.swift @@ -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() + 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 + } + } + } +} diff --git a/Sources/ContainerCommands/Image/ImagePull.swift b/Sources/ContainerCommands/Image/ImagePull.swift new file mode 100644 index 0000000..7506dc3 --- /dev/null +++ b/Sources/ContainerCommands/Image/ImagePull.swift @@ -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() + } + } +} diff --git a/Sources/ContainerCommands/Image/ImagePush.swift b/Sources/ContainerCommands/Image/ImagePush.swift new file mode 100644 index 0000000..151ad5f --- /dev/null +++ b/Sources/ContainerCommands/Image/ImagePush.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Image/ImageResource+ListDisplayable.swift b/Sources/ContainerCommands/Image/ImageResource+ListDisplayable.swift new file mode 100644 index 0000000..9a68fde --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageResource+ListDisplayable.swift @@ -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 ?? "", + Utility.trimDigest(digest: configuration.descriptor.digest), + ] + } + + public var quietValue: String { + name + } +} diff --git a/Sources/ContainerCommands/Image/ImageSave.swift b/Sources/ContainerCommands/Image/ImageSave.swift new file mode 100644 index 0000000..c9a1fb9 --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageSave.swift @@ -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) + } + } + } + } +} diff --git a/Sources/ContainerCommands/Image/ImageTag.swift b/Sources/ContainerCommands/Image/ImageTag.swift new file mode 100644 index 0000000..7ec5a61 --- /dev/null +++ b/Sources/ContainerCommands/Image/ImageTag.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/ListDisplayable.swift b/Sources/ContainerCommands/ListDisplayable.swift new file mode 100644 index 0000000..1d4a8d5 --- /dev/null +++ b/Sources/ContainerCommands/ListDisplayable.swift @@ -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 } +} diff --git a/Sources/ContainerCommands/ListFormat.swift b/Sources/ContainerCommands/ListFormat.swift new file mode 100644 index 0000000..fa67ccf --- /dev/null +++ b/Sources/ContainerCommands/ListFormat.swift @@ -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 +} diff --git a/Sources/ContainerCommands/Machine/MachineCapabilities.swift b/Sources/ContainerCommands/Machine/MachineCapabilities.swift new file mode 100644 index 0000000..5957ff0 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineCapabilities.swift @@ -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+)" + ) + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineCommand.swift b/Sources/ContainerCommands/Machine/MachineCommand.swift new file mode 100644 index 0000000..fe09bce --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineCommand.swift @@ -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 + } +} diff --git a/Sources/ContainerCommands/Machine/MachineCreate.swift b/Sources/ContainerCommands/Machine/MachineCreate.swift new file mode 100644 index 0000000..0ed4ec2 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineCreate.swift @@ -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) + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineDelete.swift b/Sources/ContainerCommands/Machine/MachineDelete.swift new file mode 100644 index 0000000..f47723c --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineDelete.swift @@ -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 '.") + } + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineHelpers.swift b/Sources/ContainerCommands/Machine/MachineHelpers.swift new file mode 100644 index 0000000..3241a3a --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineHelpers.swift @@ -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 +} diff --git a/Sources/ContainerCommands/Machine/MachineInspect.swift b/Sources/ContainerCommands/Machine/MachineInspect.swift new file mode 100644 index 0000000..49053c7 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineInspect.swift @@ -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 + } +} diff --git a/Sources/ContainerCommands/Machine/MachineList.swift b/Sources/ContainerCommands/Machine/MachineList.swift new file mode 100644 index 0000000..3677064 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineList.swift @@ -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 + } +} diff --git a/Sources/ContainerCommands/Machine/MachineLogs.swift b/Sources/ContainerCommands/Machine/MachineLogs.swift new file mode 100644 index 0000000..b76cebe --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineLogs.swift @@ -0,0 +1,152 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS +import Foundation +import MachineAPIClient + +extension Application { + public struct MachineLogs: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch container machine logs" + ) + + @Flag(name: .long, help: "Display the boot log for the container machine 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: "Machine VM ID (uses default if not specified)") + var id: String? + + public func run() async throws { + let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) + + Task { + for await _ in sigHandler.signals { + Darwin.exit(0) + } + } + + let client = MachineClient() + let id = try await resolveMachineId(id, client: client) + + let fhs = try await client.logs(id: id) + 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 { 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) + } + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineRun.swift b/Sources/ContainerCommands/Machine/MachineRun.swift new file mode 100644 index 0000000..dc62132 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineRun.swift @@ -0,0 +1,164 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS +import Foundation +import MachineAPIClient +import SystemPackage + +extension Application { + public struct MachineRun: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run a command or interactive shell in a container machine, booting the container machine if necessary" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @OptionGroup(title: "Process options") + var processFlags: Flags.Process + + @Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)") + var name: String? + + @Flag(name: .shortAndLong, help: "Run a process in a container machine and detach from it") + public var detach = false + + @Flag(name: .long, help: "Run as root instead of matching host user") + var root: Bool = false + + @Argument(help: "Command to run (default: login shell)") + var executable: String? + + @Argument(parsing: .captureForPassthrough, help: "Command arguments") + var arguments: [String] = [] + + public func run() async throws { + let client = MachineClient() + let containerClient = ContainerClient() + + let snapshot = try await bootMachine(id: name, client: client, log: log, interactive: true) + + guard let containerId = snapshot.containerId else { + throw ContainerizationError( + .invalidState, + message: "container machine is running but has no container ID" + ) + } + // Default runs `/sbin.machine/init -s` to find the shell for user + let executablePath = FilePath("/\(MachineBundle.sbinDirectory)").appending(MachineBundle.initFile).string + + let args: [String] + let tty: Bool + let interactive: Bool + + if let executable { + args = ["-s", executable] + arguments + tty = processFlags.tty + interactive = processFlags.interactive + } else { + args = ["-s"] + tty = true + interactive = true + } + + // If not root user, get default user from machine configuration + let defaultUser: ProcessConfiguration.User = { + if root || getuid() == 0 { + return .id(uid: 0, gid: 0) + } + + return snapshot.configuration.user + }() + + let (user, additionalGroups) = Parser.user( + user: processFlags.user, uid: processFlags.uid, + gid: processFlags.gid, defaultUser: defaultUser) + + let cwd = getWorkingDirectory(snapshot, user: user) + + // Build environment with HOME set correctly + let envVars = try Parser.allEnv( + imageEnvs: ["PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"], + envFiles: processFlags.envFile, + envs: processFlags.env + ) + + let processConfig = ProcessConfiguration( + executable: executablePath, + arguments: args, + environment: envVars, + workingDirectory: cwd, + terminal: tty, + user: user, + supplementalGroups: additionalGroups + ) + + let io = try ProcessIO.create(tty: tty, interactive: interactive, detach: detach) + defer { + try? io.close() + } + + let process = try await containerClient.createProcess( + containerId: containerId, + processId: UUID().uuidString.lowercased(), + configuration: processConfig, + stdio: io.stdio + ) + + if !tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + if detach { + try await process.start() + try io.closeAfterStart() + print(snapshot.id) + return + } + + let exitCode = try await io.handleProcess(process: process, log: log) + throw ArgumentParser.ExitCode(exitCode) + } + + func getWorkingDirectory(_ snapshot: MachineSnapshot, user: ProcessConfiguration.User) -> String { + if let cwd = processFlags.cwd { + return cwd + } + let fallback = user == snapshot.configuration.user ? snapshot.configuration.home : "/" + if snapshot.bootConfig.homeMount == .none { + return fallback + } + let home = FilePath(FileManager.default.homeDirectoryForCurrentUser.path) + let cwd = FilePath(FileManager.default.currentDirectoryPath) + guard cwd.starts(with: home) else { + return fallback + } + return cwd.string + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineSet.swift b/Sources/ContainerCommands/Machine/MachineSet.swift new file mode 100644 index 0000000..8eace27 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineSet.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import MachineAPIClient + +extension Application { + public struct MachineSet: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set container machine configuration values" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Option(name: [.short, .long], help: "Container machine ID (uses default if not specified)") + public var name: String? + + @Argument( + parsing: .remaining, + help: ArgumentHelp( + "Configuration values", + discussion: MachineConfig.helpText(), + valueName: "setting" + ) + ) + public var rawArgs: [String] = [] + + public func run() async throws { + guard !rawArgs.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "expected at least one configuration value (e.g., machine set cpus=4)") + } + + let client = MachineClient() + let resolvedName = try await resolveMachineId(name, client: client) + let snapshot = try await client.inspect(id: resolvedName) + + let kwargs = Dictionary( + try rawArgs.map { arg in + let parts = arg.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { + throw ContainerizationError(.invalidArgument, message: "invalid argument format '\(arg)'. Expected 'key=value'") + } + + return (String(parts[0]), String(parts[1])) + }, + uniquingKeysWith: { _, last in last } + ) + + if kwargs["virtualization"] == "true" { + try MachineCapabilities.requireNestedVirtualizationSupported() + } + var validatedKwargs = kwargs + if let raw = kwargs["kernel"], !raw.isEmpty { + validatedKwargs["kernel"] = try MachineConfig.validateKernelPath(raw).string + } + + let newConfig = try snapshot.bootConfig.with(validatedKwargs) + + try await client.setConfig(id: resolvedName, bootConfig: newConfig) + + if snapshot.status == .running { + FileHandle.standardError.write( + Data("Note: Changes will take effect after stopping and restarting '\(resolvedName)'.\n".utf8)) + } + + print(resolvedName) + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineSetDefault.swift b/Sources/ContainerCommands/Machine/MachineSetDefault.swift new file mode 100644 index 0000000..a73f9c7 --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineSetDefault.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application { + public struct MachineSetDefault: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "set-default", + abstract: "Set the default container machine") + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Container machine ID") + public var id: String + + public func run() async throws { + let client = MachineClient() + try await client.setDefault(id: id) + print(id) + } + } +} diff --git a/Sources/ContainerCommands/Machine/MachineStop.swift b/Sources/ContainerCommands/Machine/MachineStop.swift new file mode 100644 index 0000000..3a0dedd --- /dev/null +++ b/Sources/ContainerCommands/Machine/MachineStop.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// 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 MachineAPIClient +import TerminalProgress + +extension Application { + public struct MachineStop: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop a running container machine" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @OptionGroup(visibility: .hidden) + var progressFlags: Flags.Progress + + @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 progressConfig = try self.progressFlags.makeConfig( + description: "Stopping container machine" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + try await client.stop(id: machineId) + + progress.finish() + print(machineId) + } + } +} diff --git a/Sources/ContainerCommands/Network/NetworkCommand.swift b/Sources/ContainerCommands/Network/NetworkCommand.swift new file mode 100644 index 0000000..f09d163 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkCommand.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// 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 NetworkCommand: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "network", + abstract: "Manage container networks", + subcommands: [ + NetworkCreate.self, + NetworkDelete.self, + NetworkList.self, + NetworkInspect.self, + NetworkPrune.self, + ], + aliases: ["n"] + ) + + public init() {} + + @OptionGroup + public var logOptions: Flags.Logging + } +} diff --git a/Sources/ContainerCommands/Network/NetworkCreate.swift b/Sources/ContainerCommands/Network/NetworkCreate.swift new file mode 100644 index 0000000..2278fb3 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkCreate.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// 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 +import TerminalProgress + +extension Application { + public struct NetworkCreate: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new network") + + @Flag(name: .customLong("internal"), help: "Restrict to host-only network") + var hostOnly: Bool = false + + @Option(name: .customLong("label"), help: "Set metadata for a network") + var labels: [String] = [] + + @Option(name: .customLong("option"), help: "Set a plugin-specific option (key=value)") + var options: [String] = [] + + @Option(name: .long, help: "Set the plugin to use to create this network.") + var plugin: String = "container-network-vmnet" + + @Option( + name: .customLong("subnet"), help: "Set subnet for a network", + transform: { + try CIDRv4($0) + }) + var ipv4Subnet: CIDRv4? = nil + + @Option( + name: .customLong("subnet-v6"), help: "Set the IPv6 prefix for a network", + transform: { + try CIDRv6($0) + }) + var ipv6Subnet: CIDRv6? = nil + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Network name") + var name: String + + public init() {} + + public func run() async throws { + let parsedLabels = try ResourceLabels(Utility.parseKeyValuePairs(labels)) + let parsedOptions = Utility.parseKeyValuePairs(options) + let mode: NetworkMode = hostOnly ? .hostOnly : .nat + let config = try NetworkConfiguration( + name: self.name, + mode: mode, + ipv4Subnet: ipv4Subnet, + ipv6Subnet: ipv6Subnet, + labels: parsedLabels, + plugin: self.plugin, + options: parsedOptions + ) + let networkClient = NetworkClient() + let network = try await networkClient.create(configuration: config) + print(network.id) + } + } +} diff --git a/Sources/ContainerCommands/Network/NetworkDelete.swift b/Sources/ContainerCommands/Network/NetworkDelete.swift new file mode 100644 index 0000000..91225a0 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkDelete.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// 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 NetworkDelete: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete one or more networks", + aliases: ["rm"]) + + @Flag(name: .shortAndLong, help: "Delete all networks") + var all = false + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Network names") + var networkNames: [String] = [] + + public init() {} + + public func validate() throws { + if networkNames.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no networks specified and --all not supplied") + } + if networkNames.count > 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied network name(s) conflict with the --all flag" + ) + } + } + + public mutating func run() async throws { + let networkClient = NetworkClient() + let uniqueNetworkNames = Set(networkNames) + let networks: [NetworkResource] + + if all { + networks = try await networkClient.list() + .filter { !$0.isBuiltin } + } else { + networks = try await networkClient.list() + .filter { c in + guard uniqueNetworkNames.contains(c.id) else { + return false + } + guard !c.isBuiltin else { + throw ContainerizationError( + .invalidArgument, + message: "cannot delete a builtin network: \(c.id)" + ) + } + return true + } + + // If one of the networks requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no networks to remove, + // otherwise it'd be quite clunky. + if networks.count != uniqueNetworkNames.count { + let missing = uniqueNetworkNames.filter { id in + !networks.contains { n in + n.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more networks: \(missing)" + ) + } + } + + var failed = [String]() + let _log = log + try await withThrowingTaskGroup(of: NetworkResource?.self) { group in + for network in networks { + group.addTask { + do { + // Delete atomically disables the IP allocator, then deletes + // the allocator. The disable fails if any IPs are still in use. + try await networkClient.delete(id: network.id) + print(network.id) + return nil + } catch { + _log.error( + "failed to delete network", + metadata: [ + "id": "\(network.id)", + "error": "\(error)", + ]) + return network + } + } + } + + for try await network in group { + guard let network else { + continue + } + failed.append(network.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more networks: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCommands/Network/NetworkInspect.swift b/Sources/ContainerCommands/Network/NetworkInspect.swift new file mode 100644 index 0000000..fcee1f4 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkInspect.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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 NetworkInspect: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more networks") + + @Argument(help: "Networks to inspect") + var networks: [String] + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let networkClient = NetworkClient() + let uniqueNames = Set(networks) + let items = try await networkClient.list().filter { uniqueNames.contains($0.id) } + + if items.count != uniqueNames.count { + let found = Set(items.map { $0.id }) + let missing = uniqueNames.subtracting(found).sorted() + throw ContainerizationError( + .notFound, + message: "network not found: \(missing.joined(separator: ", "))" + ) + } + + try Output.emit(Output.renderJSON(items, options: .pretty)) + } + } +} diff --git a/Sources/ContainerCommands/Network/NetworkList.swift b/Sources/ContainerCommands/Network/NetworkList.swift new file mode 100644 index 0000000..9ee8599 --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkList.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + public struct NetworkList: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List networks", + aliases: ["ls"]) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @Flag(name: .shortAndLong, help: "Only output the network name") + var quiet = false + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let networkClient = NetworkClient() + let networks = try await networkClient.list() + try Output.render(payload: networks, display: networks, format: format, quiet: quiet) + } + } +} diff --git a/Sources/ContainerCommands/Network/NetworkPrune.swift b/Sources/ContainerCommands/Network/NetworkPrune.swift new file mode 100644 index 0000000..55748cf --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkPrune.swift @@ -0,0 +1,73 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application.NetworkCommand { + public struct NetworkPrune: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove networks with no container connections" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + public func run() async throws { + let networkClient = NetworkClient() + let client = ContainerClient() + let allContainers = try await client.list() + let allNetworks = try await networkClient.list() + + var networksInUse = Set() + for container in allContainers { + for network in container.configuration.networks { + networksInUse.insert(network.network) + } + } + + let networksToPrune = allNetworks.filter { network in + !network.isBuiltin && !networksInUse.contains(network.id) + } + + var prunedNetworks = [String]() + + for network in networksToPrune { + do { + try await networkClient.delete(id: network.id) + prunedNetworks.append(network.id) + } catch { + // Note: This failure may occur due to a race condition between the network/ + // container collection above and a container run command that attaches to a + // network listed in the networksToPrune collection. + log.error( + "failed to prune network", + metadata: [ + "id": "\(network.id)", + "error": "\(error)", + ]) + } + } + + for name in prunedNetworks { + print(name) + } + } + } +} diff --git a/Sources/ContainerCommands/Network/NetworkResource+ListDisplayable.swift b/Sources/ContainerCommands/Network/NetworkResource+ListDisplayable.swift new file mode 100644 index 0000000..26be85c --- /dev/null +++ b/Sources/ContainerCommands/Network/NetworkResource+ListDisplayable.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension NetworkResource: ListDisplayable { + public static var tableHeader: [String] { + ["NETWORK", "SUBNET"] + } + + public var tableRow: [String] { + [id, status.ipv4Subnet.description] + } + + public var quietValue: String { + id + } +} diff --git a/Sources/ContainerCommands/OutputRendering.swift b/Sources/ContainerCommands/OutputRendering.swift new file mode 100644 index 0000000..2f085dc --- /dev/null +++ b/Sources/ContainerCommands/OutputRendering.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import TOML +import Yams + +/// Options for JSON rendering, wrapping the knobs on `JSONEncoder`. +public struct JSONOptions: Sendable { + public var outputFormatting: JSONEncoder.OutputFormatting = [] + public var dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate + + public static let compact = JSONOptions(outputFormatting: [.sortedKeys], dateEncodingStrategy: .iso8601) + public static let pretty = JSONOptions(outputFormatting: [.prettyPrinted, .sortedKeys], dateEncodingStrategy: .iso8601) + + public init( + outputFormatting: JSONEncoder.OutputFormatting = [], + dateEncodingStrategy: JSONEncoder.DateEncodingStrategy = .deferredToDate + ) { + self.outputFormatting = outputFormatting + self.dateEncodingStrategy = dateEncodingStrategy + } +} + +/// Shared rendering helpers for CLI output. +/// +/// All list commands route their output through these methods. The machine-readable +/// payload is encoded separately from table/quiet output, since the payload model +/// often differs from the display model. +public enum Output { + /// Renders an `Encodable` value as a JSON string. + public static func renderJSON(_ value: T, options: JSONOptions = .compact) throws -> String { + let encoder = JSONEncoder() + encoder.outputFormatting = options.outputFormatting + encoder.dateEncodingStrategy = options.dateEncodingStrategy + let data = try encoder.encode(value) + return String(decoding: data, as: UTF8.self) + } + + public static func renderYAML(_ value: T) throws -> String { + let encoder = YAMLEncoder() + let data = try encoder.encode(value) + return data + } + + /// Renders an `Encodable` value as a TOML string. + /// + /// TOML has no top-level array, so array payloads are wrapped under a stable + /// `items` key (JSON and YAML emit a top-level array instead); a non-array + /// value encodes as a normal top-level table. + public static func renderTOML(_ value: T) throws -> String { + let encoder = TOMLEncoder() + encoder.outputFormatting = .sortedKeys + if value is [Any] { + return try encoder.encodeToString(["items": value]) + } + return try encoder.encodeToString(value) + } + + /// Renders a list of displayable items as a table (with header) or quiet-mode identifiers. + public static func renderList(_ items: [T], quiet: Bool) -> String { + if quiet { + return items.map(\.quietValue).joined(separator: "\n") + } + return renderTable(items) + } + + /// Renders a list of displayable items as a column-aligned table with a header row. + public static func renderTable(_ items: [T]) -> String { + var rows: [[String]] = [T.tableHeader] + for item in items { + rows.append(item.tableRow) + } + return TableOutput(rows: rows).format() + } + + /// Renders `payload` in the requested format, encoding it for the + /// machine-readable formats and delegating `.table` to the caller. + /// + /// This is the single place where `ListFormat` is matched exhaustively, so + /// adopting commands handle every format by construction: a new `ListFormat` + /// case becomes a compile error here until it is given an encoder. + public static func render( + payload: J, format: ListFormat, jsonOptions: JSONOptions = .compact, table: () throws -> String + ) throws { + switch format { + case .json: try emit(renderJSON(payload, options: jsonOptions)) + case .yaml: try emit(renderYAML(payload)) + case .toml: try emit(renderTOML(payload)) + case .table: try emit(table()) + } + } + + /// Renders list output in the requested format. + /// + /// The machine-readable payload and the display model may be the same type + /// (e.g., `ManagedContainer`) or different types. + public static func render( + payload: J, display: [D], format: ListFormat, quiet: Bool, jsonOptions: JSONOptions = .compact + ) throws { + try render(payload: payload, format: format, jsonOptions: jsonOptions) { + renderList(display, quiet: quiet) + } + } + + /// Writes rendered output to stdout. No-ops on empty strings to avoid blank lines + /// (e.g., `container list -q` with zero results should produce no output, not a newline). + public static func emit(_ output: String) { + if !output.isEmpty { + print(output) + } + } +} diff --git a/Sources/ContainerCommands/Registry/RegistryCommand.swift b/Sources/ContainerCommands/Registry/RegistryCommand.swift new file mode 100644 index 0000000..5aab382 --- /dev/null +++ b/Sources/ContainerCommands/Registry/RegistryCommand.swift @@ -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 RegistryCommand: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "registry", + abstract: "Manage registry logins", + subcommands: [ + RegistryLogin.self, + RegistryLogout.self, + RegistryList.self, + ], + aliases: ["r"] + ) + + public init() {} + + @OptionGroup + public var logOptions: Flags.Logging + } +} diff --git a/Sources/ContainerCommands/Registry/RegistryList.swift b/Sources/ContainerCommands/Registry/RegistryList.swift new file mode 100644 index 0000000..c23d335 --- /dev/null +++ b/Sources/ContainerCommands/Registry/RegistryList.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import ContainerizationOS +import Foundation + +extension Application { + public struct RegistryList: AsyncLoggableCommand { + @OptionGroup + public var logOptions: Flags.Logging + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @Flag(name: .shortAndLong, help: "Only output the registry hostname") + var quiet = false + + public init() {} + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List image registry logins", + aliases: ["ls"]) + + public func run() async throws { + let keychain = KeychainHelper(securityDomain: Constants.keychainID) + let registryInfos = try keychain.list() + let registries = registryInfos.map { RegistryResource(from: $0) } + + try Output.render( + payload: registries, + display: registries.map { PrintableRegistry($0) }, + format: format, quiet: quiet + ) + } + } +} + +private struct PrintableRegistry: ListDisplayable { + let registry: RegistryResource + + init(_ registry: RegistryResource) { + self.registry = registry + } + + static var tableHeader: [String] { + ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] + } + + var tableRow: [String] { + [ + registry.name, + registry.username, + registry.modificationDate.ISO8601Format(), + registry.creationDate.ISO8601Format(), + ] + } + + var quietValue: String { + registry.name + } +} diff --git a/Sources/ContainerCommands/Registry/RegistryLogin.swift b/Sources/ContainerCommands/Registry/RegistryLogin.swift new file mode 100644 index 0000000..5dce6c5 --- /dev/null +++ b/Sources/ContainerCommands/Registry/RegistryLogin.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation + +extension Application { + public struct RegistryLogin: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "login", + abstract: "Log in to a registry" + ) + + @OptionGroup + var registry: Flags.Registry + + @Flag(help: "Take the password from stdin") + var passwordStdin: Bool = false + + @Option(name: .shortAndLong, help: "Registry user name") + var username: String = "" + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Registry server name") + var server: String + + public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig() + var username = self.username + var password = "" + if passwordStdin { + if username == "" { + throw ContainerizationError( + .invalidArgument, message: "must provide --username with --password-stdin") + } + guard let passwordData = try FileHandle.standardInput.readToEnd() else { + throw ContainerizationError(.invalidArgument, message: "failed to read password from stdin") + } + password = String(decoding: passwordData, as: UTF8.self).trimmingCharacters(in: .whitespacesAndNewlines) + } + let keychain = KeychainHelper(securityDomain: Constants.keychainID) + if username == "" { + username = try keychain.userPrompt(hostname: server) + } + if password == "" { + password = try keychain.passwordPrompt() + print() + } + + let server = Reference.resolveDomain(domain: server) + let scheme = try RequestScheme(registry.scheme).schemeFor(host: server, internalDnsDomain: containerSystemConfig.dns.domain) + let _url = "\(scheme)://\(server)" + guard let url = URL(string: _url) else { + throw ContainerizationError(.invalidArgument, message: "cannot convert \(_url) to URL") + } + guard let host = url.host else { + throw ContainerizationError(.invalidArgument, message: "invalid host \(server)") + } + + let client = RegistryClient( + host: host, + scheme: scheme.rawValue, + port: url.port, + authentication: BasicAuthentication(username: username, password: password), + retryOptions: .init( + maxRetries: 10, + retryInterval: 300_000_000, + shouldRetry: ({ response in + response.status.code >= 500 + }) + ) + ) + try await client.ping() + try keychain.save(hostname: server, username: username, password: password) + log.info("Login succeeded") + } + } +} diff --git a/Sources/ContainerCommands/Registry/RegistryLogout.swift b/Sources/ContainerCommands/Registry/RegistryLogout.swift new file mode 100644 index 0000000..2f5a243 --- /dev/null +++ b/Sources/ContainerCommands/Registry/RegistryLogout.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationOCI + +extension Application { + public struct RegistryLogout: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "logout", + abstract: "Log out from a registry" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Registry server name") + var registry: String + + public func run() async throws { + let keychain = KeychainHelper(securityDomain: Constants.keychainID) + let r = Reference.resolveDomain(domain: registry) + try keychain.delete(hostname: r) + } + } +} diff --git a/Sources/ContainerCommands/System/DNS/DNSCreate.swift b/Sources/ContainerCommands/System/DNS/DNSCreate.swift new file mode 100644 index 0000000..0367b33 --- /dev/null +++ b/Sources/ContainerCommands/System/DNS/DNSCreate.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import ContainerizationExtras +import DNSServer +import Foundation + +extension Application { + public struct DNSCreate: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a local DNS domain for containers (must run as an administrator)" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Option(name: .long, help: "Set the ip address to be redirected to localhost") + var localhost: String? + + @Argument(help: "The local domain name") + var domainName: String + + public init() {} + + public func run() async throws { + var localhostIP: IPAddress? = nil + if let localhost { + localhostIP = try? IPAddress(localhost) + guard let localhostIP, case .v4(_) = localhostIP else { + throw ContainerizationError(.invalidArgument, message: "invalid IPv4 address: \(localhost)") + } + } + + guard let domainName = try? DNSName(domainName) else { + throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(domainName)") + } + + let resolver: HostDNSResolver = HostDNSResolver() + do { + try resolver.createDomain(name: domainName, localhost: localhostIP) + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + let pf = PacketFilter() + if let from = localhostIP { + let to = try! IPAddress("127.0.0.1") + do { + try pf.createRedirectRule(from: from, to: to, domain: domainName) + } catch { + _ = try resolver.deleteDomain(name: domainName) + throw error + } + } + print(domainName.pqdn) + + if localhostIP != nil { + do { + try pf.reinitialize() + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "failed loading pf rules") + } + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/ContainerCommands/System/DNS/DNSDelete.swift b/Sources/ContainerCommands/System/DNS/DNSDelete.swift new file mode 100644 index 0000000..fc372e5 --- /dev/null +++ b/Sources/ContainerCommands/System/DNS/DNSDelete.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import DNSServer +import Foundation + +extension Application { + public struct DNSDelete: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a local DNS domain (must run as an administrator)", + aliases: ["rm"] + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "The local domain name") + var domainName: String + + public init() {} + + public func run() async throws { + guard let domainName = try? DNSName(domainName) else { + throw ContainerizationError(.invalidArgument, message: "invalid domain name: \(domainName)") + } + + let resolver = HostDNSResolver() + var localhostIP: IPAddress? + do { + localhostIP = try resolver.deleteDomain(name: domainName) + } catch { + throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + + guard let localhostIP else { + print(domainName.pqdn) + return + } + + let pf = PacketFilter() + try pf.removeRedirectRule(from: localhostIP, to: try! IPAddress("127.0.0.1"), domain: domainName) + + do { + try pf.reinitialize() + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "failed loading pf rules") + } + print(domainName.pqdn) + } + } +} diff --git a/Sources/ContainerCommands/System/DNS/DNSList.swift b/Sources/ContainerCommands/System/DNS/DNSList.swift new file mode 100644 index 0000000..ea2df01 --- /dev/null +++ b/Sources/ContainerCommands/System/DNS/DNSList.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 DNSServer +import Foundation + +extension Application { + public struct DNSList: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List local DNS domains", + aliases: ["ls"] + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @Flag(name: .shortAndLong, help: "Only output the domain") + var quiet = false + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let resolver = HostDNSResolver() + let domains = resolver.listDomains() + + try Output.render( + payload: domains.map { $0.pqdn }, + display: domains.map { PrintableDomain($0) }, + format: format, quiet: quiet + ) + } + } +} + +private struct PrintableDomain: ListDisplayable { + let domain: DNSName + + init(_ domain: DNSName) { + self.domain = domain + } + + static var tableHeader: [String] { + ["DOMAIN"] + } + + var tableRow: [String] { + [domain.pqdn] + } + + var quietValue: String { + domain.pqdn + } +} diff --git a/Sources/ContainerCommands/System/Kernel/KernelSet.swift b/Sources/ContainerCommands/System/Kernel/KernelSet.swift new file mode 100644 index 0000000..b4a85d6 --- /dev/null +++ b/Sources/ContainerCommands/System/Kernel/KernelSet.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + public struct KernelSet: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set the default kernel" + ) + + @Option(name: .long, help: "The architecture of the kernel binary (values: amd64, arm64)") + var arch: String = ContainerizationOCI.Platform.current.architecture.description + + @Option(name: .customLong("binary"), help: "Path to the kernel file (or archive member, if used with --tar)") + var binaryPath: String? = nil + + @Flag(name: .long, help: "Overwrites an existing kernel with the same name") + var force: Bool = false + + @Flag(name: .long, help: "Download and install the recommended kernel as the default (takes precedence over all other flags)") + var recommended: Bool = false + + @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar archive containing a kernel file") + var tarPath: String? = nil + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig() + if recommended { + let url = containerSystemConfig.kernel.url + let path: String = containerSystemConfig.kernel.binaryPath + log.info("Installing the recommended kernel from \(url)...") + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path, force: force) + return + } + guard tarPath != nil else { + return try await self.setKernelFromBinary() + } + try await self.setKernelFromTar() + } + + private func setKernelFromBinary() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("missing argument '--binary'") + } + let absolutePath = URL(fileURLWithPath: binaryPath, relativeTo: .currentDirectory()).absoluteURL.absoluteString + let platform = try getSystemPlatform() + try await ClientKernel.installKernel(kernelFilePath: absolutePath, platform: platform, force: force) + } + + private func setKernelFromTar() async throws { + guard let binaryPath else { + throw ArgumentParser.ValidationError("missing argument '--binary'") + } + guard let tarPath else { + throw ArgumentParser.ValidationError("missing argument '--tar") + } + let platform = try getSystemPlatform() + let localTarPath = URL(fileURLWithPath: tarPath, relativeTo: .currentDirectory()).path + let fm = FileManager.default + if fm.fileExists(atPath: localTarPath) { + try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform, force: force) + return + } + guard let remoteURL = URL(string: tarPath) else { + throw ContainerizationError(.invalidArgument, message: "invalid remote URL '\(tarPath)' for argument '--tar'. Missing protocol?") + } + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: remoteURL, kernelFilePath: binaryPath, platform: platform, force: force) + } + + private func getSystemPlatform() throws -> SystemPlatform { + switch arch { + case "arm64": + return .linuxArm + case "amd64": + return .linuxAmd + default: + throw ContainerizationError(.unsupported, message: "unsupported architecture \(arch)") + } + } + + static func downloadAndInstallWithProgressBar(tarRemoteURL: URL, kernelFilePath: String, platform: SystemPlatform = .current, force: Bool) async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await ClientKernel.installKernelFromTar( + tarFile: tarRemoteURL.absoluteString, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler, force: force) + progress.finish() + } + + } +} diff --git a/Sources/ContainerCommands/System/Property/PropertyList.swift b/Sources/ContainerCommands/System/Property/PropertyList.swift new file mode 100644 index 0000000..4ddaf08 --- /dev/null +++ b/Sources/ContainerCommands/System/Property/PropertyList.swift @@ -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 ContainerPersistence +import ContainerPlugin +import Foundation + +enum ListOutputFormat: String, Decodable, ExpressibleByArgument { + case json + case toml +} + +extension Application { + public struct PropertyList: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List system properties", + aliases: ["ls"] + ) + + @Option(name: .long, help: "Format of the output") + var format: ListOutputFormat = .toml + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let containerSystemConfig: ContainerSystemConfig = try await Application.loadContainerSystemConfig() + let output = + switch format { + case .json: try Output.renderJSON(containerSystemConfig) + case .toml: try Output.renderTOML(containerSystemConfig) + } + Output.emit(output) + } + } +} diff --git a/Sources/ContainerCommands/System/SystemCommand.swift b/Sources/ContainerCommands/System/SystemCommand.swift new file mode 100644 index 0000000..10b77ca --- /dev/null +++ b/Sources/ContainerCommands/System/SystemCommand.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// 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 SystemCommand: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "system", + abstract: "Manage system components", + subcommands: [ + SystemDF.self, + SystemDNS.self, + SystemKernel.self, + SystemLogs.self, + SystemProperty.self, + SystemStart.self, + SystemStatus.self, + SystemStop.self, + SystemVersion.self, + ], + aliases: ["s"] + ) + + @OptionGroup + public var logOptions: Flags.Logging + } +} diff --git a/Sources/ContainerCommands/System/SystemDF.swift b/Sources/ContainerCommands/System/SystemDF.swift new file mode 100644 index 0000000..3f7d641 --- /dev/null +++ b/Sources/ContainerCommands/System/SystemDF.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application { + public struct SystemDF: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "df", + abstract: "Show disk usage for images, containers, and volumes" + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let stats = try await ClientDiskUsage.get() + try Output.render(payload: stats, format: format, jsonOptions: .pretty) { + diskUsageTable(stats: stats) + } + } + + private func diskUsageTable(stats: DiskUsageStats) -> String { + var rows: [[String]] = [] + + // Header row + rows.append(["TYPE", "TOTAL", "ACTIVE", "SIZE", "RECLAIMABLE"]) + + // Images row + rows.append([ + "Images", + "\(stats.images.total)", + "\(stats.images.active)", + formatSize(stats.images.sizeInBytes), + formatReclaimable(stats.images.reclaimable, total: stats.images.sizeInBytes), + ]) + + // Containers row + rows.append([ + "Containers", + "\(stats.containers.total)", + "\(stats.containers.active)", + formatSize(stats.containers.sizeInBytes), + formatReclaimable(stats.containers.reclaimable, total: stats.containers.sizeInBytes), + ]) + + // Volumes row + rows.append([ + "Local Volumes", + "\(stats.volumes.total)", + "\(stats.volumes.active)", + formatSize(stats.volumes.sizeInBytes), + formatReclaimable(stats.volumes.reclaimable, total: stats.volumes.sizeInBytes), + ]) + + let tableFormatter = TableOutput(rows: rows) + return tableFormatter.format() + } + + private func formatSize(_ bytes: UInt64) -> String { + if bytes == 0 { + return "0 B" + } + let formatter = ByteCountFormatter() + formatter.countStyle = .file + return formatter.string(fromByteCount: Int64(bytes)) + } + + private func formatReclaimable(_ reclaimable: UInt64, total: UInt64) -> String { + let sizeStr = formatSize(reclaimable) + + if total == 0 { + return "\(sizeStr) (0%)" + } + + // Cap at 100% in case reclaimable > total (shouldn't happen but be defensive) + let percentage = min(100, Int(round(Double(reclaimable) / Double(total) * 100.0))) + return "\(sizeStr) (\(percentage)%)" + } + } +} diff --git a/Sources/ContainerCommands/System/SystemDNS.swift b/Sources/ContainerCommands/System/SystemDNS.swift new file mode 100644 index 0000000..e254940 --- /dev/null +++ b/Sources/ContainerCommands/System/SystemDNS.swift @@ -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 +import ContainerizationError +import Foundation + +extension Application { + public struct SystemDNS: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "dns", + abstract: "Manage local DNS domains", + subcommands: [ + DNSCreate.self, + DNSDelete.self, + DNSList.self, + ] + ) + + @OptionGroup + public var logOptions: Flags.Logging + } +} diff --git a/Sources/ContainerCommands/System/SystemKernel.swift b/Sources/ContainerCommands/System/SystemKernel.swift new file mode 100644 index 0000000..2879523 --- /dev/null +++ b/Sources/ContainerCommands/System/SystemKernel.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 SystemKernel: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "kernel", + abstract: "Manage the default kernel configuration", + subcommands: [ + KernelSet.self + ] + ) + + @OptionGroup + public var logOptions: Flags.Logging + } +} diff --git a/Sources/ContainerCommands/System/SystemLogs.swift b/Sources/ContainerCommands/System/SystemLogs.swift new file mode 100644 index 0000000..476010e --- /dev/null +++ b/Sources/ContainerCommands/System/SystemLogs.swift @@ -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 ContainerizationError +import ContainerizationOS +import Foundation +import OSLog + +extension Application { + public struct SystemLogs: AsyncLoggableCommand { + public static let subsystem = "com.apple.container" + + public static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch system logs for `container` services" + ) + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + @Option( + name: .long, + help: "Fetch logs starting from the specified time period (minus the current time); supported formats: [m|h|d] (defaults to seconds)" + ) + var last: String = "5m" + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func validate() throws { + if follow { return } + if let value = Int(last), value > 0 { return } + guard let unit = last.last, "mhd".contains(unit), + let value = Int(last.dropLast()), value > 0 + else { + throw ContainerizationError( + .invalidArgument, + message: "invalid --last value '\(last)': expected a positive integer (seconds) or a value with suffix m, h, or d (e.g. 30, 5m, 1h, 2d)" + ) + } + } + + public func run() async throws { + let process = Process() + let sigHandler = AsyncSignalHandler.create(notify: [SIGINT, SIGTERM]) + + Task { + for await _ in sigHandler.signals { + process.terminate() + Darwin.exit(0) + } + } + + do { + var args = ["log"] + args.append(self.follow ? "stream" : "show") + args.append(contentsOf: ["--info", logOptions.debug ? "--debug" : nil].compactMap { $0 }) + if !self.follow { + args.append(contentsOf: ["--last", last]) + } + args.append(contentsOf: ["--predicate", "subsystem = 'com.apple.container'"]) + + process.launchPath = "/usr/bin/env" + process.arguments = args + + process.standardOutput = FileHandle.standardOutput + process.standardError = FileHandle.standardError + + try process.run() + process.waitUntilExit() + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to system logs: \(error)" + ) + } + throw ArgumentParser.ExitCode(process.terminationStatus) + } + } +} diff --git a/Sources/ContainerCommands/System/SystemProperty.swift b/Sources/ContainerCommands/System/SystemProperty.swift new file mode 100644 index 0000000..d3e346f --- /dev/null +++ b/Sources/ContainerCommands/System/SystemProperty.swift @@ -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 +import ContainerPersistence +import ContainerizationError +import Foundation + +extension Application { + public struct SystemProperty: AsyncLoggableCommand { + public init() {} + + public static let configuration = CommandConfiguration( + commandName: "property", + abstract: "Manage system property values", + subcommands: [ + PropertyList.self + ] + ) + + @OptionGroup + public var logOptions: Flags.Logging + } +} diff --git a/Sources/ContainerCommands/System/SystemStart.swift b/Sources/ContainerCommands/System/SystemStart.swift new file mode 100644 index 0000000..ca3b1a8 --- /dev/null +++ b/Sources/ContainerCommands/System/SystemStart.swift @@ -0,0 +1,219 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import Foundation +import MachineAPIClient +import SystemPackage +import TerminalProgress + +extension Application { + public struct SystemStart: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start `container` services" + ) + + @Option( + name: .shortAndLong, + help: "Path to the root directory for application data", + transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) }) + var appRoot = ApplicationRoot.defaultPath + + @Option( + name: .long, + help: "Path to the root directory for application executables and plugins", + transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) }) + var installRoot = InstallRoot.defaultPath + + @Option( + name: .long, + help: "Path to the root directory for log data, using macOS log facility if not set", + transform: { FilePath(FileManager.default.currentDirectoryPath).resolve($0, defaultPath: FilePath($0)) }) + var logRoot: FilePath? = nil + + @Flag( + name: .long, + inversion: .prefixedEnableDisable, + help: "Specify whether the default kernel should be installed or not (default: prompt user)") + var kernelInstall: Bool? + + @Option( + help: "Number of seconds to wait for API service to become responsive", + transform: { + guard let timeoutSeconds = Double($0) else { + throw ValidationError("Invalid timeout value: \($0)") + } + return .seconds(timeoutSeconds) + } + ) + var timeout: Duration = XPCClient.xpcRegistrationTimeout + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + try ConfigurationLoader.copyConfigurationToReadOnly(to: appRoot) + // Pass appRoot before installRoot: ConfigurationLoader uses first-match-wins + // precedence, so user-provided config in appRoot overrides the defaults + // shipped under installRoot. Both layers are passed explicitly because + // users can override --app-root and --install-root from the CLI, and the + // loader's default search would otherwise ignore those overrides. + let containerSystemConfig: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [ + ConfigurationLoader.configurationFile(in: appRoot, of: .appRoot), + ConfigurationLoader.configurationFile(in: installRoot, of: .installRoot), + ]) + + // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. + // Resolve the symlink to get the true binary path before writing the launchd plist. + // Gatekeeper / amfid validates code signatures relative to the enclosing .app bundle + // hierarchy; launching via a symlink outside the bundle fails that check. + // TODO: Can we use the plugin loader to bootstrap the API server? + let executablePath = try CommandLine.executablePath + .removingLastComponent() + .appending(FilePath.Component("container-apiserver")) + .resolvingSymlinks() + + var args = [executablePath.string] + + args.append("start") + if logOptions.debug { + args.append("--debug") + } + + let apiServerDataPath = appRoot.appending(FilePath.Component("apiserver")) + let apiServerDataURL = URL(fileURLWithPath: apiServerDataPath.string) + try FileManager.default.createDirectory(at: apiServerDataURL, withIntermediateDirectories: true) + + var env = PluginLoader.filterEnvironment() + env[ApplicationRoot.environmentName] = appRoot.string + env[InstallRoot.environmentName] = installRoot.string + if let logRoot { + env[LogRoot.environmentName] = logRoot.string + } + let plist = LaunchPlist( + label: "com.apple.container.apiserver", + arguments: args, + environment: env, + limitLoadToSessionType: [.Aqua, .Background, .System], + runAtLoad: true, + machServices: ["com.apple.container.apiserver"] + ) + + let plistPath = apiServerDataPath.appending(FilePath.Component("apiserver.plist")) + let plistURL = URL(fileURLWithPath: plistPath.string) + let data = try plist.encode() + try data.write(to: plistURL) + + log.info("Launching container-apiserver...") + try ServiceManager.register(plistPath: plistURL.path) + + // Now ping our friendly daemon. Fail if we don't get a response. + do { + log.info("Testing access to container-apiserver...") + _ = try await ClientHealthCheck.ping(timeout: timeout) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from apiserver: \(error)" + ) + } + + do { + print("Verifying machine API server is running...") + _ = try await MachineClient().list() + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from machine API server: \(error)" + ) + } + + if await !initImageExists(containerSystemConfig: containerSystemConfig) { + try? await installInitialFilesystem(initImage: containerSystemConfig.vminit.image) + } + + guard await !kernelExists() else { + return + } + try await installDefaultKernel(kernelURL: containerSystemConfig.kernel.url, kernelBinaryPath: containerSystemConfig.kernel.binaryPath) + } + + private func installInitialFilesystem(initImage: String) async throws { + var pullCommand = try ImagePull.parse() + pullCommand.reference = initImage + log.info("Installing base container filesystem...") + do { + try await pullCommand.run() + } catch { + log.error("failed to install base container filesystem", metadata: ["error": "\(error)"]) + } + } + + private func installDefaultKernel(kernelURL: URL, kernelBinaryPath: String) async throws { + var shouldInstallKernel = false + if kernelInstall == nil { + print("No default kernel configured.") + print("Install the recommended default kernel from [\(kernelURL)]? [Y/n]: ", terminator: "") + guard let read = readLine(strippingNewline: true) else { + throw ContainerizationError(.internalError, message: "failed to read user input") + } + guard read.lowercased() == "y" || read.count == 0 else { + log.info("Please use the `container system kernel set --recommended` command to configure the default kernel") + return + } + shouldInstallKernel = true + } else { + shouldInstallKernel = kernelInstall ?? false + } + guard shouldInstallKernel else { + return + } + log.info("Installing kernel...") + try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: kernelURL, kernelFilePath: kernelBinaryPath, force: true) + } + + private func initImageExists(containerSystemConfig: ContainerSystemConfig) async -> Bool { + do { + let img = try await ClientImage.get( + reference: containerSystemConfig.vminit.image, + containerSystemConfig: containerSystemConfig + ) + let _ = try await img.getSnapshot(platform: .current) + return true + } catch { + return false + } + } + + private func kernelExists() async -> Bool { + do { + try await ClientKernel.getDefaultKernel(for: .current) + return true + } catch { + return false + } + } + } +} diff --git a/Sources/ContainerCommands/System/SystemStatus.swift b/Sources/ContainerCommands/System/SystemStatus.swift new file mode 100644 index 0000000..bfdd765 --- /dev/null +++ b/Sources/ContainerCommands/System/SystemStatus.swift @@ -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 ArgumentParser +import ContainerAPIClient +import ContainerPlugin +import ContainerizationError +import Foundation +import Logging + +extension Application { + public struct SystemStatus: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "status", + abstract: "Show the status of `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for services") + var prefix: String = "com.apple.container." + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + struct PrintableStatus: Codable { + let status: String + let appRoot: String + let installRoot: String + let logRoot: String? + let apiServerVersion: String + let apiServerCommit: String + let apiServerBuild: String + let apiServerAppName: String + + init( + status: String, + appRoot: String = "", + installRoot: String = "", + logRoot: String? = nil, + apiServerVersion: String = "", + apiServerCommit: String = "", + apiServerBuild: String = "", + apiServerAppName: String = "" + ) { + self.status = status + self.appRoot = appRoot + self.installRoot = installRoot + self.logRoot = logRoot + self.apiServerVersion = apiServerVersion + self.apiServerCommit = apiServerCommit + self.apiServerBuild = apiServerBuild + self.apiServerAppName = apiServerAppName + } + } + + public func run() async throws { + let isRegistered = try ServiceManager.isRegistered(fullServiceLabel: "\(prefix)apiserver") + if !isRegistered { + try Output.render(payload: PrintableStatus(status: "unregistered"), format: format) { + "apiserver is not running and not registered with launchd" + } + Application.exit(withError: ExitCode(1)) + } + + // Now ping our friendly daemon. Fail after 10 seconds with no response. + do { + let systemHealth = try await ClientHealthCheck.ping(timeout: .seconds(10)) + let status = PrintableStatus( + status: "running", + appRoot: systemHealth.appRoot.path(percentEncoded: false), + installRoot: systemHealth.installRoot.path(percentEncoded: false), + logRoot: systemHealth.logRoot?.string, + apiServerVersion: systemHealth.apiServerVersion, + apiServerCommit: systemHealth.apiServerCommit, + apiServerBuild: systemHealth.apiServerBuild, + apiServerAppName: systemHealth.apiServerAppName + ) + try Output.render(payload: status, format: format) { + Self.statusTable(status) + } + } catch { + try Output.render(payload: PrintableStatus(status: "not running"), format: format) { + "apiserver is not running" + } + Application.exit(withError: ExitCode(1)) + } + } + + private static func statusTable(_ status: PrintableStatus) -> String { + let rows: [[String]] = [ + ["FIELD", "VALUE"], + ["status", status.status], + ["appRoot", status.appRoot], + ["installRoot", status.installRoot], + ["logRoot", status.logRoot ?? ""], + ["apiserver.version", status.apiServerVersion], + ["apiserver.commit", status.apiServerCommit], + ["apiserver.build", status.apiServerBuild], + ["apiserver.appName", status.apiServerAppName], + ] + return TableOutput(rows: rows).format() + } + } +} diff --git a/Sources/ContainerCommands/System/SystemStop.swift b/Sources/ContainerCommands/System/SystemStop.swift new file mode 100644 index 0000000..164dd90 --- /dev/null +++ b/Sources/ContainerCommands/System/SystemStop.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource +import Containerization +import ContainerizationOS +import Foundation +import Logging + +extension Application { + public struct SystemStop: AsyncLoggableCommand { + private static let stopTimeoutSeconds: Int32 = 5 + private static let shutdownTimeoutSeconds: Int32 = 20 + + public static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop all `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for services") + var prefix: String = "com.apple.container." + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let log = Logger( + label: "com.apple.container.cli", + factory: { label in + StreamLogHandler.standardOutput(label: label) + } + ) + + let launchdDomainString = try ServiceManager.getDomainString() + let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" + + var running = true + do { + log.info("checking if APIServer is alive") + _ = try await ClientHealthCheck.ping(timeout: .seconds(5)) + } catch { + log.info("APIServer health check failed, skipping bootout") + running = false + } + + if running { + let client = ContainerClient() + log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) + do { + let containers = try await client.list().map { $0.id } + let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: nil) + try await ContainerStop.stopContainers( + client: client, + containers: containers, + stopOptions: opts, + ) + } catch { + log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) + } + + log.info("waiting for containers to exit") + do { + for _ in 0.. String { + let header = ["COMPONENT", "VERSION", "BUILD", "COMMIT"] + let rows = [header] + versions.map { [$0.appName, $0.version, $0.buildType, $0.commit] } + return TableOutput(rows: rows).format() + } + } + + public struct VersionInfo: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + } +} diff --git a/Sources/ContainerCommands/TableOutput.swift b/Sources/ContainerCommands/TableOutput.swift new file mode 100644 index 0000000..5d264d9 --- /dev/null +++ b/Sources/ContainerCommands/TableOutput.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +public struct TableOutput: Sendable { + private let rows: [[String]] + private let spacing: Int + + public init( + rows: [[String]], + spacing: Int = 2 + ) { + self.rows = rows + self.spacing = spacing + } + + public func format() -> String { + var output = "" + let maxLengths = self.maxLength() + + for rowIndex in 0.. [Int: Int] { + var output: [Int: Int] = [:] + for row in self.rows { + for (i, column) in row.enumerated() { + let currentMax = output[i] ?? 0 + output[i] = (column.count > currentMax) ? column.count : currentMax + } + } + return output + } +} diff --git a/Sources/ContainerCommands/Volume/VolumeCommand.swift b/Sources/ContainerCommands/Volume/VolumeCommand.swift new file mode 100644 index 0000000..e03cbc2 --- /dev/null +++ b/Sources/ContainerCommands/Volume/VolumeCommand.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// 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 VolumeCommand: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "volume", + abstract: "Manage container volumes", + subcommands: [ + VolumeCreate.self, + VolumeDelete.self, + VolumeList.self, + VolumeInspect.self, + VolumePrune.self, + ], + aliases: ["v"] + ) + + public init() {} + + @OptionGroup + public var logOptions: Flags.Logging + } +} diff --git a/Sources/ContainerCommands/Volume/VolumeCreate.swift b/Sources/ContainerCommands/Volume/VolumeCreate.swift new file mode 100644 index 0000000..cc41fd2 --- /dev/null +++ b/Sources/ContainerCommands/Volume/VolumeCreate.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application.VolumeCommand { + public struct VolumeCreate: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new volume" + ) + + @Option(name: .customLong("label"), help: "Set metadata for a volume") + var labels: [String] = [] + + @Option(name: .customLong("opt"), help: "Set driver specific options") + var driverOpts: [String] = [] + + @Option(name: .short, help: "Size of the volume in bytes, with optional K, M, G, T, or P suffix") + var size: String? + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Volume name") + var name: String + + public init() {} + + public func run() async throws { + var parsedDriverOpts = Utility.parseKeyValuePairs(driverOpts) + let parsedLabels = Utility.parseKeyValuePairs(labels) + + // If --size is specified, add it to driver options + if let size = size { + parsedDriverOpts["size"] = size + } + + let volume = try await ClientVolume.create( + name: name, + driver: "local", + driverOpts: parsedDriverOpts, + labels: parsedLabels + ) + print(volume.name) + } + } +} diff --git a/Sources/ContainerCommands/Volume/VolumeDelete.swift b/Sources/ContainerCommands/Volume/VolumeDelete.swift new file mode 100644 index 0000000..ab150cd --- /dev/null +++ b/Sources/ContainerCommands/Volume/VolumeDelete.swift @@ -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 ContainerResource +import ContainerizationError +import Foundation + +extension Application.VolumeCommand { + public struct VolumeDelete: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete one or more volumes", + aliases: ["rm"] + ) + + @Flag(name: .shortAndLong, help: "Delete all volumes") + var all = false + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Volume names") + var names: [String] = [] + + public init() {} + + public func validate() throws { + if names.count == 0 && !all { + throw ContainerizationError(.invalidArgument, message: "no volumes specified and --all not supplied") + } + if names.count > 0 && all { + throw ContainerizationError( + .invalidArgument, + message: "explicitly supplied volume name(s) conflict with the --all flag" + ) + } + } + + public func run() async throws { + let uniqueVolumeNames = Set(names) + let volumes: [VolumeConfiguration] + + if all { + volumes = try await ClientVolume.list() + } else { + volumes = try await ClientVolume.list() + .filter { v in + uniqueVolumeNames.contains(v.id) + } + + // If one of the volumes requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no volumes to remove, + // otherwise it'd be quite clunky. + if volumes.count != uniqueVolumeNames.count { + let missing = uniqueVolumeNames.filter { id in + !volumes.contains { v in + v.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more volumes: \(missing)" + ) + } + } + + var failed = [String]() + let _log = log + try await withThrowingTaskGroup(of: VolumeConfiguration?.self) { group in + for volume in volumes { + group.addTask { + do { + try await ClientVolume.delete(name: volume.id) + print(volume.id) + return nil + } catch { + _log.error( + "failed to delete volume", + metadata: [ + "id": "\(volume.id)", + "error": "\(error)", + ]) + return volume + } + } + } + + for try await volume in group { + guard let volume else { + continue + } + failed.append(volume.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more volumes: \(failed)") + } + } + } +} diff --git a/Sources/ContainerCommands/Volume/VolumeInspect.swift b/Sources/ContainerCommands/Volume/VolumeInspect.swift new file mode 100644 index 0000000..a0be58d --- /dev/null +++ b/Sources/ContainerCommands/Volume/VolumeInspect.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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.VolumeCommand { + public struct VolumeInspect: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more volumes" + ) + + @OptionGroup + public var logOptions: Flags.Logging + + @Argument(help: "Volumes to inspect") + var names: [String] + + public init() {} + + public func run() async throws { + let uniqueNames = Set(names) + let volumes = try await ClientVolume.list().filter { uniqueNames.contains($0.id) } + let volumeResources = volumes.map { VolumeResource(configuration: $0) } + + if volumes.count != uniqueNames.count { + let found = Set(volumes.map { $0.id }) + let missing = uniqueNames.subtracting(found).sorted() + throw ContainerizationError( + .notFound, + message: "volume not found: \(missing.joined(separator: ", "))" + ) + } + + try Output.emit(Output.renderJSON(volumeResources, options: .pretty)) + } + } +} diff --git a/Sources/ContainerCommands/Volume/VolumeList.swift b/Sources/ContainerCommands/Volume/VolumeList.swift new file mode 100644 index 0000000..9bcddf6 --- /dev/null +++ b/Sources/ContainerCommands/Volume/VolumeList.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension Application.VolumeCommand { + public struct VolumeList: AsyncLoggableCommand { + public static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List volumes", + aliases: ["ls"] + ) + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @Flag(name: .shortAndLong, help: "Only output the volume name") + var quiet: Bool = false + + @OptionGroup + public var logOptions: Flags.Logging + + public init() {} + + public func run() async throws { + let volumes = try await ClientVolume.list() + let volumeResources = volumes.map { VolumeResource(configuration: $0) } + try Output.render(payload: volumeResources, display: volumeResources, format: format, quiet: quiet) + } + } +} diff --git a/Sources/ContainerCommands/Volume/VolumePrune.swift b/Sources/ContainerCommands/Volume/VolumePrune.swift new file mode 100644 index 0000000..cad2391 --- /dev/null +++ b/Sources/ContainerCommands/Volume/VolumePrune.swift @@ -0,0 +1,78 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension Application.VolumeCommand { + public struct VolumePrune: AsyncLoggableCommand { + public init() {} + public static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove volumes with no container references") + + @OptionGroup + public var logOptions: Flags.Logging + + public func run() async throws { + let allVolumes = try await ClientVolume.list() + + // Find all volumes not used by any container + let client = ContainerClient() + let containers = try await client.list() + var volumesInUse = Set() + for container in containers { + for mount in container.configuration.mounts { + if mount.isVolume, let volumeName = mount.volumeName { + volumesInUse.insert(volumeName) + } + } + } + + let volumesToPrune = allVolumes.filter { volume in + !volumesInUse.contains(volume.name) + } + + var prunedVolumes = [String]() + var totalSize: UInt64 = 0 + + for volume in volumesToPrune { + do { + let actualSize = try await ClientVolume.volumeDiskUsage(name: volume.name) + totalSize += actualSize + try await ClientVolume.delete(name: volume.name) + prunedVolumes.append(volume.name) + } catch { + log.error( + "failed to prune volume", + metadata: [ + "id": "\(volume.name)", + "error": "\(error)", + ]) + } + } + + for name in prunedVolumes { + print(name) + } + + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(totalSize)) + log.info("Reclaimed \(freed) in disk space") + } + } +} diff --git a/Sources/ContainerCommands/Volume/VolumeResource+ListDisplayable.swift b/Sources/ContainerCommands/Volume/VolumeResource+ListDisplayable.swift new file mode 100644 index 0000000..70cc5a1 --- /dev/null +++ b/Sources/ContainerCommands/Volume/VolumeResource+ListDisplayable.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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 + +extension VolumeResource: ListDisplayable { + public static var tableHeader: [String] { + ["NAME", "TYPE", "DRIVER", "OPTIONS"] + } + + public var tableRow: [String] { + [ + name, + isAnonymous ? "anonymous" : "named", + configuration.driver, + configuration.options.isEmpty ? "" : configuration.options.sorted(by: { $0.key < $1.key }).map { "\($0.key)=\($0.value)" }.joined(separator: ","), + ] + } + + public var quietValue: String { + name + } +} diff --git a/Sources/ContainerLog/FileLogHandler.swift b/Sources/ContainerLog/FileLogHandler.swift new file mode 100644 index 0000000..446fa33 --- /dev/null +++ b/Sources/ContainerLog/FileLogHandler.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Logging +import SystemPackage + +/// Log handler that appends messages to a file, without any +/// rotation or truncation strategy. Use for development purposes only. +public struct FileLogHandler: LogHandler { + public var logLevel: Logger.Level = .info + public var metadata: Logger.Metadata = [:] + + private let label: String + private let category: String + private let fileHandle: FileHandle + + public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { + self.metadata[metadataKey] + } + set { + self.metadata[metadataKey] = newValue + } + } + + /// Create a log handler that appends to the specified file. + /// + /// - Parameters: + /// - label: A unique identifier for the application. + /// - category: An identifier for the application subsystem. + /// - path: The log file location. The log handler creates the + /// file and parent directory if needed. + /// - Returns: The log handler. + public init(label: String, category: String, path: FilePath) throws { + self.label = label + self.category = category + let parentPath = path.removingLastComponent() + try FileManager.default.createDirectory(atPath: parentPath.string, withIntermediateDirectories: true) + if !FileManager.default.fileExists(atPath: path.string) { + FileManager.default.createFile(atPath: path.string, contents: nil) + } + guard let handle = FileHandle(forWritingAtPath: path.string) else { + throw FileLogFailure.openFailed + } + self.fileHandle = handle + self.fileHandle.seekToEndOfFile() + } + + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + let timestampFormatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions.insert(.withFractionalSeconds) + return formatter + }() + let timestamp = timestampFormatter.string(from: Date()) + + // Merge logger-level metadata with per-message metadata + var effectiveMetadata = self.metadata + if let metadata { + effectiveMetadata.merge(metadata) { _, new in new } + } + + let text: String + if !effectiveMetadata.isEmpty { + text = "\(timestamp) [\(level)] \(label) \(category) \(effectiveMetadata.description): \(message)\n" + } else { + text = "\(timestamp) [\(level)] \(label): \(category) \(message)\n" + } + if let data = text.data(using: .utf8) { + fileHandle.write(data) + } + } + + /// Failures relating to the log handler. + public enum FileLogFailure: Error { + /// The log handler could not open the log file. + case openFailed + } +} diff --git a/Sources/ContainerLog/OSLogHandler.swift b/Sources/ContainerLog/OSLogHandler.swift new file mode 100644 index 0000000..227e35e --- /dev/null +++ b/Sources/ContainerLog/OSLogHandler.swift @@ -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 Foundation +import Logging +import os + +import struct Logging.Logger + +public struct OSLogHandler: LogHandler { + private let logger: os.Logger + + public var logLevel: Logger.Level = .info + private var formattedMetadata: String? + + public var metadata = Logger.Metadata() { + didSet { + self.formattedMetadata = self.formatMetadata(self.metadata) + } + } + + public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { + self.metadata[metadataKey] + } + set { + self.metadata[metadataKey] = newValue + } + } + + public init(label: String, category: String) { + self.logger = os.Logger(subsystem: label, category: category) + } +} + +extension OSLogHandler { + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + var formattedMetadata = self.formattedMetadata + if let metadataOverride = metadata, !metadataOverride.isEmpty { + formattedMetadata = self.formatMetadata( + self.metadata.merging(metadataOverride) { + $1 + } + ) + } + + var finalMessage = message.description + if let formattedMetadata { + finalMessage += " " + formattedMetadata + } + + self.logger.log( + level: level.toOSLogLevel(), + "\(finalMessage, privacy: .public)" + ) + } + + private func formatMetadata(_ metadata: Logger.Metadata) -> String? { + if metadata.isEmpty { + return nil + } + return metadata.map { + "[\($0)=\($1)]" + }.joined(separator: " ") + } +} + +extension Logger.Level { + func toOSLogLevel() -> OSLogType { + switch self { + case .debug, .trace: + return .debug + case .info: + return .info + case .notice, .warning: + return .default + case .error: + return .error + case .critical: + return .fault + } + } +} diff --git a/Sources/ContainerLog/ServiceLogger.swift b/Sources/ContainerLog/ServiceLogger.swift new file mode 100644 index 0000000..2fd111e --- /dev/null +++ b/Sources/ContainerLog/ServiceLogger.swift @@ -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 Logging +import SystemPackage + +/// Common logging setup for application services. +public struct ServiceLogger { + /// Set up the logging system and create a root logger. + /// + /// - Parameters: + /// - label: A unique identifier for the application. + /// - category: An identifier for the application subsystem. + /// - metadata: Metadata to include for all messsages. A message + /// specific value for a duplicate key overrides these values. + /// - debug: Enable debug logging. + /// - logPath: If supplied, create log files under the named + /// directory. Otherwise, log to the OS log facility. + /// - Returns: The root logger. + public static func bootstrap( + label: String = "com.apple.container", + category: String, + metadata: [String: String] = [:], + debug: Bool, + logPath: FilePath? + ) -> Logger { + // Select the log handler and bootstrap logging. + LoggingSystem.bootstrap { label in + if let logPath { + if let handler = try? FileLogHandler( + label: label, + category: category, + path: logPath + ) { + return handler + } + } + return OSLogHandler(label: label, category: category) + } + + // Configure log level and metadata. + var log = Logger(label: label) + if debug { + log.logLevel = .debug + } + for (key, value) in metadata { + log[metadataKey: key] = "\(value)" + } + + // Log an error if for some reason FileLogHandler init failed. + if let logPath, log.handler as? OSLogHandler != nil { + log.error( + "unable to initialize FileLogHandler, using OSLogHandler", + metadata: [ + "logPath": "\(logPath)" + ]) + } + + return log + } +} diff --git a/Sources/ContainerLog/StderrLogHandler.swift b/Sources/ContainerLog/StderrLogHandler.swift new file mode 100644 index 0000000..ffcd917 --- /dev/null +++ b/Sources/ContainerLog/StderrLogHandler.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Basic log handler for where simple message output is needed, +/// such as CLI commands. +public struct StderrLogHandler: LogHandler { + public var logLevel: Logger.Level = .info + public var metadata: Logger.Metadata = [:] + + public subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? { + get { + self.metadata[metadataKey] + } + set { + self.metadata[metadataKey] = newValue + } + } + + public init() {} + + public func log( + level: Logger.Level, + message: Logger.Message, + metadata: Logger.Metadata?, + source: String, + file: String, + function: String, + line: UInt + ) { + let data: Data + switch logLevel { + case .debug, .trace: + let timestamp = isoTimestamp() + if let metadata, !metadata.isEmpty { + data = + "\(timestamp) \(message.description): \(metadata.description)\n" + .data(using: .utf8) ?? Data() + } else { + data = + "\(timestamp) \(message.description)\n" + .data(using: .utf8) ?? Data() + } + default: + if let metadata, !metadata.isEmpty { + data = + "\(message.description): \(metadata.description)\n" + .data(using: .utf8) ?? Data() + } else { + data = + "\(message.description)\n" + .data(using: .utf8) ?? Data() + } + } + + FileHandle.standardError.write(data) + } + + private func isoTimestamp() -> String { + let date = Date() + var time = time_t(date.timeIntervalSince1970) + var ms = Int(date.timeIntervalSince1970 * 1000) % 1000 + if ms < 0 { ms += 1000 } + var tm = tm() + gmtime_r(&time, &tm) + let buf = withUnsafeTemporaryAllocation(of: CChar.self, capacity: 32) { ptr -> String in + strftime(ptr.baseAddress!, 32, "%Y-%m-%dT%H:%M:%S", &tm) + return String(cString: ptr.baseAddress!) + } + return String(format: "%@.%03dZ", buf, ms) + } +} diff --git a/Sources/ContainerOS/DirectoryWatcher.swift b/Sources/ContainerOS/DirectoryWatcher.swift new file mode 100644 index 0000000..ab00cab --- /dev/null +++ b/Sources/ContainerOS/DirectoryWatcher.swift @@ -0,0 +1,139 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS +import Foundation +import Logging +import Synchronization +import SystemPackage + +/// Watches a directory for changes and invokes a handler when the contents change. +/// +/// `DirectoryWatcher` uses `DispatchSource` file system events to monitor a directory. +/// If the target directory does not exist yet, it polls until the directory is created. +/// the target is created, then transitions to watching the target directly. +/// +/// Example usage: +/// ```swift +/// let watcher = DirectoryWatcher(directoryPath: myPath, log: logger) +/// try watcher.startWatching { paths in +/// print("Directory contents changed: \(paths)") +/// } +/// ``` +public actor DirectoryWatcher { + public static let watchPeriod = Duration.seconds(1) + + /// The path of the directory being watched. + public let directoryPath: FilePath + + private var task: Task? + private let monitorQueue: DispatchQueue + private let source: Mutex + + private let log: Logger? + + /// Creates a new `DirectoryWatcher` for the given directory path. + /// + /// - Parameters: + /// - directoryPath: The path of the directory to watch. + /// - log: An optional logger for diagnostic messages. + public init(directoryPath: FilePath, log: Logger?) { + self.directoryPath = directoryPath + self.monitorQueue = DispatchQueue(label: "monitor:\(directoryPath.string)") + self.log = log + self.source = Mutex(nil) + } + + /// Starts watching the directory for changes. + /// + /// - Parameters: + /// - handler: handler to run on directory state change. + public func startWatching(handler: @Sendable @escaping ([FilePath]) throws -> Void) { + self.task = Task { + var exists: Bool + var isDir: ObjCBool = false + + while true { + do { + exists = FileManager.default.fileExists(atPath: self.directoryPath.string, isDirectory: &isDir) + if exists && isDir.boolValue && self.source.withLock({ $0 }) == nil { + try _startWatching(handler: handler) + } + } catch { + log?.error("failed to start watching", metadata: ["error": "\(error)"]) + } + + try await Task.sleep(for: Self.watchPeriod) + } + } + } + + private func _startWatching( + handler: @escaping ([FilePath]) throws -> Void + ) throws { + let descriptor = open(directoryPath.string, O_EVTONLY) + guard descriptor > 0 else { + throw ContainerizationError(.internalError, message: "cannot open \(directoryPath.string), descriptor=\(descriptor)") + } + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: directoryPath.string) + try handler(files.map { directoryPath.appending($0) }) + } catch { + throw ContainerizationError(.internalError, message: "failed to run handler for \(directoryPath.string)") + } + + log?.info("starting directory watcher", metadata: ["path": "\(directoryPath.string)"]) + + let dispatchSource = DispatchSource.makeFileSystemObjectSource( + fileDescriptor: descriptor, + eventMask: [.delete, .write], + queue: monitorQueue + ) + + dispatchSource.setCancelHandler { + close(descriptor) + } + + dispatchSource.setEventHandler { [weak self] in + guard let self else { return } + + guard !dispatchSource.data.contains(.delete) else { + dispatchSource.cancel() + self.source.withLock { $0 = nil } + return + } + + do { + let files = try FileManager.default.contentsOfDirectory(atPath: directoryPath.string) + try handler(files.map { directoryPath.appending($0) }) + } catch { + self.log?.error( + "failed to run watch handler", + metadata: ["error": "\(error)", "path": "\(directoryPath.string)"]) + } + } + + source.withLock { $0 = dispatchSource } + dispatchSource.resume() + } + + deinit { + self.task?.cancel() + source.withLock { $0?.cancel() } + } +} diff --git a/Sources/ContainerOS/LocalNetworkPrivacy.swift b/Sources/ContainerOS/LocalNetworkPrivacy.swift new file mode 100644 index 0000000..5663eeb --- /dev/null +++ b/Sources/ContainerOS/LocalNetworkPrivacy.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Darwin + +/// Utility for triggering local network privacy alert. +/// The local networking privacy feature introduced in +/// macOS 15 requires users to authorize an app before it can +/// access peers on the local network. This security feature +/// affects runtime helpers that publish ports on the loopback +/// interface. +/// +/// The approach used here is for the application to trigger +/// the alert before clients attemot to communicate with it. +/// This is a best effort method; there is no guarantee that +/// the alert will display. +/// +/// See https://developer.apple.com/documentation/technotes/tn3179-understanding-local-network-privacy +/// for additional details. +package struct LocalNetworkPrivacy { + /// Attempts to trigger the local network privacy alert. + /// + /// This builds a list of link-local IPv6 addresses and then creates a connected + /// UDP socket to each in turn. Connecting a UDP socket triggers the local + /// network alert without actually sending any traffic. + package static func triggerLocalNetworkPrivacyAlert() { + let addresses = selectedLinkLocalIPv6Addresses() + for address in addresses { + let sock6 = socket(AF_INET6, SOCK_DGRAM, 0) + guard sock6 >= 0 else { return } + defer { close(sock6) } + + withUnsafePointer(to: address) { sa6 in + sa6.withMemoryRebound(to: sockaddr.self, capacity: 1) { sa in + _ = connect(sock6, sa, socklen_t(sa.pointee.sa_len)) >= 0 + } + } + } + } + + private static func selectedLinkLocalIPv6Addresses() -> [sockaddr_in6] { + // Find the link-local broadcast-capable IPv6 interfaces, and + // for each, create two peer socket addresses for the interface + // with the port set to the discard service (port 9). + let r1 = (0..<8).map { _ in UInt8.random(in: 0...255) } + let r2 = (0..<8).map { _ in UInt8.random(in: 0...255) } + return Array( + ipv6AddressesOfBroadcastCapableInterfaces() + .filter { isIPv6AddressLinkLocal($0) } + .map { + var addr = $0 + addr.sin6_port = UInt16(9).bigEndian + return addr + } + .map { [setIPv6LinkLocalAddressHostPart(of: $0, to: r1), setIPv6LinkLocalAddressHostPart(of: $0, to: r2)] } + .joined()) + } + + private static func setIPv6LinkLocalAddressHostPart(of address: sockaddr_in6, to hostPart: [UInt8]) -> sockaddr_in6 { + // Set the host part (the bottom 64 bits) of the supplied + // IPv6 socket address. + precondition(hostPart.count == 8) + var result = address + withUnsafeMutableBytes(of: &result.sin6_addr) { buf in + buf[8...].copyBytes(from: hostPart) + } + return result + } + + private static func isIPv6AddressLinkLocal(_ address: sockaddr_in6) -> Bool { + // Link-local address have the fe:c0/10 prefix. + address.sin6_addr.__u6_addr.__u6_addr8.0 == 0xfe + && (address.sin6_addr.__u6_addr.__u6_addr8.1 & 0xc0) == 0x80 + } + + private static func ipv6AddressesOfBroadcastCapableInterfaces() -> [sockaddr_in6] { + // Iterate all interfaces and return the IPv6 addresses + // for those that can broadcast. + var addrList: UnsafeMutablePointer? = nil + let err = getifaddrs(&addrList) + guard err == 0, let start = addrList else { return [] } + defer { freeifaddrs(start) } + return sequence(first: start, next: { $0.pointee.ifa_next }) + .compactMap { i -> sockaddr_in6? in + guard + (i.pointee.ifa_flags & UInt32(bitPattern: IFF_BROADCAST)) != 0, + let sa = i.pointee.ifa_addr, + sa.pointee.sa_family == AF_INET6, + sa.pointee.sa_len >= MemoryLayout.size + else { return nil } + return UnsafeRawPointer(sa).load(as: sockaddr_in6.self) + } + } +} +#endif diff --git a/Sources/ContainerPersistence/ConfigCodingStrategy.swift b/Sources/ContainerPersistence/ConfigCodingStrategy.swift new file mode 100644 index 0000000..858e7a9 --- /dev/null +++ b/Sources/ContainerPersistence/ConfigCodingStrategy.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// A type that provides custom decoding logic for a specific `Decodable` type +/// when using `ConfigSnapshotDecoder`. This enables modification of `Decodable` behavior +/// for types that already conform to `Decodable`, such as `URL` and `Measurement`. +public protocol ConfigDecodingStrategy: Sendable { + associatedtype Value: Decodable + func decode(from decoder: Decoder) throws -> Value +} + +/// A type that provides custom encoding logic for a specific `Encodable` type. +/// This enables modification of `Encodable` behavior for types that already conform +// to `Encodable`, such as `URL` and `Measurement`. +public protocol ConfigEncodingStrategy: Sendable { + associatedtype Value: Encodable + func encode(_ value: Value, to encoder: Encoder) throws +} + +/// A type that provides both custom encoding and decoding for the same type. Similar to `Codable` +public typealias ConfigCodingStrategy = ConfigDecodingStrategy & ConfigEncodingStrategy + +/// Decodes a `URL` from a string value. e.g. "https://www.apple.com" +public struct URLConfigDecodingStrategy: ConfigDecodingStrategy { + public init() {} + + public func decode(from decoder: Decoder) throws -> URL { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + guard let url = URL(string: string) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid URL string: \"\(string)\"." + ) + ) + } + return url + } +} + +// A type erased version of `ConfigDecodingStrategy` +struct AnyConfigDecodingStrategy: Sendable { + let valueTypeID: ObjectIdentifier + private let decode: @Sendable (Decoder) throws -> Any + + init(_ strategy: S) { + self.valueTypeID = ObjectIdentifier(S.Value.self) + self.decode = { decoder in try strategy.decode(from: decoder) as Any } + } + + func decode(from decoder: Decoder) throws -> Any { + try decode(decoder) + } +} diff --git a/Sources/ContainerPersistence/ConfigSnapshotDecoder.swift b/Sources/ContainerPersistence/ConfigSnapshotDecoder.swift new file mode 100644 index 0000000..168255e --- /dev/null +++ b/Sources/ContainerPersistence/ConfigSnapshotDecoder.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// 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 Configuration + +/// A decoder that decodes `Decodable` types from a ``ConfigSnapshotReader``. +/// +/// `ConfigSnapshotDecoder` bridges Swift's `Decodable` protocol with the configuration +/// provider system, allowing you to decode structured configuration into typed +/// Swift values. +/// +/// ```swift +/// struct AppConfig: Decodable { +/// var host: String +/// var port: Int +/// var database: DatabaseConfig +/// } +/// +/// struct DatabaseConfig: Decodable { +/// var connectionString: String +/// var maxConnections: Int +/// } +/// +/// let reader = ConfigReader(providers: [envProvider, jsonProvider]) +/// let snapshot = reader.snapshot() +/// let config = try ConfigSnapshotDecoder().decode(AppConfig.self, from: snapshot) +/// ``` +/// +/// Nested structs map to dot-separated key paths. In the example above, +/// `database.connectionString` and `database.maxConnections` are looked up +/// from the snapshot. +public struct ConfigSnapshotDecoder: Sendable { + + public var userInfo: [CodingUserInfoKey: any Sendable] + private let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy] + + public init(decodingStrategies: [any ConfigDecodingStrategy] = [URLConfigDecodingStrategy()]) { + self.userInfo = [:] + var strategies: [ObjectIdentifier: AnyConfigDecodingStrategy] = [:] + for strategy in decodingStrategies { + let erased = AnyConfigDecodingStrategy(strategy) + strategies[erased.valueTypeID] = erased + } + self.typeDecodingStrategies = strategies + } + + public func decode( + _ type: T.Type, + from snapshot: ConfigSnapshotReader + ) throws -> T { + if type is any UnsupportedDictionaryDecoding.Type { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: [], + debugDescription: + "ConfigSnapshotDecoder does not support decoding dictionaries (got \(T.self)). Represent dynamic keys as nested structs with known property names." + ) + ) + } + let decoder = ConfigSnapshotDecoderImpl( + snapshot: snapshot, + codingPath: [], + userInfo: userInfo.mapValues { $0 as Any }, + typeDecodingStrategies: typeDecodingStrategies + ) + return try T(from: decoder) + } +} + +struct ConfigSnapshotDecoderImpl: Decoder { + let snapshot: ConfigSnapshotReader + let codingPath: [any CodingKey] + let userInfo: [CodingUserInfoKey: Any] + let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy] + + func container( + keyedBy type: Key.Type + ) throws -> KeyedDecodingContainer { + KeyedDecodingContainer( + KeyedContainer( + snapshot: snapshot, + codingPath: codingPath, + userInfo: userInfo, + typeDecodingStrategies: typeDecodingStrategies + ) + ) + } + + func unkeyedContainer() throws -> any UnkeyedDecodingContainer { + UnkeyedContainer( + snapshot: snapshot, + codingPath: codingPath, + userInfo: userInfo, + typeDecodingStrategies: typeDecodingStrategies + ) + } + + func singleValueContainer() throws -> any SingleValueDecodingContainer { + SingleValueContainer( + snapshot: snapshot, + codingPath: codingPath, + userInfo: userInfo, + typeDecodingStrategies: typeDecodingStrategies + ) + } +} diff --git a/Sources/ContainerPersistence/ConfigSnapshotDecoderContainers.swift b/Sources/ContainerPersistence/ConfigSnapshotDecoderContainers.swift new file mode 100644 index 0000000..dff3338 --- /dev/null +++ b/Sources/ContainerPersistence/ConfigSnapshotDecoderContainers.swift @@ -0,0 +1,659 @@ +//===----------------------------------------------------------------------===// +// 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 Configuration +import Foundation + +// MARK: - Shared helpers + +extension ConfigSnapshotReader { + /// Returns true when the snapshot holds a primitive value at `key`, regardless of + /// type. `ConfigSnapshotReader` stores typed values — each primitive accessor returns + /// nil both when the key is absent and when the stored value is of a different type. + /// Callers that need to distinguish "absent" from "present but wrong type" must check + /// `hasValue` first, then the typed accessor. + func hasValue(forKey key: ConfigKey) -> Bool { + string(forKey: key) != nil + || int(forKey: key) != nil + || double(forKey: key) != nil + || bool(forKey: key) != nil + } +} + +/// Marker used by `decode` to reject `Dictionary`-valued properties. The snapshot +/// has no key-enumeration API, so a `Dictionary` would silently decode to `[:]` via +/// `allKeys == []`. We reject the attempt explicitly instead. +protocol UnsupportedDictionaryDecoding {} +extension Dictionary: UnsupportedDictionaryDecoding {} + +struct KeyedContainer: KeyedDecodingContainerProtocol { + let snapshot: ConfigSnapshotReader + let codingPath: [any CodingKey] + let userInfo: [CodingUserInfoKey: Any] + let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy] + + // ConfigSnapshotReader has no "key exists" API, so allKeys cannot enumerate + // available keys and contains always returns true. This works for structs with + // known properties. Types that iterate allKeys for dynamic keys (e.g. dictionaries) + // will see an empty collection. + var allKeys: [Key] { [] } + + func contains(_ key: Key) -> Bool { true } + + // Always return false — the flat config snapshot stores nested struct keys as + // dot-separated paths (e.g. "build.cpus") but has no entry for the parent key + // itself (e.g. "build"). Returning true here would cause decodeIfPresent to skip + // structs whose child keys DO exist. Instead, we always attempt to decode and + // rely on decodeIfPresent overrides for primitive optionals. + func decodeNil(forKey key: Key) throws -> Bool { + false + } + + // MARK: - Primitive decodeIfPresent overrides + // + // Each override distinguishes three cases: + // 1. key absent → return nil + // 2. key present, right type → return the value + // 3. key present, wrong type → throw DecodingError.typeMismatch + // + // The typed accessors on ConfigSnapshotReader collapse (1) and (3) into nil, + // so we use `hasValue` to disambiguate. Without this, a user config mistake + // like `cpus = "8"` would silently fall back to the property's default. + + func decodeIfPresent(_ type: Bool.Type, forKey key: Key) throws -> Bool? { + let ck = configKey(appending: key) + guard snapshot.hasValue(forKey: ck) else { return nil } + guard let value = snapshot.bool(forKey: ck) else { + throw typeMismatch(Bool.self, at: ck, for: key) + } + return value + } + + func decodeIfPresent(_ type: String.Type, forKey key: Key) throws -> String? { + let ck = configKey(appending: key) + guard snapshot.hasValue(forKey: ck) else { return nil } + guard let value = snapshot.string(forKey: ck) else { + throw typeMismatch(String.self, at: ck, for: key) + } + return value + } + + func decodeIfPresent(_ type: Int.Type, forKey key: Key) throws -> Int? { + let ck = configKey(appending: key) + guard snapshot.hasValue(forKey: ck) else { return nil } + guard let value = snapshot.int(forKey: ck) else { + throw typeMismatch(Int.self, at: ck, for: key) + } + return value + } + + func decodeIfPresent(_ type: Double.Type, forKey key: Key) throws -> Double? { + let ck = configKey(appending: key) + guard snapshot.hasValue(forKey: ck) else { return nil } + guard let value = snapshot.double(forKey: ck) else { + throw typeMismatch(Double.self, at: ck, for: key) + } + return value + } + + func decodeIfPresent(_ type: Float.Type, forKey key: Key) throws -> Float? { + let ck = configKey(appending: key) + guard snapshot.hasValue(forKey: ck) else { return nil } + guard let value = snapshot.double(forKey: ck) else { + throw typeMismatch(Float.self, at: ck, for: key) + } + return Float(value) + } + + func decodeIfPresent(_ type: T.Type, forKey key: Key) throws -> T? { + // For non-primitive Decodable types, always attempt decode. + // If the nested struct's init(from:) uses decodeIfPresent for its own keys, + // missing keys will resolve to defaults correctly. + // Catch keyNotFound/valueNotFound at this level — they indicate the key is absent. + do { + return try decode(type, forKey: key) + } catch DecodingError.keyNotFound(let k, _) where k.stringValue == key.stringValue { + return nil + } catch DecodingError.valueNotFound { + return nil + } + } + + func decode(_ type: Bool.Type, forKey key: Key) throws -> Bool { + try decodeValue(forKey: key) { try snapshot.requiredBool(forKey: $0) } + } + + func decode(_ type: String.Type, forKey key: Key) throws -> String { + try decodeValue(forKey: key) { try snapshot.requiredString(forKey: $0) } + } + + func decode(_ type: Double.Type, forKey key: Key) throws -> Double { + try decodeValue(forKey: key) { try snapshot.requiredDouble(forKey: $0) } + } + + func decode(_ type: Float.Type, forKey key: Key) throws -> Float { + Float(try decodeValue(forKey: key) { try snapshot.requiredDouble(forKey: $0) }) + } + + func decode(_ type: Int.Type, forKey key: Key) throws -> Int { + try decodeValue(forKey: key) { try snapshot.requiredInt(forKey: $0) } + } + + func decode(_ type: Int8.Type, forKey key: Key) throws -> Int8 { + try integerValue(forKey: key) + } + + func decode(_ type: Int16.Type, forKey key: Key) throws -> Int16 { + try integerValue(forKey: key) + } + + func decode(_ type: Int32.Type, forKey key: Key) throws -> Int32 { + try integerValue(forKey: key) + } + + func decode(_ type: Int64.Type, forKey key: Key) throws -> Int64 { + try integerValue(forKey: key) + } + + func decode(_ type: UInt.Type, forKey key: Key) throws -> UInt { + try integerValue(forKey: key) + } + + func decode(_ type: UInt8.Type, forKey key: Key) throws -> UInt8 { + try integerValue(forKey: key) + } + + func decode(_ type: UInt16.Type, forKey key: Key) throws -> UInt16 { + try integerValue(forKey: key) + } + + func decode(_ type: UInt32.Type, forKey key: Key) throws -> UInt32 { + try integerValue(forKey: key) + } + + func decode(_ type: UInt64.Type, forKey key: Key) throws -> UInt64 { + try integerValue(forKey: key) + } + + func decode(_ type: T.Type, forKey key: Key) throws -> T { + if type is any UnsupportedDictionaryDecoding.Type { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath + [key], + debugDescription: + "ConfigSnapshotDecoder does not support decoding dictionaries (got \(T.self)). Represent dynamic keys as nested structs with known property names." + ) + ) + } + let impl = ConfigSnapshotDecoderImpl( + snapshot: snapshot, + codingPath: codingPath + [key], + userInfo: userInfo, + typeDecodingStrategies: typeDecodingStrategies + ) + if let strategy = typeDecodingStrategies[ObjectIdentifier(type)] { + guard let typed = try strategy.decode(from: impl) as? T else { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath + [key], + debugDescription: "Strategy returned value of unexpected type for \(T.self)." + ) + ) + } + return typed + } + return try T(from: impl) + } + + func nestedContainer( + keyedBy type: NestedKey.Type, + forKey key: Key + ) throws -> KeyedDecodingContainer { + KeyedDecodingContainer( + KeyedContainer( + snapshot: snapshot, + codingPath: codingPath + [key], + userInfo: userInfo, + typeDecodingStrategies: typeDecodingStrategies + ) + ) + } + + func nestedUnkeyedContainer(forKey key: Key) throws -> any UnkeyedDecodingContainer { + UnkeyedContainer( + snapshot: snapshot, + codingPath: codingPath + [key], + userInfo: userInfo, + typeDecodingStrategies: typeDecodingStrategies + ) + } + + func superDecoder() throws -> any Decoder { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "ConfigSnapshotDecoder does not support superDecoder()." + ) + ) + } + + func superDecoder(forKey key: Key) throws -> any Decoder { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath + [key], + debugDescription: "ConfigSnapshotDecoder does not support superDecoder(forKey:)." + ) + ) + } + + // MARK: - Private helpers + + private func configKey(appending key: Key) -> ConfigKey { + ConfigKey(codingPath.map(\.stringValue) + [key.stringValue]) + } + + private func decodeValue(forKey key: Key, _ body: (ConfigKey) throws -> V) throws -> V { + let configKey = configKey(appending: key) + do { + return try body(configKey) + } catch { + throw DecodingError.keyNotFound( + key, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "No value found for key \"\(configKey)\"." + ) + ) + } + } + + private func integerValue(forKey key: Key) throws -> T { + let intValue: Int = try decodeValue(forKey: key) { try snapshot.requiredInt(forKey: $0) } + guard let converted = T(exactly: intValue) else { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath + [key], + debugDescription: "Value \(intValue) does not fit in \(T.self)." + ) + ) + } + return converted + } + + private func typeMismatch(_ type: T.Type, at configKey: ConfigKey, for key: Key) -> DecodingError { + DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath + [key], + debugDescription: "Expected \(T.self) at \"\(configKey)\" but found a value of a different type." + ) + ) + } +} + +struct SingleValueContainer: SingleValueDecodingContainer { + let snapshot: ConfigSnapshotReader + let codingPath: [any CodingKey] + let userInfo: [CodingUserInfoKey: Any] + let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy] + + // ConfigSnapshotReader stores typed values — string(forKey:) returns nil for + // int/double/bool values. `hasValue` checks all primitive accessors. + func decodeNil() -> Bool { + !snapshot.hasValue(forKey: configKey()) + } + + func decode(_ type: Bool.Type) throws -> Bool { + try decodeValue { try snapshot.requiredBool(forKey: $0) } + } + + func decode(_ type: String.Type) throws -> String { + try decodeValue { try snapshot.requiredString(forKey: $0) } + } + + func decode(_ type: Double.Type) throws -> Double { + try decodeValue { try snapshot.requiredDouble(forKey: $0) } + } + + func decode(_ type: Float.Type) throws -> Float { + Float(try decodeValue { try snapshot.requiredDouble(forKey: $0) }) + } + + func decode(_ type: Int.Type) throws -> Int { + try decodeValue { try snapshot.requiredInt(forKey: $0) } + } + + func decode(_ type: Int8.Type) throws -> Int8 { try integerValue() } + func decode(_ type: Int16.Type) throws -> Int16 { try integerValue() } + func decode(_ type: Int32.Type) throws -> Int32 { try integerValue() } + func decode(_ type: Int64.Type) throws -> Int64 { try integerValue() } + func decode(_ type: UInt.Type) throws -> UInt { try integerValue() } + func decode(_ type: UInt8.Type) throws -> UInt8 { try integerValue() } + func decode(_ type: UInt16.Type) throws -> UInt16 { try integerValue() } + func decode(_ type: UInt32.Type) throws -> UInt32 { try integerValue() } + func decode(_ type: UInt64.Type) throws -> UInt64 { try integerValue() } + + func decode(_ type: T.Type) throws -> T { + if type is any UnsupportedDictionaryDecoding.Type { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: + "ConfigSnapshotDecoder does not support decoding dictionaries (got \(T.self)). Represent dynamic keys as nested structs with known property names." + ) + ) + } + let impl = ConfigSnapshotDecoderImpl( + snapshot: snapshot, + codingPath: codingPath, + userInfo: userInfo, + typeDecodingStrategies: typeDecodingStrategies + ) + if let strategy = typeDecodingStrategies[ObjectIdentifier(type)] { + guard let typed = try strategy.decode(from: impl) as? T else { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Strategy returned value of unexpected type for \(T.self)." + ) + ) + } + return typed + } + return try T(from: impl) + } + + // MARK: - Private helpers + + private func configKey() -> ConfigKey { + ConfigKey(codingPath.map(\.stringValue)) + } + + private func decodeValue(_ body: (ConfigKey) throws -> V) throws -> V { + let configKey = configKey() + do { + return try body(configKey) + } catch { + throw DecodingError.valueNotFound( + V.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "No value found for key \"\(configKey)\"." + ) + ) + } + } + + private func integerValue() throws -> T { + let intValue: Int = try decodeValue { try snapshot.requiredInt(forKey: $0) } + guard let converted = T(exactly: intValue) else { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Value \(intValue) does not fit in \(T.self)." + ) + ) + } + return converted + } +} + +struct UnkeyedContainer: UnkeyedDecodingContainer { + let snapshot: ConfigSnapshotReader + let codingPath: [any CodingKey] + let userInfo: [CodingUserInfoKey: Any] + let typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy] + + private enum ArrayValue { + case strings([String]) + case ints([Int]) + case doubles([Double]) + case bools([Bool]) + case unsupported + } + + private var resolved: ArrayValue = .unsupported + private(set) var currentIndex: Int = 0 + + var count: Int? { + switch resolved { + case .strings(let a): a.count + case .ints(let a): a.count + case .doubles(let a): a.count + case .bools(let a): a.count + case .unsupported: nil + } + } + + var isAtEnd: Bool { + switch resolved { + case .unsupported: false + default: currentIndex >= (count ?? 0) + } + } + + init( + snapshot: ConfigSnapshotReader, + codingPath: [any CodingKey], + userInfo: [CodingUserInfoKey: Any], + typeDecodingStrategies: [ObjectIdentifier: AnyConfigDecodingStrategy] + ) { + self.snapshot = snapshot + self.codingPath = codingPath + self.userInfo = userInfo + self.typeDecodingStrategies = typeDecodingStrategies + self.resolved = .unsupported + } + + mutating func decodeNil() throws -> Bool { false } + + mutating func decode(_ type: String.Type) throws -> String { + let array = try resolveStrings() + try checkBounds(array.count) + defer { currentIndex += 1 } + return array[currentIndex] + } + + mutating func decode(_ type: Bool.Type) throws -> Bool { + let array = try resolveBools() + try checkBounds(array.count) + defer { currentIndex += 1 } + return array[currentIndex] + } + + mutating func decode(_ type: Int.Type) throws -> Int { + let array = try resolveInts() + try checkBounds(array.count) + defer { currentIndex += 1 } + return array[currentIndex] + } + + mutating func decode(_ type: Double.Type) throws -> Double { + let array = try resolveDoubles() + try checkBounds(array.count) + defer { currentIndex += 1 } + return array[currentIndex] + } + + mutating func decode(_ type: Float.Type) throws -> Float { + Float(try decode(Double.self)) + } + + mutating func decode(_ type: Int8.Type) throws -> Int8 { try integerElement() } + mutating func decode(_ type: Int16.Type) throws -> Int16 { try integerElement() } + mutating func decode(_ type: Int32.Type) throws -> Int32 { try integerElement() } + mutating func decode(_ type: Int64.Type) throws -> Int64 { try integerElement() } + mutating func decode(_ type: UInt.Type) throws -> UInt { try integerElement() } + mutating func decode(_ type: UInt8.Type) throws -> UInt8 { try integerElement() } + mutating func decode(_ type: UInt16.Type) throws -> UInt16 { try integerElement() } + mutating func decode(_ type: UInt32.Type) throws -> UInt32 { try integerElement() } + mutating func decode(_ type: UInt64.Type) throws -> UInt64 { try integerElement() } + + mutating func decode(_ type: T.Type) throws -> T { + if type == String.self { return try decode(String.self) as! T } + if type == Int.self { return try decode(Int.self) as! T } + if type == Double.self { return try decode(Double.self) as! T } + if type == Bool.self { return try decode(Bool.self) as! T } + if type == Float.self { return try decode(Float.self) as! T } + if type == Int8.self { return try decode(Int8.self) as! T } + if type == Int16.self { return try decode(Int16.self) as! T } + if type == Int32.self { return try decode(Int32.self) as! T } + if type == Int64.self { return try decode(Int64.self) as! T } + if type == UInt.self { return try decode(UInt.self) as! T } + if type == UInt8.self { return try decode(UInt8.self) as! T } + if type == UInt16.self { return try decode(UInt16.self) as! T } + if type == UInt32.self { return try decode(UInt32.self) as! T } + if type == UInt64.self { return try decode(UInt64.self) as! T } + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: + "ConfigSnapshotDecoder does not support decoding arrays of \(T.self). Only arrays of primitive types (String, Int, Double, Bool) are supported." + ) + ) + } + + mutating func nestedContainer( + keyedBy type: NestedKey.Type + ) throws -> KeyedDecodingContainer { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "ConfigSnapshotDecoder does not support nested containers inside arrays." + ) + ) + } + + mutating func nestedUnkeyedContainer() throws -> any UnkeyedDecodingContainer { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "ConfigSnapshotDecoder does not support nested arrays." + ) + ) + } + + func superDecoder() throws -> any Decoder { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: codingPath, + debugDescription: "ConfigSnapshotDecoder does not support superDecoder()." + ) + ) + } + + // MARK: - Private helpers + + private func configKey() -> ConfigKey { + ConfigKey(codingPath.map(\.stringValue)) + } + + private mutating func resolveStrings() throws -> [String] { + if case .strings(let a) = resolved { return a } + let configKey = configKey() + guard let result = snapshot.stringArray(forKey: configKey) else { + throw DecodingError.valueNotFound( + [String].self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "No string array found for key \"\(configKey)\"." + ) + ) + } + resolved = .strings(result) + return result + } + + private mutating func resolveInts() throws -> [Int] { + if case .ints(let a) = resolved { return a } + let configKey = configKey() + guard let result = snapshot.intArray(forKey: configKey) else { + throw DecodingError.valueNotFound( + [Int].self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "No int array found for key \"\(configKey)\"." + ) + ) + } + resolved = .ints(result) + return result + } + + private mutating func resolveDoubles() throws -> [Double] { + if case .doubles(let a) = resolved { return a } + let configKey = configKey() + guard let result = snapshot.doubleArray(forKey: configKey) else { + throw DecodingError.valueNotFound( + [Double].self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "No double array found for key \"\(configKey)\"." + ) + ) + } + resolved = .doubles(result) + return result + } + + private mutating func resolveBools() throws -> [Bool] { + if case .bools(let a) = resolved { return a } + let configKey = configKey() + guard let result = snapshot.boolArray(forKey: configKey) else { + throw DecodingError.valueNotFound( + [Bool].self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "No bool array found for key \"\(configKey)\"." + ) + ) + } + resolved = .bools(result) + return result + } + + private mutating func integerElement() throws -> T { + let intValue = try decode(Int.self) + guard let converted = T(exactly: intValue) else { + throw DecodingError.typeMismatch( + T.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Value \(intValue) does not fit in \(T.self)." + ) + ) + } + return converted + } + + private func checkBounds(_ count: Int) throws { + guard currentIndex < count else { + throw DecodingError.valueNotFound( + Any.self, + DecodingError.Context( + codingPath: codingPath, + debugDescription: "Unkeyed container is at end (index \(currentIndex), count \(count))." + ) + ) + } + } +} diff --git a/Sources/ContainerPersistence/ConfigurationLoader.swift b/Sources/ContainerPersistence/ConfigurationLoader.swift new file mode 100644 index 0000000..760fd46 --- /dev/null +++ b/Sources/ContainerPersistence/ConfigurationLoader.swift @@ -0,0 +1,218 @@ +//===----------------------------------------------------------------------===// +// 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 Configuration +import ConfigurationTOML +import ContainerizationError +import Foundation +import SystemPackage + +public protocol Initable { + init() +} + +public typealias LoadableConfiguration = Codable & Sendable & Initable + +public protocol LoadablePluginConfiguration: LoadableConfiguration { + static var pluginId: String { get } +} + +public enum ConfigurationLoader { + private static let configFilename = "config.toml" + private static let configDirectory = "config" + private static let READ_ONLY: Int = 0o444 + private static let READ_AND_WRITE: Int = 0o644 + + /// Returns the configuration file path for a given base kind, resolving the base + /// directory via `BaseConfigPath.basePath()` (env-driven, with fallbacks). + /// + /// Use `configurationFile(in:of:)` when you need to supply an explicit base — + /// e.g. a CLI flag like `--app-root` that bypasses env lookup. + /// + /// - Parameter kind: The base directory role to resolve. + public static func configurationFile(_ kind: PathUtils.BaseConfigPath) -> FilePath { + configurationFile(in: kind.basePath(), of: kind) + } + + /// Returns the configuration file path under an explicit base directory. + /// + /// Path shape depends on `kind`: + /// - `.home`: `/config.toml` (user source under `~/.config/container`) + /// - e.g. `~/.config/container/config.toml` + /// - `.appRoot`: `/config/config.toml` (read-only copy of user config) + /// - e.g. `~/Library/Application Support/com.apple.container/config/config.toml` + /// - `.installRoot`: `/etc/container/config.toml` (system defaults shipped with install) + /// - e.g. `/usr/local/etc/container/config.toml` + /// + /// - Parameters: + /// - base: Directory to resolve against. + /// - kind: Base directory role. Defaults to `.appRoot`. + public static func configurationFile( + in base: FilePath, + of kind: PathUtils.BaseConfigPath = .appRoot + ) -> FilePath { + switch kind { + case .home: base.appending(configFilename) + case .appRoot: base.appending(configDirectory).appending(configFilename) + case .installRoot: base.appending("etc/container").appending(configFilename) + } + } + + /// Default ordered TOML layers consumed by `load` and `loadForPlugin`: + /// user config (`.appRoot`) followed by system defaults (`.installRoot`). + public static func defaultConfigFiles() -> [FilePath] { + [ + configurationFile(.appRoot), + configurationFile(.installRoot), + ] + } + + /// Load the `ContainerSystemConfig` by layering TOML files with first-match-wins precedence. + /// + /// Providers are consulted in the order given — values from earlier files override + /// later ones. The default order is user config (`/config/config.toml`) + /// > system config (`/etc/container/config/config.toml`). + /// + /// An empty `configurationFiles` array falls back to `defaultConfigFiles()`. + /// + /// When a key is absent from every file, `ContainerSystemConfig.init(from:)` uses + /// `decodeIfPresent` and falls back to the property's default value — "code defaults" + /// are not a provider layer. + /// + /// Missing files are tolerated; malformed TOML still throws. + /// + /// - Parameter configurationFiles: Ordered TOML layers, highest precedence first. + /// Defaults to `defaultConfigFiles()`. + /// - Returns: The decoded `ContainerSystemConfig`. + /// - Throws: `ContainerizationError.invalidArgument` if any layer fails to load or decode. + public static func load( + configurationFiles: [FilePath] = defaultConfigFiles() + ) async throws -> ContainerSystemConfig { + try await loadAndDecode( + ContainerSystemConfig.self, + configurationFiles: configurationFiles, + decodeErrorContext: "failed to decode configuration" + ) + } + + /// Load a plugin-scoped configuration from the `[plugin.]` section of + /// the layered TOML files. + /// + /// Uses the same layering and precedence rules as `load`, but scopes the snapshot + /// to `plugin.` before decoding. A missing `[plugin.]` + /// section falls back to `P()`. + /// + /// - Parameter configurationFiles: Ordered TOML layers, highest precedence first. + /// Defaults to `defaultConfigFiles()`. + /// - Returns: The decoded plugin configuration, or `P()` if no files exist. + /// - Throws: `ContainerizationError.invalidArgument` if `P.pluginId` is empty, a + /// layer fails to load, or the `[plugin.]` section is malformed. + public static func loadForPlugin( + configurationFiles: [FilePath] = defaultConfigFiles() + ) async throws -> P { + let id = P.pluginId + guard !id.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "plugin id must not be empty") + } + return try await loadAndDecode( + P.self, + configurationFiles: configurationFiles, + scope: ConfigKey("plugin.\(id)"), + decodeErrorContext: "failed to decode plugin configuration for '\(id)'" + ) + } + + /// Shared implementation for `load` and `loadForPlugin`. Builds TOML providers + /// from `configurationFiles`, optionally scopes the snapshot, then decodes into `T`. + /// Short-circuits to `T()` when every path is missing on disk. + /// + /// - Parameters: + /// - type: The concrete `LoadableConfiguration` type to decode. + /// - configurationFiles: Ordered TOML layers; empty falls back to `defaultConfigFiles()`. + /// - scope: Optional `ConfigKey` to scope the snapshot before decoding. + /// - decodeErrorContext: Prefix used in the `invalidArgument` error thrown on decode failure. + private static func loadAndDecode( + _ type: T.Type, + configurationFiles: [FilePath], + scope: ConfigKey? = nil, + decodeErrorContext: String + ) async throws -> T { + let paths = configurationFiles.isEmpty ? defaultConfigFiles() : configurationFiles + let fm = FileManager.default + if paths.allSatisfy({ !fm.fileExists(atPath: $0.string) }) { + return T() + } + + var providers: [FileProvider] = [] + for path in paths { + do { + try providers.append(await FileProvider(filePath: path, allowMissing: true)) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to load configuration from '\(path)': \(error)" + ) + } + } + + let reader = ConfigReader(providers: providers) + let snapshot = scope.map { reader.snapshot().scoped(to: $0) } ?? reader.snapshot() + do { + return try ConfigSnapshotDecoder().decode(T.self, from: snapshot) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "\(decodeErrorContext): \(error)" + ) + } + } + + /// Copies the user's runtime configuration into the app-root as a read-only snapshot. + /// + /// If `source` does not exist, this is a no-op. Otherwise, any existing destination + /// is deleted and replaced with a fresh copy, which is then marked read-only. + /// + /// - Parameters: + /// - source: File to copy from. Defaults to `/container/config.toml`. + /// - destination: Directory to copy into — the filename is appended automatically. + /// Defaults to `/config/config.toml`. + public static func copyConfigurationToReadOnly( + from source: FilePath? = nil, + to destination: FilePath? = nil + ) throws { + let sourcePath = source ?? configurationFile(.home) + let destBase = destination ?? PathUtils.BaseConfigPath.appRoot.basePath() + let destPath = configurationFile(in: destBase) + + let fm = FileManager.default + + if fm.fileExists(atPath: destPath.string) { + try fm.setAttributes([.posixPermissions: READ_AND_WRITE], ofItemAtPath: destPath.string) + try fm.removeItem(at: URL(filePath: destPath.string)) + } + + guard fm.fileExists(atPath: sourcePath.string) else { return } + + let destDir = destPath.removingLastComponent() + try fm.createDirectory(atPath: destDir.string, withIntermediateDirectories: true) + + try fm.copyItem( + at: URL(filePath: sourcePath.string), + to: URL(filePath: destPath.string) + ) + try fm.setAttributes([.posixPermissions: READ_ONLY], ofItemAtPath: destPath.string) + } +} diff --git a/Sources/ContainerPersistence/ContainerSystemConfig.swift b/Sources/ContainerPersistence/ContainerSystemConfig.swift new file mode 100644 index 0000000..c3b04e2 --- /dev/null +++ b/Sources/ContainerPersistence/ContainerSystemConfig.swift @@ -0,0 +1,238 @@ +//===----------------------------------------------------------------------===// +// 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 CVersion +import ContainerVersion +import ContainerizationExtras +import Foundation + +/// Top-level configuration decoded from config.toml. +/// +/// Each section maps to a nested struct. Missing keys fall back to +/// hardcoded defaults via custom `init(from:)` implementations. +public final class ContainerSystemConfig: Codable, Sendable, Initable { + public let build: BuildConfig + public let container: ContainerConfig + public let dns: DNSConfig + public let kernel: KernelConfig + public let machine: MachineConfig + public let network: NetworkConfig + public let registry: RegistryConfig + public let vminit: VminitConfig + + public init( + build: BuildConfig = .init(), + container: ContainerConfig = .init(), + dns: DNSConfig = .init(), + kernel: KernelConfig = .init(), + machine: MachineConfig = MachineConfig.default, + network: NetworkConfig = .init(), + registry: RegistryConfig = .init(), + vminit: VminitConfig = .init() + ) { + self.build = build + self.container = container + self.dns = dns + self.kernel = kernel + self.machine = machine + self.network = network + self.registry = registry + self.vminit = vminit + } + + public init() { + self.build = .init() + self.container = .init() + self.dns = .init() + self.kernel = .init() + self.machine = MachineConfig.default + self.network = .init() + self.registry = .init() + self.vminit = .init() + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.build = try container.decodeIfPresent(BuildConfig.self, forKey: .build) ?? .init() + self.container = try container.decodeIfPresent(ContainerConfig.self, forKey: .container) ?? .init() + self.dns = try container.decodeIfPresent(DNSConfig.self, forKey: .dns) ?? .init() + self.kernel = try container.decodeIfPresent(KernelConfig.self, forKey: .kernel) ?? .init() + self.machine = try container.decodeIfPresent(MachineConfig.self, forKey: .machine) ?? MachineConfig.default + self.network = try container.decodeIfPresent(NetworkConfig.self, forKey: .network) ?? .init() + self.registry = try container.decodeIfPresent(RegistryConfig.self, forKey: .registry) ?? .init() + self.vminit = try container.decodeIfPresent(VminitConfig.self, forKey: .vminit) ?? .init() + } +} + +final public class BuildConfig: Codable, Sendable { + public static let defaultRosetta = true + public static let defaultCPUs = 2 + public static let defaultMemory = try! MemorySize("2048MB") + public static var defaultImage: String { + let tag = String(cString: get_container_builder_shim_version()) + return "ghcr.io/apple/container-builder-shim/builder:\(tag)" + } + + public let rosetta: Bool + public let cpus: Int + public let memory: MemorySize + public let image: String + + public init( + rosetta: Bool = defaultRosetta, + cpus: Int = defaultCPUs, + memory: MemorySize = defaultMemory, + image: String = defaultImage + ) { + self.rosetta = rosetta + self.cpus = cpus + self.memory = memory + self.image = image + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? Self.defaultRosetta + self.cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) ?? Self.defaultCPUs + self.memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) ?? Self.defaultMemory + self.image = try container.decodeIfPresent(String.self, forKey: .image) ?? Self.defaultImage + } +} + +final public class ContainerConfig: Codable, Sendable { + public static let defaultCPUs = 4 + public static let defaultMemory = try! MemorySize("1g") + + public let cpus: Int + public let memory: MemorySize + + public init(cpus: Int = defaultCPUs, memory: MemorySize = defaultMemory) { + self.cpus = cpus + self.memory = memory + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) ?? Self.defaultCPUs + self.memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) ?? Self.defaultMemory + } +} + +final public class DNSConfig: Codable, Sendable { + public let domain: String? + + public init(domain: String? = nil) { + self.domain = domain + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.domain = try container.decodeIfPresent(String.self, forKey: .domain) + } +} + +final public class VminitConfig: Codable, Sendable { + public static var defaultImage: String { + let tag = String(cString: get_swift_containerization_version()) + return tag == "latest" + ? "vminit:latest" + : "ghcr.io/apple/containerization/vminit:\(tag)" + } + + public let image: String + + public init(image: String = defaultImage) { + self.image = image + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.image = try container.decodeIfPresent(String.self, forKey: .image) ?? Self.defaultImage + } +} + +final public class KernelConfig: Codable, Sendable { + public static let defaultBinaryPath = "opt/kata/share/kata-containers/vmlinux-6.18.15-186" + public static let defaultURL: URL = + URL(string: "https://github.com/kata-containers/kata-containers/releases/download/3.28.0/kata-static-3.28.0-arm64.tar.zst")! + + private enum CodingKeys: String, CodingKey { + case binaryPath + case url + } + + public let binaryPath: String + public let url: URL + + public init( + binaryPath: String = defaultBinaryPath, + url: URL = defaultURL + ) { + self.binaryPath = binaryPath + self.url = url + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.binaryPath = + try container.decodeIfPresent(String.self, forKey: .binaryPath) + ?? Self.defaultBinaryPath + if let urlString = try container.decodeIfPresent(String.self, forKey: .url), + let parsed = URL(string: urlString) + { + self.url = parsed + } else { + self.url = Self.defaultURL + } + } + + // JSONEncoder special-cases URL to encode as absoluteString, but third-party + // encoders (e.g. TOMLEncoder) hit Foundation's default Codable conformance which + // encodes into a keyed container with a "relative" key. Encode as a plain string + // so all formats produce a consistent URL representation. + // If more config types start using URL, consider a property wrapper or a wrapper + // type (like MemorySize) that encodes/decodes URL as a string uniformly. + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(binaryPath, forKey: .binaryPath) + try container.encode(url.absoluteString, forKey: .url) + } +} + +final public class NetworkConfig: Codable, Sendable { + public let subnet: CIDRv4? + public let subnetv6: CIDRv6? + + public init(subnet: CIDRv4? = nil, subnetv6: CIDRv6? = nil) { + self.subnet = subnet + self.subnetv6 = subnetv6 + } +} + +final public class RegistryConfig: Codable, Sendable { + public static let defaultDomain = "docker.io" + + public let domain: String + + public init(domain: String = defaultDomain) { + self.domain = domain + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.domain = try container.decodeIfPresent(String.self, forKey: .domain) ?? Self.defaultDomain + } +} diff --git a/Sources/ContainerPersistence/EntityStore.swift b/Sources/ContainerPersistence/EntityStore.swift new file mode 100644 index 0000000..60b0805 --- /dev/null +++ b/Sources/ContainerPersistence/EntityStore.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import Logging +import SystemPackage + +private let metadataFilename: String = "entity.json" + +public protocol EntityStore { + associatedtype T: Codable & Identifiable & Sendable + + func list() async throws -> [T] + func create(_ entity: T) async throws + func retrieve(_ id: String) async throws -> T? + func update(_ entity: T) async throws + func upsert(_ entity: T) async throws + func delete(_ id: String) async throws +} + +public actor FilesystemEntityStore: EntityStore where T: Codable & Identifiable & Sendable { + typealias Index = [String: T] + + private let path: FilePath + private let type: String + private var index: Index + private let log: Logger + private let encoder = JSONEncoder() + + public init(path: FilePath, type: String, log: Logger) throws { + self.path = path + self.type = type + self.log = log + self.index = try Self.load(path: path, log: log) + } + + public func list() async throws -> [T] { + Array(index.values) + } + + public func create(_ entity: T) async throws { + let metadataPath = try metadataPath(entity.id) + guard !FileManager.default.fileExists(atPath: metadataPath.string) else { + throw ContainerizationError(.exists, message: "entity \(entity.id) already exist") + } + + let entityPath = try entityPath(entity.id) + try FileManager.default.createDirectory(atPath: entityPath.string, withIntermediateDirectories: true) + let data = try encoder.encode(entity) + try data.write(to: URL(filePath: metadataPath.string)) + index[entity.id] = entity + } + + public func retrieve(_ id: String) throws -> T? { + index[id] + } + + public func update(_ entity: T) async throws { + let metadataPath = try metadataPath(entity.id) + guard FileManager.default.fileExists(atPath: metadataPath.string) else { + throw ContainerizationError(.notFound, message: "entity \(entity.id) not found") + } + + let data = try encoder.encode(entity) + try data.write(to: URL(filePath: metadataPath.string)) + index[entity.id] = entity + } + + public func upsert(_ entity: T) async throws { + let entityPath = try entityPath(entity.id) + try FileManager.default.createDirectory(atPath: entityPath.string, withIntermediateDirectories: true) + let metadataPath = try metadataPath(entity.id) + let data = try encoder.encode(entity) + try data.write(to: URL(filePath: metadataPath.string)) + index[entity.id] = entity + } + + public func delete(_ id: String) async throws { + let metadataPath = try entityPath(id) + guard FileManager.default.fileExists(atPath: metadataPath.string) else { + throw ContainerizationError(.notFound, message: "entity \(id) not found") + } + try FileManager.default.removeItem(atPath: metadataPath.string) + index.removeValue(forKey: id) + } + + public nonisolated func entityPath(_ id: String) throws -> FilePath { + guard let component = FilePath.Component(id) else { + throw ContainerizationError(.invalidArgument, message: "entity ID \(id) cannot be a path component") + } + return path.appending(component) + } + + private static func load(path: FilePath, log: Logger) throws -> Index { + let directories = try FileManager.default.contentsOfDirectory(atPath: path.string) + var index: FilesystemEntityStore.Index = Index() + let decoder = JSONDecoder() + + for filename in directories { + let metadataPath = path.appending(filename).appending(metadataFilename) + do { + let data = try Data(contentsOf: URL(filePath: metadataPath.string)) + let entity = try decoder.decode(T.self, from: data) + index[entity.id] = entity + } catch { + log.warning( + "failed to load entity, ignoring", + metadata: [ + "path": "\(metadataPath.string)" + ]) + } + } + + return index + } + + private func metadataPath(_ id: String) throws -> FilePath { + try entityPath(id).appending(metadataFilename) + } +} diff --git a/Sources/ContainerPersistence/FilePath+Symlinks.swift b/Sources/ContainerPersistence/FilePath+Symlinks.swift new file mode 100644 index 0000000..fcf90d3 --- /dev/null +++ b/Sources/ContainerPersistence/FilePath+Symlinks.swift @@ -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 Darwin +import SystemPackage + +extension FilePath { + /// Returns a new `FilePath` with all symlinks resolved and `.`/`..` + /// components normalized, by calling `realpath(3)`. + /// + /// Unlike ``lexicallyNormalized()``, this method accesses the file system. + /// It throws ``Errno/noSuchFileOrDirectory`` if any component of the path + /// does not exist. + /// + /// The returned path is always absolute. If the receiver is a relative path, + /// it is resolved against the process's current working directory. + public func resolvingSymlinks() throws -> FilePath { + try withPlatformString { cPath in + guard let resolved = Darwin.realpath(cPath, nil) else { + throw Errno(rawValue: Darwin.errno) + } + defer { free(resolved) } + return FilePath(platformString: resolved) + } + } +} diff --git a/Sources/ContainerPersistence/MachineConfig.swift b/Sources/ContainerPersistence/MachineConfig.swift new file mode 100644 index 0000000..6c81612 --- /dev/null +++ b/Sources/ContainerPersistence/MachineConfig.swift @@ -0,0 +1,251 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SystemPackage + +/// Boot-time configuration for a container machine. +/// +/// These values can be modified without recreating the container machine. +/// Changes take effect on the next boot. `nil` values mean +/// "use the container runtime default." +public struct MachineConfig: Codable, Sendable { + public static let `default`: MachineConfig = try! .init( + cpus: nil, memory: nil, homeMount: nil, virtualization: nil, kernelPath: nil) + + public static var defaultCPUs: Int { + max(ProcessInfo.processInfo.processorCount / 2, 4) + } + + public static var defaultMemory: MemorySize { + let bytes = max(ProcessInfo.processInfo.physicalMemory / 2, 1024 * 1024 * 1024) + let gb = bytes / (1024 * 1024 * 1024) + return try! MemorySize("\(gb)gb") + } + + public static let defaultHomeMount: HomeMountOption = .rw + + /// Home mount option for the /Users/ directory. + public enum HomeMountOption: String, Sendable, Codable { + case ro + case rw + case none + } + + /// Number of virtual CPUs. + public let cpus: Int + /// Memory in bytes. + public let memory: MemorySize + /// Home mount configuration. nil = system default. + public let homeMount: HomeMountOption + /// Whether to expose nested virtualization to the container machine. + public let virtualization: Bool + /// Optional path to a custom kernel binary. nil falls back to the system default. + public let kernelPath: FilePath? + + private enum CodingKeys: String, CodingKey { + case cpus + case memory + case homeMount + case virtualization + case kernelPath + } + + /// Settable keys and their descriptions, for CLI help text generation. + public static let settableKeys: [(key: String, valueName: String, description: String)] = [ + ("cpus", "", "Number of virtual CPUs"), + ("memory", "", "Memory allocation (e.g., 2G, 1G). Default: half of system memory"), + ("home-mount", "", "User home directory mount option (ro, rw, none). Default: rw"), + ("virtualization", "", "Enable nested virtualization (true|false). Requires Apple Silicon M3+ and macOS 15+ and kernel with CONFIG_KVM=y."), + ("kernel", "", "Path to a custom kernel binary. Empty value resets to the system default."), + ] + + public init( + cpus: Int?, + memory: MemorySize?, + homeMount: HomeMountOption?, + virtualization: Bool?, + kernelPath: FilePath? + ) throws { + self.cpus = cpus ?? Self.defaultCPUs + self.memory = memory ?? Self.defaultMemory + self.homeMount = homeMount ?? Self.defaultHomeMount + self.virtualization = virtualization ?? false + self.kernelPath = kernelPath + + try self.validate() + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + let cpus = try container.decodeIfPresent(Int.self, forKey: .cpus) + let memory = try container.decodeIfPresent(MemorySize.self, forKey: .memory) + let homeMount = try container.decodeIfPresent(HomeMountOption.self, forKey: .homeMount) + let virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) + // FilePath's default Codable conformance encodes its internal SystemChar storage, + // which the project's ConfigSnapshotDecoder can't handle. Persist as a plain String + // and lift to FilePath in memory. + let kernelPath = try container.decodeIfPresent(String.self, forKey: .kernelPath).map { FilePath($0) } + + try self.init( + cpus: cpus, + memory: memory, + homeMount: homeMount, + virtualization: virtualization, + kernelPath: kernelPath) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(cpus, forKey: .cpus) + try container.encode(memory, forKey: .memory) + try container.encode(homeMount, forKey: .homeMount) + try container.encode(virtualization, forKey: .virtualization) + try container.encodeIfPresent(kernelPath?.string, forKey: .kernelPath) + } + + private func validate() throws { + guard self.cpus > 0 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid CPU count '\(self.cpus)'. Must be a positive integer (e.g., 4)." + ) + } + + guard self.memory.toUInt64(unit: .bytes) >= 1024 * 1024 * 1024 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid memory value '\(self.memory)'. Must be greater than 1gb." + ) + } + } +} + +extension MachineConfig { + /// Generate a help discussion string listing all settable keys. + public static func helpText() -> String { + settableKeys.map { entry in + let label = "\(entry.key)=\(entry.valueName)" + let padding = String(repeating: " ", count: max(1, 24 - label.count)) + return "\(label)\(padding)\(entry.description)" + }.joined(separator: "\n") + } + + /// Create a new MachineConfig from `self`, applying fields defined in `kwargs` + /// This function is used in both `machine create` and `machine set` + public func with(_ kwargs: [String: String]) throws -> MachineConfig { + let validKeys = Set(Self.settableKeys.map(\.key)) + let unknownKeys = Set(kwargs.keys).subtracting(validKeys) + guard unknownKeys.isEmpty else { + throw ContainerizationError( + .invalidArgument, + message: "unknown fields '\(unknownKeys.joined(separator: ", "))'. Valid: \(validKeys.joined(separator: ", "))") + } + + let cpus = try kwargs["cpus"].map { try Self.parseInt($0, for: "cpus") } + let memory = try kwargs["memory"].map { try MemorySize($0) } + let homeMount = try kwargs["home-mount"].map { try Self.parseHomeMount($0) } + let virtualization = try kwargs["virtualization"].map { try Self.parseBool($0, for: "virtualization") } + // Empty string explicitly clears the kernel override; absent key leaves it unchanged. + let kernelPath: FilePath? + if let raw = kwargs["kernel"] { + kernelPath = raw.isEmpty ? nil : FilePath(raw) + } else { + kernelPath = self.kernelPath + } + + return try .init( + cpus: cpus ?? self.cpus, + memory: memory ?? self.memory, + homeMount: homeMount ?? self.homeMount, + virtualization: virtualization ?? self.virtualization, + kernelPath: kernelPath + ) + } + + /// Parse and validate a CPU count from user input. + private static func parseInt(_ value: String, for key: String) throws -> Int { + guard let num = Int(value) else { + throw ContainerizationError( + .invalidArgument, + message: "failed to parse \(value) for \(key)" + ) + } + return num + } + + /// Parse and validate a home mount option from user input. + private static func parseHomeMount(_ value: String) throws -> MachineConfig.HomeMountOption { + guard let opt = MachineConfig.HomeMountOption(rawValue: value) else { + throw ContainerizationError( + .invalidArgument, + message: "invalid home mount option '\(value)'. Valid options: ro, rw, none" + ) + } + return opt + } + + /// Parse a boolean setting accepting only "true" or "false". + private static func parseBool(_ value: String, for key: String) throws -> Bool { + guard let result = Parsers.parseBool(string: value) else { + throw ContainerizationError( + .invalidArgument, + message: "invalid value '\(value)' for \(key). Expected 'true' or 'false'." + ) + } + return result + } +} + +extension MachineConfig { + /// Resolves the user-supplied kernel path to absolute and confirms it points to a + /// readable, non-empty regular file. Used at create, set, and boot time. + public static func validateKernelPath(_ path: String) throws -> FilePath { + let absolute = URL(fileURLWithPath: path).path + let fm = FileManager.default + + var isDirectory: ObjCBool = false + guard fm.fileExists(atPath: absolute, isDirectory: &isDirectory) else { + throw ContainerizationError( + .invalidArgument, + message: "kernel binary not found at '\(absolute)'" + ) + } + guard !isDirectory.boolValue else { + throw ContainerizationError( + .invalidArgument, + message: "kernel path '\(absolute)' is a directory, expected a file" + ) + } + guard fm.isReadableFile(atPath: absolute) else { + throw ContainerizationError( + .invalidArgument, + message: "kernel binary at '\(absolute)' is not readable" + ) + } + let attrs = try fm.attributesOfItem(atPath: absolute) + let size = (attrs[.size] as? NSNumber)?.uint64Value ?? 0 + guard size > 0 else { + throw ContainerizationError( + .invalidArgument, + message: "kernel binary at '\(absolute)' is empty" + ) + } + return FilePath(absolute) + } +} diff --git a/Sources/ContainerPersistence/Measurement+Parse.swift b/Sources/ContainerPersistence/Measurement+Parse.swift new file mode 100644 index 0000000..8661ad8 --- /dev/null +++ b/Sources/ContainerPersistence/Measurement+Parse.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +let binaryUnits: [Character: UnitInformationStorage] = [ + "b": .bytes, + "k": .kibibytes, + "m": .mebibytes, + "g": .gibibytes, + "t": .tebibytes, + "p": .pebibytes, +] + +extension Measurement { + public enum ParseError: Swift.Error, CustomStringConvertible { + case invalidSize + case invalidSymbol(String) + + public var description: String { + switch self { + case .invalidSize: + return "invalid size" + case .invalidSymbol(let symbol): + return "invalid symbol: \(symbol)" + } + } + } + + /// parseMemory the provided string into a measurement that is able to be converted to various byte sizes using binary exponents + public static func parse(parsing: String) throws -> Measurement { + let check = "01234567890." + let trimmed = parsing.trimmingCharacters(in: .whitespaces).lowercased() + guard !trimmed.isEmpty else { + throw ParseError.invalidSize + } + + let i = trimmed.firstIndex { + !check.contains($0) + } + let rawValue = + i + .map { trimmed[..<$0].trimmingCharacters(in: .whitespaces) } + ?? trimmed + let rawUnit = i.map { trimmed[$0...].trimmingCharacters(in: .whitespaces) } ?? "" + + let value = Double(rawValue) + guard let value else { + throw ParseError.invalidSize + } + let unitSymbol = try Self.parseUnit(rawUnit) + + let unit = binaryUnits[unitSymbol] + guard let unit else { + throw ParseError.invalidSymbol(rawUnit) + } + return Measurement(value: value, unit: unit) + } + + static func parseUnit(_ unit: String) throws -> Character { + let s = unit.dropFirst() + let unitSymbol = unit.first ?? "b" + + switch s { + case "", "ib", "b": + return unitSymbol + default: + throw ParseError.invalidSymbol(unit) + } + } +} diff --git a/Sources/ContainerPersistence/MemorySize.swift b/Sources/ContainerPersistence/MemorySize.swift new file mode 100644 index 0000000..e0762b3 --- /dev/null +++ b/Sources/ContainerPersistence/MemorySize.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// This is a thin wrapper around Measurement to enable +/// better Codable implementations for user provided options. With this wrapper +/// values will get encoded and decoded from the format "1g" or "10mb". +public struct MemorySize: Codable, Sendable, Equatable, CustomStringConvertible { + public var description: String { formatted } + + public let measurement: Measurement + + public init(_ string: String) throws { + self.measurement = try .parse(parsing: string) + } + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + let string = try container.decode(String.self) + try self.init(string) + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(formatted) + } + + private static let unitLabels: [UnitInformationStorage: String] = [ + .bytes: "b", + .kibibytes: "kb", + .mebibytes: "mb", + .gibibytes: "gb", + .tebibytes: "tb", + .pebibytes: "pb", + ] + + public var formatted: String { + let value = Int64(measurement.value) + let label = Self.unitLabels[measurement.unit] ?? "unknown" + return "\(value)\(label)" + } +} + +extension MemorySize { + public func toUInt64(unit: UnitInformationStorage) -> UInt64 { + UInt64(self.measurement.converted(to: unit).value.rounded()) + } +} diff --git a/Sources/ContainerPersistence/Parsers.swift b/Sources/ContainerPersistence/Parsers.swift new file mode 100644 index 0000000..4218de3 --- /dev/null +++ b/Sources/ContainerPersistence/Parsers.swift @@ -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 Foundation + +/// Generic value parsers shared across the project. Lives in `ContainerPersistence` +/// so both higher-level CLI parsers and the persistence layer can reuse the same +/// canonical implementations. +public enum Parsers { + /// Parse a boolean string accepting "true"/"t"/"false"/"f" (case-insensitive). + /// Returns nil if the input matches none. + public static func parseBool(string: String) -> Bool? { + switch string.lowercased() { + case "true", "t": return true + case "false", "f": return false + default: return nil + } + } +} diff --git a/Sources/ContainerPersistence/PathUtils.swift b/Sources/ContainerPersistence/PathUtils.swift new file mode 100644 index 0000000..d91bb3e --- /dev/null +++ b/Sources/ContainerPersistence/PathUtils.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion +import Foundation +import SystemPackage + +public enum PathUtils { + public enum BaseConfigPath { + case home + case appRoot + case installRoot + + public func basePath(env: [String: String] = ProcessInfo.processInfo.environment) -> FilePath { + switch self { + case .home: + let configHome: String + if let xdg = env["XDG_CONFIG_HOME"], !xdg.isEmpty { + configHome = xdg + } else { + configHome = NSHomeDirectory() + "/.config" + } + return FilePath(configHome).appending("container") + case .appRoot: + if let envPath = env["CONTAINER_APP_ROOT"], !envPath.isEmpty { + return FilePath(envPath) + } + let appSupportURL = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("com.apple.container") + return FilePath(appSupportURL.path(percentEncoded: false)) + case .installRoot: + if let envPath = env["CONTAINER_INSTALL_ROOT"], !envPath.isEmpty { + return FilePath(envPath) + } + // Use the kernel-recorded executable path (via _NSGetExecutablePath) + // rather than argv[0]: when the binary is invoked through PATH (e.g. + // `container ...`), argv[0] is just the basename and resolves to an + // empty FilePath, which FileManager treats as CWD-relative. + let installRootPath = CommandLine.executablePath + .removingLastComponent() + .removingLastComponent() + return installRootPath + } + } + } +} diff --git a/Sources/ContainerPlugin/ApplicationRoot.swift b/Sources/ContainerPlugin/ApplicationRoot.swift new file mode 100644 index 0000000..4677327 --- /dev/null +++ b/Sources/ContainerPlugin/ApplicationRoot.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// 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 SystemPackage + +/// Provides the application data root path. +public struct ApplicationRoot { + /// The environment variable that if set, determines the root directory for the application data store. + /// Otherwise, the system uses the default "~/Library/Application Support/com.apple.container". + public static let environmentName = "CONTAINER_APP_ROOT" + + /// The default root directory used when ``environmentName`` is not set: + /// `~/Library/Application Support/com.apple.container`. + public static let defaultPath = FilePath( + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.path(percentEncoded: false) + ) + .appending(FilePath.Component("com.apple.container")) + + /// The resolved root directory path, always lexically normalized. + /// + /// If the environment variable is set to an absolute path, that path is used directly. + /// If it is set to a relative path, the path is resolved against the working directory. + /// Otherwise, ``defaultPath`` is used. + public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve( + ProcessInfo.processInfo.environment[environmentName], + defaultPath: defaultPath + ) + + /// The pathname to the root directory + public static let pathname = path.string +} diff --git a/Sources/ContainerPlugin/FilePath+Resolve.swift b/Sources/ContainerPlugin/FilePath+Resolve.swift new file mode 100644 index 0000000..54dd22c --- /dev/null +++ b/Sources/ContainerPlugin/FilePath+Resolve.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// 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 SystemPackage + +extension FilePath { + /// Resolves a pathname string relative to this path. + /// + /// The result is lexically normalized — `.` components are removed and `..` components + /// collapse the preceding component. Absolute pathnames are returned normalized as-is; + /// relative pathnames are appended to `self` before normalizing. + /// + /// - Parameter pathname: The pathname to resolve. + /// - Returns: The resolved ``FilePath``, or `nil` if `pathname` is `nil` or empty. + package func resolve(_ pathname: String?) -> FilePath? { + guard let pathname, !pathname.isEmpty else { return nil } + let path = FilePath(pathname) + guard !path.isAbsolute else { return path.lexicallyNormalized() } + return self.appending(path.components).lexicallyNormalized() + } + + /// Resolves a pathname string relative to this path, falling back to a default. + /// + /// The result is lexically normalized — `.` components are removed and `..` components + /// collapse the preceding component. Absolute pathnames are returned normalized as-is; + /// relative pathnames are appended to `self` before normalizing. + /// + /// - Parameters: + /// - pathname: The pathname to resolve. + /// - defaultPath: The path returned when `pathname` is `nil` or empty. + /// - Returns: The resolved ``FilePath``, or `defaultPath` lexically normalized if `pathname` is `nil` or empty. + package func resolve(_ pathname: String?, defaultPath: FilePath) -> FilePath { + resolve(pathname) ?? defaultPath.lexicallyNormalized() + } +} diff --git a/Sources/ContainerPlugin/InstallRoot.swift b/Sources/ContainerPlugin/InstallRoot.swift new file mode 100644 index 0000000..01578b5 --- /dev/null +++ b/Sources/ContainerPlugin/InstallRoot.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion +import Foundation +import SystemPackage + +/// Provides the application installation root path. +public struct InstallRoot { + /// The environment variable that if set, determines the root directory for installed application. + /// Otherwise, the system computes the install path as the parent of the directory containing the + /// application binary (for example, "/usr/local/bin/container" -> "/usr/local"). + public static let environmentName = "CONTAINER_INSTALL_ROOT" + + /// The default root directory used when the environment variable is not set. + /// + /// Computed as the grandparent of ``CommandLine/executablePath`` + /// (for example, `/usr/local/bin/container` → `/usr/local`). + /// Lexically normalized but not canonical, as symlinks in the executable path are not resolved. + public static let defaultPath = CommandLine.executablePath + .removingLastComponent() + .removingLastComponent() + + /// The resolved root directory path, always lexically normalized. + /// + /// If the environment variable is set to an absolute path, that path is used directly. + /// If it is set to a relative path, the path is resolved against the working directory. + /// Otherwise, ``defaultPath`` is used. + public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve( + ProcessInfo.processInfo.environment[environmentName], + defaultPath: defaultPath + ) + + /// The pathname to the root directory + public static let pathname = path.string +} diff --git a/Sources/ContainerPlugin/LaunchPlist.swift b/Sources/ContainerPlugin/LaunchPlist.swift new file mode 100644 index 0000000..ff2297e --- /dev/null +++ b/Sources/ContainerPlugin/LaunchPlist.swift @@ -0,0 +1,133 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Foundation + +public struct LaunchPlist: Encodable { + static let debugTarget = "CONTAINER_DEBUG_LAUNCHD_LABEL" + + public enum Domain: String, Codable { + case Aqua + case Background + case System + } + + public let label: String + public let arguments: [String] + + public let environment: [String: String]? + public let cwd: String? + public let username: String? + public let groupname: String? + public let limitLoadToSessionType: [Domain]? + public let runAtLoad: Bool? + public let stdin: String? + public let stdout: String? + public let stderr: String? + public let disabled: Bool? + public let program: String? + public let keepAlive: Bool? + public let machServices: [String: Bool]? + public let waitForDebugger: Bool? + + enum CodingKeys: String, CodingKey { + case label = "Label" + case arguments = "ProgramArguments" + case environment = "EnvironmentVariables" + case cwd = "WorkingDirectory" + case username = "UserName" + case groupname = "GroupName" + case limitLoadToSessionType = "LimitLoadToSessionType" + case runAtLoad = "RunAtLoad" + case stdin = "StandardInPath" + case stdout = "StandardOutPath" + case stderr = "StandardErrorPath" + case disabled = "Disabled" + case program = "Program" + case keepAlive = "KeepAlive" + case machServices = "MachServices" + case waitForDebugger = "WaitForDebugger" + } + + static private func getWaitForDebugger(label: String, fromArg: Bool?) -> Bool? { + if let fromArg { + return fromArg + } + + let env = ProcessInfo.processInfo.environment + if let debugTarget = env[Self.debugTarget], + label == debugTarget || label.starts(with: debugTarget + ".") + { + return true + } + + return nil + } + + public init( + label: String, + arguments: [String], + environment: [String: String]? = nil, + cwd: String? = nil, + username: String? = nil, + groupname: String? = nil, + limitLoadToSessionType: [Domain]? = nil, + runAtLoad: Bool? = nil, + stdin: String? = nil, + stdout: String? = nil, + stderr: String? = nil, + disabled: Bool? = nil, + program: String? = nil, + keepAlive: Bool? = nil, + machServices: [String]? = nil, + waitForDebugger: Bool? = nil + ) { + self.label = label + self.arguments = arguments + self.environment = environment + self.cwd = cwd + self.username = username + self.groupname = groupname + self.limitLoadToSessionType = limitLoadToSessionType + self.runAtLoad = runAtLoad + self.stdin = stdin + self.stdout = stdout + self.stderr = stderr + self.disabled = disabled + self.program = program + self.keepAlive = keepAlive + self.waitForDebugger = Self.getWaitForDebugger(label: label, fromArg: waitForDebugger) + if let services = machServices { + var machServices: [String: Bool] = [:] + for service in services { + machServices[service] = true + } + self.machServices = machServices + } else { + self.machServices = nil + } + } +} + +extension LaunchPlist { + public func encode() throws -> Data { + let enc = PropertyListEncoder() + enc.outputFormat = .xml + return try enc.encode(self) + } +} +#endif diff --git a/Sources/ContainerPlugin/LogRoot.swift b/Sources/ContainerPlugin/LogRoot.swift new file mode 100644 index 0000000..51630d2 --- /dev/null +++ b/Sources/ContainerPlugin/LogRoot.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SystemPackage + +/// Provides the application data root path. +public struct LogRoot { + /// The environment variable that if set, determines the root directory for log files. + /// Otherwise, the application uses the macOS log facility. + public static let environmentName = "CONTAINER_LOG_ROOT" + + /// The resolved root directory for log files, or `nil` if the environment variable is not set. + /// + /// When non-nil, the path is always lexically normalized. + /// If the environment variable is set to an absolute path, that path is used directly. + /// If it is set to a relative path, the path is resolved against the working directory. + public static let path = FilePath(FileManager.default.currentDirectoryPath).resolve( + ProcessInfo.processInfo.environment[environmentName] + ) + + /// The pathname to the root directory + public static let pathname = path?.string +} diff --git a/Sources/ContainerPlugin/Plugin.swift b/Sources/ContainerPlugin/Plugin.swift new file mode 100644 index 0000000..69251ed --- /dev/null +++ b/Sources/ContainerPlugin/Plugin.swift @@ -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 Foundation + +/// Value type that contains the plugin configuration, the parsed name of the +/// plugin and whether a CLI surface for the plugin was found. +public struct Plugin: Sendable, Codable { + private static let machServicePrefix = "com.apple.container." + + /// Pathname to installation directory for plugins. + public let binaryURL: URL + + /// Configuration for the plugin. + public let config: PluginConfig + + /// Pathname to resources directory for plugins. + public let resourceURL: URL? + + public init(binaryURL: URL, config: PluginConfig, resourceURL: URL? = nil) { + self.binaryURL = binaryURL + self.config = config + self.resourceURL = resourceURL + } +} + +extension Plugin { + public var name: String { binaryURL.lastPathComponent } + + public var shouldBoot: Bool { + guard let config = self.config.servicesConfig else { + return false + } + + return config.loadAtBoot + } + + public func getLaunchdLabel(instanceId: String? = nil) -> String { + // Use the plugin name for the launchd label. + guard let instanceId else { + return "\(Self.machServicePrefix)\(self.name)" + } + return "\(Self.machServicePrefix)\(self.name).\(instanceId)" + } + + public func getMachServices(instanceId: String? = nil) -> [String] { + // Use the service type for the mach service. + guard let config = self.config.servicesConfig else { + return [] + } + var services = [String]() + for service in config.services { + let serviceName: String + if let instanceId { + serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name).\(instanceId)" + } else { + serviceName = "\(Self.machServicePrefix)\(service.type.rawValue).\(name)" + } + services.append(serviceName) + } + return services + } + + public func getMachService(instanceId: String? = nil, type: PluginConfig.DaemonPluginType) -> String? { + guard hasType(type) else { + return nil + } + + guard let instanceId else { + return "\(Self.machServicePrefix)\(type.rawValue).\(name)" + } + return "\(Self.machServicePrefix)\(type.rawValue).\(name).\(instanceId)" + } + + public func hasType(_ type: PluginConfig.DaemonPluginType) -> Bool { + guard let config = self.config.servicesConfig else { + return false + } + + guard !(config.services.filter { $0.type == type }.isEmpty) else { + return false + } + + return true + } +} + +extension Plugin { + public func exec(args: [String]) throws { + var args = args + let executable = self.binaryURL.path + args[0] = executable + let argv = args.map { strdup($0) } + [nil] + guard execvp(executable, argv) != -1 else { + throw POSIXError.fromErrno() + } + fatalError("unreachable") + } + + func helpText(padding: Int) -> String { + guard !self.name.isEmpty else { + return "" + } + let namePadded = name.padding(toLength: padding, withPad: " ", startingAt: 0) + return " " + namePadded + self.config.abstract + } +} diff --git a/Sources/ContainerPlugin/PluginConfig.swift b/Sources/ContainerPlugin/PluginConfig.swift new file mode 100644 index 0000000..c7aaf17 --- /dev/null +++ b/Sources/ContainerPlugin/PluginConfig.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// 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 TOML + +/// PluginConfig details all of the fields to describe and register a plugin. +/// A plugin is registered by creating a subdirectory `/user-plugins`, +/// where the name of the subdirectory is the name of the plugin, and then placing a +/// file named `config.toml` (or fall back to the legacy `config.json` file) inside with +/// the schema below. If `services` is filled in then there MUST be a binary named +/// matching the plugin name in a `bin` subdirectory inside the same directory as +/// the `config.toml`. An example of a valid plugin directory structure would be +/// $ tree foobar +/// foobar +/// ├── bin +/// │ └── foobar +/// └── config.toml (`config.json`) +public struct PluginConfig: Sendable, Codable { + /// Categories of services that can be offered through plugins. + public enum DaemonPluginType: String, Sendable, Codable { + /// A runtime plugin provides an XPC API through which the lifecycle + /// of a **single** container can be managed. + /// A runtime daemon plugin would typically also have a counterpart + /// CLI plugin which knows how to talk to the API exposed by the runtime plugin. + /// The API server ensures that a single instance of the plugin is configured + /// for a given container such that the client can communicate with it given an instance id. + case runtime + /// A network plugin provides an XPC API through which IP address allocations on a given + /// network can be managed. The API server ensures that a single instance + /// of this plugin is configured for a given network. Similar to the runtime plugin, it typically + /// would be accompanied by a CLI plugin that knows how to communicate with the XPC API + /// given an instance id. + case network + /// A core plugin provides an XPC API to manage a given type of resource. + /// The API server ensures that there exist only a single running instance + /// of this plugin type. A core plugin can be thought of a singleton whose lifecycle + /// is tied to that of the API server. Core plugins can be used to expand the base functionality + /// provided by the API server. As with the other plugin types, it maybe associated with a client + /// side plugin that communicates with the XPC service exposed by the daemon plugin. + case core + /// Reserved for future use. Currently there is no difference between a core and auxiliary daemon plugin. + case auxiliary + } + + // An XPC service that the plugin publishes. + public struct Service: Sendable, Codable { + /// The type of the service the daemon is exposing. + /// One plugin can expose multiple services of different types. + /// + /// The plugin MUST expose a MachService at + /// `com.apple.container.{type}.{name}.[{id}]` for + /// each service that it exposes. + public let type: DaemonPluginType + /// Optional description of this service. + public let description: String? + } + + /// Descriptor for the services that the plugin offers. + public struct ServicesConfig: Sendable, Codable { + /// Load the plugin into launchd when the API server starts. + public let loadAtBoot: Bool + /// Launch the plugin binary as soon as it loads into launchd. + public let runAtLoad: Bool + /// The service types that the plugin provides. + public let services: [Service] + /// An optional parameter that include any command line arguments + /// that must be passed to the plugin binary when it is loaded. + public let defaultArguments: [String] + } + + /// Short description of the plugin surface. This will be displayed as the + /// help-text for CLI plugins, and will be returned in API calls to view loaded + /// plugins from the daemon. + public let abstract: String + + /// Author of the plugin. This is solely metadata. + public let author: String? + + /// Services configuration. Specify nil for a CLI plugin, and an empty array for + /// that does not publish any XPC services. + public let servicesConfig: ServicesConfig? + + public init(abstract: String, author: String?, servicesConfig: ServicesConfig?) { + self.abstract = abstract + self.author = author + self.servicesConfig = servicesConfig + } +} + +extension PluginConfig { + public var isCLI: Bool { self.servicesConfig == nil } +} + +extension PluginConfig { + /// Initialize from a config file, selecting the decoder based on file extension. + /// Supports `.toml` (via TOMLDecoder) and `.json` (via JSONDecoder). + public init?(configURL: URL) throws { + let fm = FileManager.default + if !fm.fileExists(atPath: configURL.path) { + return nil + } + + guard let data = fm.contents(atPath: configURL.path) else { + return nil + } + + switch configURL.pathExtension { + case "toml": + guard let content = String(data: data, encoding: .utf8) else { + return nil + } + self = try TOMLDecoder().decode(PluginConfig.self, from: content) + case "json": + self = try JSONDecoder().decode(PluginConfig.self, from: data) + default: + return nil + } + } +} diff --git a/Sources/ContainerPlugin/PluginFactory.swift b/Sources/ContainerPlugin/PluginFactory.swift new file mode 100644 index 0000000..2a9998b --- /dev/null +++ b/Sources/ContainerPlugin/PluginFactory.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Describes the configuration and binary file locations for a plugin. +public protocol PluginFactory: Sendable { + /// Create a plugin from the plugin path, if it conforms to the layout. + func create(installURL: URL) throws -> Plugin? + /// Create a plugin from the plugin parent path and name, if it conforms to the layout. + func create(parentURL: URL, name: String) throws -> Plugin? +} + +/// Default layout which uses a Unix-like structure. +public struct DefaultPluginFactory: PluginFactory { + // Order matters: earlier entries take priority during config file discovery. + private static let configFilenames: [String] = ["config.toml", "config.json"] + private let logger: Logger + + public init(logger: Logger) { + self.logger = logger + } + + /// Returns the URL of the first config file found in `directory`, preferring TOML over JSON. + static func findConfigURL(in directory: URL, logger: Logger) -> URL? { + let fm = FileManager.default + for filename in configFilenames { + let url = directory.appending(path: filename) + if fm.fileExists(atPath: url.path) { + if url.pathExtension == "json" { + logger.warning( + "Plugin using legacy config.json; please migrate to config.toml", + metadata: ["path": "\(url.path)"] + ) + } + return url + } + } + return nil + } + + public func create(installURL: URL) throws -> Plugin? { + let fm = FileManager.default + + guard let configURL = Self.findConfigURL(in: installURL, logger: logger) else { + return nil + } + + guard let config = try PluginConfig(configURL: configURL) else { + return nil + } + + let name = installURL.lastPathComponent + let binaryURL = installURL.appending(path: "bin").appending(path: name) + guard fm.fileExists(atPath: binaryURL.path) else { + return nil + } + + var resourceURL: URL? = nil + if case let url = installURL.appending(path: "resources"), fm.fileExists(atPath: url.path) { + resourceURL = url + } + return Plugin(binaryURL: binaryURL, config: config, resourceURL: resourceURL) + } + + public func create(parentURL: URL, name: String) throws -> Plugin? { + try create(installURL: parentURL.appendingPathComponent(name)) + } +} + +/// Layout which uses a macOS application bundle structure. +public struct AppBundlePluginFactory: PluginFactory { + private static let appSuffix = ".app" + private let logger: Logger + + public init(logger: Logger) { + self.logger = logger + } + + public func create(installURL: URL) throws -> Plugin? { + let fm = FileManager.default + + let contentResources = + installURL + .appending(path: "Contents") + .appending(path: "Resources") + + guard let configURL = DefaultPluginFactory.findConfigURL(in: contentResources, logger: logger) else { + return nil + } + + guard let config = try PluginConfig(configURL: configURL) else { + return nil + } + + let appName = installURL.lastPathComponent + guard appName.hasSuffix(Self.appSuffix) else { + return nil + } + let name = String(appName.dropLast(Self.appSuffix.count)) + let binaryURL = + installURL + .appending(path: "Contents") + .appending(path: "MacOS") + .appending(path: name) + guard fm.fileExists(atPath: binaryURL.path) else { + return nil + } + + var resourceURL: URL? = nil + if case let url = contentResources.appending(path: "resources"), fm.fileExists(atPath: url.path) { + resourceURL = url + } + + return Plugin(binaryURL: binaryURL, config: config, resourceURL: resourceURL) + } + + public func create(parentURL: URL, name: String) throws -> Plugin? { + try create(installURL: parentURL.appendingPathComponent("\(name)\(Self.appSuffix)")) + } +} diff --git a/Sources/ContainerPlugin/PluginLoader.swift b/Sources/ContainerPlugin/PluginLoader.swift new file mode 100644 index 0000000..d685d39 --- /dev/null +++ b/Sources/ContainerPlugin/PluginLoader.swift @@ -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 ContainerizationOS +import Foundation +import Logging +import SystemPackage + +public struct PluginLoader: Sendable { + private let appRoot: URL + + private let installRoot: URL + + private let logRoot: FilePath? + + private let pluginDirectories: [URL] + + private let pluginFactories: [PluginFactory] + + private let log: Logger? + + public typealias PluginQualifier = ((Plugin) -> Bool) + + // A path on disk managed by the PluginLoader, where it stores + // runtime data for loaded plugins. This includes the launchd plists + // and logs files. + private let pluginResourceRoot: URL + + public init( + appRoot: URL, + installRoot: URL, + logRoot: FilePath?, + pluginDirectories: [URL], + pluginFactories: [PluginFactory], + log: Logger? = nil + ) throws { + let pluginResourceRoot = appRoot.appendingPathComponent("plugin-state") + try FileManager.default.createDirectory(at: pluginResourceRoot, withIntermediateDirectories: true) + self.pluginResourceRoot = pluginResourceRoot + self.appRoot = appRoot + self.installRoot = installRoot + self.logRoot = logRoot + self.pluginDirectories = pluginDirectories + self.pluginFactories = pluginFactories + self.log = log + } + + static public func userPluginsDir(installRoot: URL) -> URL { + installRoot + .appending(path: "libexec") + .appending(path: "container-plugins") + .resolvingSymlinksInPath() + } +} + +extension PluginLoader { + public func alterCLIHelpText(original: String) -> String { + var plugins = findPlugins() + plugins = plugins.filter { $0.config.isCLI } + guard !plugins.isEmpty else { + return original + } + + var lines = original.split(separator: "\n").map { String($0) } + + let sectionHeader = "PLUGINS:" + lines.append(sectionHeader) + + for plugin in plugins { + let helpText = plugin.helpText(padding: 24) + lines.append(helpText) + } + + return lines.joined(separator: "\n") + } + + /// Scan all plugin directories and detect plugins. + public func findPlugins() -> [Plugin] { + let fm = FileManager.default + + // Maintain a set for tracking shadowed plugins + var pluginNames = Set() + var plugins: [Plugin] = [] + + for pluginDir in pluginDirectories { + // Skip nonexistent plugin parent directories + if !fm.fileExists(atPath: pluginDir.path) { + continue + } + + // Get all entries under the parent directory + let resolvedPluginDir = pluginDir.resolvingSymlinksInPath() + guard + let urls = try? fm.contentsOfDirectory( + at: resolvedPluginDir, + includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey], + options: .skipsHiddenFiles + ) + else { + continue + } + + // Filter out all but plugin installation directories + let installURLs = urls.filter { url in + if url.isDirectory { + return true + } + + if url.isSymlink { + var isDirectory: ObjCBool = false + _ = fm.fileExists(atPath: url.resolvingSymlinksInPath().path(percentEncoded: false), isDirectory: &isDirectory) + return isDirectory.boolValue + } + + return false + } + + for installURL in installURLs { + do { + // Create a plugin with the first factory that can grok the layout under the install URL + guard + let plugin = try + (pluginFactories.compactMap { + try $0.create(installURL: installURL) + }.first) + else { + log?.warning( + "not installing plugin with missing configuration", + metadata: [ + "path": "\(installURL.path)" + ] + ) + continue + } + + // Warn and skip if this plugin name has been encountered already + guard !pluginNames.contains(plugin.name) else { + log?.warning( + "not installing shadowed plugin", + metadata: [ + "path": "\(installURL.path)", + "name": "\(plugin.name)", + ]) + continue + } + + // Add the plugin to the list + plugins.append(plugin) + pluginNames.insert(plugin.name) + } catch { + log?.warning( + "not installing plugin with invalid configuration", + metadata: [ + "path": "\(installURL.path)", + "error": "\(error)", + ] + ) + } + } + } + + return plugins + } + + /// Locate a plugin with a specific name. + public func findPlugin(name: String, log: Logger? = nil) -> Plugin? { + do { + for pluginDirectory in pluginDirectories { + for PluginFactory in pluginFactories { + // throw means that the factory is correct but the plugin is broken + if let plugin = try PluginFactory.create(parentURL: pluginDirectory, name: name) { + return plugin + } + } + } + } catch { + log?.warning( + "not installing plugin with invalid configuration", + metadata: [ + "name": "\(name)", + "error": "\(error)", + ] + ) + } + + return nil + } +} + +extension PluginLoader { + public static let proxyKeys: Set = { + var keys: Set = [ + "http_proxy", "HTTP_PROXY", + "https_proxy", "HTTPS_PROXY", + "no_proxy", "NO_PROXY", + ] + #if CONTAINER_COVERAGE + // Allows LLVM coverage profiling data to be written by launchd-managed + // helper processes. Compiled in only for coverage enabled builds. + keys.insert("LLVM_PROFILE_FILE") + #endif + return keys + }() + + public func registerWithLaunchd( + plugin: Plugin, + pluginStateRoot: URL? = nil, + args: [String]? = nil, + instanceId: String? = nil, + debug: Bool = false, + ) throws { + // We only care about loading plugins that have a service + // to expose; otherwise, they may just be CLI commands. + guard let serviceConfig = plugin.config.servicesConfig else { + return + } + + let id = plugin.getLaunchdLabel(instanceId: instanceId) + log?.info("Registering plugin", metadata: ["id": "\(id)"]) + let rootURL = pluginStateRoot ?? self.pluginResourceRoot.appending(path: plugin.name) + let resourceURL = plugin.resourceURL + + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + + var env = Self.filterEnvironment() + env[ApplicationRoot.environmentName] = appRoot.path(percentEncoded: false) + env[InstallRoot.environmentName] = installRoot.path(percentEncoded: false) + if let logRoot { + env[LogRoot.environmentName] = + logRoot.isAbsolute + ? logRoot.string + : FilePath(FileManager.default.currentDirectoryPath).appending(logRoot.components).string + } + + let processedArgs = (args ?? ["start"]) + (resourceURL.map { ["--resources", $0.path] } ?? []) + (debug ? ["--debug"] : []) + let plist = LaunchPlist( + label: id, + arguments: [plugin.binaryURL.path] + processedArgs + serviceConfig.defaultArguments, + environment: env, + limitLoadToSessionType: [.Aqua, .Background, .System], + runAtLoad: serviceConfig.runAtLoad, + machServices: plugin.getMachServices(instanceId: instanceId) + ) + + let plistUrl = rootURL.appendingPathComponent("service.plist") + let data = try plist.encode() + try data.write(to: plistUrl) + try ServiceManager.register(plistPath: plistUrl.path) + } + + public func deregisterWithLaunchd(plugin: Plugin, instanceId: String? = nil) throws { + // We only care about loading plugins that have a service + // to expose; otherwise, they may just be CLI commands. + guard plugin.config.servicesConfig != nil else { + return + } + let domain = try ServiceManager.getDomainString() + let label = "\(domain)/\(plugin.getLaunchdLabel(instanceId: instanceId))" + log?.info("Deregistering plugin", metadata: ["id": "\(plugin.getLaunchdLabel())"]) + try ServiceManager.deregister(fullServiceLabel: label) + } + + public static func filterEnvironment( + env: [String: String] = ProcessInfo.processInfo.environment, + additionalAllowKeys: Set = Self.proxyKeys + ) -> [String: String] { + env.filter { key, _ in + key.hasPrefix("CONTAINER_") || additionalAllowKeys.contains(key) + } + } +} diff --git a/Sources/ContainerPlugin/PluginStateRoot.swift b/Sources/ContainerPlugin/PluginStateRoot.swift new file mode 100644 index 0000000..bcb8131 --- /dev/null +++ b/Sources/ContainerPlugin/PluginStateRoot.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SystemPackage + +public struct PluginStateRoot { + private let plugin: FilePath.Component + + public init(plugin: String) throws { + guard let plugin = FilePath.Component(plugin) else { + throw ContainerizationError(.invalidArgument, message: "invalid plugin name \(plugin)") + } + self.plugin = plugin + } + + public var path: FilePath { + ApplicationRoot.path + .appending(FilePath.Component("plugin-state")) + .appending(plugin) + } +} diff --git a/Sources/ContainerPlugin/ServiceManager.swift b/Sources/ContainerPlugin/ServiceManager.swift new file mode 100644 index 0000000..4d2c362 --- /dev/null +++ b/Sources/ContainerPlugin/ServiceManager.swift @@ -0,0 +1,137 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +public struct ServiceManager { + private static func runLaunchctlCommand(args: [String]) throws -> Int32 { + let launchctl = Foundation.Process() + launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") + launchctl.arguments = args + + let null = FileHandle.nullDevice + launchctl.standardOutput = null + launchctl.standardError = null + + try launchctl.run() + launchctl.waitUntilExit() + + return launchctl.terminationStatus + } + + /// Register a service by providing the path to a plist. + public static func register(plistPath: String) throws { + let domain = try Self.getDomainString() + _ = try runLaunchctlCommand(args: ["bootstrap", domain, plistPath]) + } + + /// Deregister a service by a launchd label. + public static func deregister(fullServiceLabel label: String) throws { + _ = try runLaunchctlCommand(args: ["bootout", label]) + } + + /// Deregister a service and pass return status + public static func deregister(fullServiceLabel label: String, status: inout Int32) throws { + status = try runLaunchctlCommand(args: ["bootout", label]) + } + + /// Restart a service by a launchd label. + public static func kickstart(fullServiceLabel label: String) throws { + _ = try runLaunchctlCommand(args: ["kickstart", "-k", label]) + } + + /// Send a signal to a service by a launchd label. + public static func kill(fullServiceLabel label: String, signal: Int32 = 15) throws { + _ = try runLaunchctlCommand(args: ["kill", "\(signal)", label]) + } + + /// Retrieve labels for all loaded launch units. + public static func enumerate() throws -> [String] { + let launchctl = Foundation.Process() + launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") + launchctl.arguments = ["list"] + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + launchctl.standardOutput = stdoutPipe + launchctl.standardError = stderrPipe + + try launchctl.run() + let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + let stderrData = stderrPipe.fileHandleForReading.readDataToEndOfFile() + launchctl.waitUntilExit() + let status = launchctl.terminationStatus + guard status == 0 else { + throw ContainerizationError( + .internalError, message: "command `launchctl list` failed with status \(status), message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")") + } + + guard let outputText = String(data: outputData, encoding: .utf8) else { + throw ContainerizationError( + .internalError, message: "could not decode output of command `launchctl list`, message: \(String(data: stderrData, encoding: .utf8) ?? "no error message")") + } + + // The third field of each line of launchctl list output is the label + return outputText.split { $0.isNewline } + .map { String($0).split { $0.isWhitespace } } + .filter { $0.count >= 3 } + .map { String($0[2]) } + } + + /// Check if a service has been registered or not. + public static func isRegistered(fullServiceLabel label: String) throws -> Bool { + let exitStatus = try runLaunchctlCommand(args: ["list", label]) + return exitStatus == 0 + } + + private static func getLaunchdSessionType() throws -> String { + let launchctl = Foundation.Process() + launchctl.executableURL = URL(fileURLWithPath: "/bin/launchctl") + launchctl.arguments = ["managername"] + + let null = FileHandle.nullDevice + let stdoutPipe = Pipe() + launchctl.standardOutput = stdoutPipe + launchctl.standardError = null + + try launchctl.run() + let outputData = stdoutPipe.fileHandleForReading.readDataToEndOfFile() + launchctl.waitUntilExit() + let status = launchctl.terminationStatus + guard status == 0 else { + throw ContainerizationError(.internalError, message: "command `launchctl managername` failed with status \(status)") + } + guard let outputText = String(data: outputData, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "could not decode output of command `launchctl managername`") + } + return outputText.trimmingCharacters(in: .whitespacesAndNewlines) + } + + public static func getDomainString() throws -> String { + let currentSessionType = try getLaunchdSessionType() + switch currentSessionType { + case LaunchPlist.Domain.System.rawValue: + return LaunchPlist.Domain.System.rawValue.lowercased() + case LaunchPlist.Domain.Background.rawValue: + return "user/\(getuid())" + case LaunchPlist.Domain.Aqua.rawValue: + return "gui/\(getuid())" + default: + throw ContainerizationError(.internalError, message: "unsupported session type \(currentSessionType)") + } + } +} diff --git a/Sources/ContainerResource/Common/ApplicationError.swift b/Sources/ContainerResource/Common/ApplicationError.swift new file mode 100644 index 0000000..6d94afe --- /dev/null +++ b/Sources/ContainerResource/Common/ApplicationError.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Protocol for errors with a stable code and structured metadata. +/// This allows the client to present the error as it chooses. + +import OrderedCollections + +public protocol AppError: Error { + var code: AppErrorCode { get } + var metadata: OrderedDictionary { get } + var underlyingError: Error? { get } +} + +public struct AppErrorCode: RawRepresentable, Hashable, Sendable { + public let rawValue: String + + public init(rawValue: String) { + self.rawValue = rawValue + } + + public static let invalidArgument = AppErrorCode(rawValue: "invalid_argument") +} diff --git a/Sources/ContainerResource/Common/FileManager+AllocatedSize.swift b/Sources/ContainerResource/Common/FileManager+AllocatedSize.swift new file mode 100644 index 0000000..c95dfcc --- /dev/null +++ b/Sources/ContainerResource/Common/FileManager+AllocatedSize.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension FileManager { + /// Total bytes allocated on disk for all files in a directory (recursive). + /// + /// Caveats: hidden files are skipped, symlinks to directories are not followed, but + /// symlinks-to-files and hard links each contribute their target's full allocation + /// so shared inodes are counted multiple times. + public func allocatedSize(of directory: URL) -> UInt64 { + guard + let enumerator = self.enumerator( + at: directory, + includingPropertiesForKeys: [.totalFileAllocatedSizeKey], + options: [.skipsHiddenFiles] + ) + else { + return 0 + } + + var size: UInt64 = 0 + for case let fileURL as URL in enumerator { + guard let resourceValues = try? fileURL.resourceValues(forKeys: [.totalFileAllocatedSizeKey]), + let fileSize = resourceValues.totalFileAllocatedSize + else { + continue + } + size += UInt64(fileSize) + } + return size + } +} diff --git a/Sources/ContainerResource/Common/ManagedResource.swift b/Sources/ContainerResource/Common/ManagedResource.swift new file mode 100644 index 0000000..b2bcda6 --- /dev/null +++ b/Sources/ContainerResource/Common/ManagedResource.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Common properties for all managed resources. +public protocol ManagedResource: Identifiable, Sendable, Codable { + /// A 64 byte hexadecimal string, assigned by the system, that uniquely + /// identifies the resource. + var id: String { get } + + /// A user assigned name that shall be unique within the namespace of + /// the resource category. If the user does not assign a name, this value + /// shall be the same as the system-assigned identifier. + var name: String { get } + + /// The time at which the system created the resource. + var creationDate: Date { get } + + /// Key-value properties for the resource. The user and system may both + /// make use of labels to read and write annotations or other metadata. + /// A good practice when using labels for automation is to use reverse + /// domain name notation (example: `com.example.mytool.role`) for + /// label names. + var labels: ResourceLabels { get } + + /// Generates a unique resource ID value. + static func generateId() -> String + + /// Returns true only if the specified resource name is syntactically valid. + static func nameValid(_ name: String) -> Bool +} + +extension ManagedResource { + /// Generate a random identifier that has the format of an ASCII SHA-256 hash. + public static func generateId() -> String { + (0..<2) + .map { _ in UInt128.random(in: 0...UInt128.max) } + .map { String($0, radix: 16).padding(toLength: 32, withPad: "0", startingAt: 0) } + .joined() + } +} + +extension ResourceLabels { + /// Returns true if for a resource that the system automatically manages. + public var isBuiltin: Bool { self.contains { $0 == ResourceLabelKeys.role && $1 == ResourceRoleValues.builtin } } +} diff --git a/Sources/ContainerResource/Common/ResourceLabels.swift b/Sources/ContainerResource/Common/ResourceLabels.swift new file mode 100644 index 0000000..b02564a --- /dev/null +++ b/Sources/ContainerResource/Common/ResourceLabels.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// 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 OrderedCollections + +/// Metadata for a managed resource. +public struct ResourceLabels: Sendable, Equatable { + public static let keyLengthMax = 128 + + public static let labelLengthMax = 4096 + + public let dictionary: [String: String] + + public struct LabelError: AppError { + public var code: AppErrorCode + + public var metadata: OrderedDictionary + + public var underlyingError: (any Error)? { nil } + } + + public init() { + dictionary = [:] + } + + public init(_ labels: [String: String]) throws { + for (key, value) in labels { + try Self.validateLabel(key: key, value: value) + } + self.dictionary = labels + } + + public static func validateLabelKey(_ key: String) throws { + guard key.count <= Self.keyLengthMax else { + throw LabelError(code: .invalidLabelKeyLength, metadata: ["key": key, "maxLength": "\(Self.keyLengthMax)"]) + } + let dockerPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*$/# + let ociPattern = #/^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*(?:/(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]*[a-z0-9])?)*))*$/# + let dockerMatch = !key.ranges(of: dockerPattern).isEmpty + let ociMatch = !key.ranges(of: ociPattern).isEmpty + guard dockerMatch || ociMatch else { + throw LabelError(code: .invalidLabelKeyContent, metadata: ["key": key]) + } + } + + public static func validateLabel(key: String, value: String) throws { + try validateLabelKey(key) + let fullLabel = "\(key)=\(value)" + guard fullLabel.count <= labelLengthMax else { + throw LabelError(code: .invalidLabelLength, metadata: ["label": fullLabel, "maxLength": "\(Self.labelLengthMax)"]) + } + } +} + +extension ResourceLabels: Codable { + public func encode(to encoder: Encoder) throws { + try dictionary.encode(to: encoder) + } + + public init(from decoder: Decoder) throws { + let dict = try [String: String](from: decoder) + try self.init(dict) + } +} + +extension ResourceLabels: Collection { + public typealias Index = Dictionary.Index + public typealias Element = Dictionary.Element + + public var startIndex: Index { dictionary.startIndex } + public var endIndex: Index { dictionary.endIndex } + + public subscript(position: Index) -> Element { dictionary[position] } + public func index(after i: Index) -> Index { dictionary.index(after: i) } + + // Direct key access + public subscript(key: String) -> String? { + get { dictionary[key] } + } +} + +extension AppErrorCode { + public static let invalidLabelKeyContent = AppErrorCode(rawValue: "invalid_label_key_content") + public static let invalidLabelKeyLength = AppErrorCode(rawValue: "invalid_label_key_length") + public static let invalidLabelLength = AppErrorCode(rawValue: "invalid_label_length") +} + +/// System-defined keys for resource labels. +public struct ResourceLabelKeys { + /// Indicates a owner of a resource managed by a plugin. + public static let plugin = "com.apple.container.plugin" + + /// Indicates a resource with a reserved or dedicated purpose. + public static let role = "com.apple.container.resource.role" +} + +/// System-defined values for resource the resource role label. +public struct ResourceRoleValues { + /// Indicates a container that can build images. + public static let builder = "builder" + + /// Indicates a system-created resource that cannot be deleted by the user. + public static let builtin = "builtin" +} diff --git a/Sources/ContainerResource/Container/Bundle.swift b/Sources/ContainerResource/Container/Bundle.swift new file mode 100644 index 0000000..217531b --- /dev/null +++ b/Sources/ContainerResource/Container/Bundle.swift @@ -0,0 +1,174 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation + +public struct Bundle: Sendable { + private static let initfsFilename = "initfs.ext4" + private static let kernelFilename = "kernel.json" + private static let kernelBinaryFilename = "kernel.bin" + private static let containerRootFsBlockFilename = "rootfs.ext4" + private static let containerRootFsFilename = "rootfs.json" + + static let containerConfigFilename = "config.json" + + /// The path to the bundle. + public let path: URL + + public init(path: URL) { + self.path = path + } + + public var bootlog: URL { + self.path.appendingPathComponent("vminitd.log") + } + + public var containerRootfsBlock: URL { + self.path.appendingPathComponent(Self.containerRootFsBlockFilename) + } + + private var containerRootfsConfig: URL { + self.path.appendingPathComponent(Self.containerRootFsFilename) + } + + public var containerRootfs: Filesystem { + get throws { + let data = try Data(contentsOf: containerRootfsConfig) + let fs = try JSONDecoder().decode(Filesystem.self, from: data) + return fs + } + } + + /// Return the initial filesystem for a sandbox. + public var initialFilesystem: Filesystem { + .block( + format: "ext4", + source: self.path.appendingPathComponent(Self.initfsFilename).path, + destination: "/", + options: ["ro"] + ) + } + + public var kernel: Kernel { + get throws { + try load(path: self.path.appendingPathComponent(Self.kernelFilename)) + } + } + + public var configuration: ContainerConfiguration { + get throws { + try load(path: self.path.appendingPathComponent(Self.containerConfigFilename)) + } + } +} + +extension Bundle { + public static func create( + path: URL, + initialFilesystem: Filesystem, + kernel: Kernel, + containerConfiguration: ContainerConfiguration? = nil, + containerRootFilesystem: Filesystem? = nil, + options: ContainerCreateOptions? = nil + ) throws -> Bundle { + try FileManager.default.createDirectory(at: path, withIntermediateDirectories: true) + let kbin = path.appendingPathComponent(Self.kernelBinaryFilename) + try FileManager.default.copyItem(at: kernel.path, to: kbin) + var k = kernel + k.path = kbin + try write(path.appendingPathComponent(Self.kernelFilename), value: k) + + switch initialFilesystem.type { + case .block(let fmt, _, _): + guard fmt == "ext4" else { + fatalError("ext4 is the only supported format for initial filesystem") + } + // when saving the Initial Filesystem to the bundle + // discard any filesystem information and just persist + // the block into the Bundle. + _ = try initialFilesystem.clone(to: path.appendingPathComponent(Self.initfsFilename).path) + default: + fatalError("invalid filesystem type for initial filesystem") + } + let bundle = Bundle(path: path) + if let containerConfiguration { + try bundle.write(filename: Self.containerConfigFilename, value: containerConfiguration) + } + + if let rootFsOverride = options?.rootFsOverride { + try bundle.setContainerRootFs(fs: rootFsOverride) + } else if let containerRootFilesystem { + let readonly = containerConfiguration?.readOnly ?? false + try bundle.cloneContainerRootFs(cloning: containerRootFilesystem, readonly: readonly) + } + + if let options { + try bundle.write(filename: "options.json", value: options) + } + return bundle + } +} + +extension Bundle { + /// Set the value of the configuration for the Bundle. + public func set(configuration: ContainerConfiguration) throws { + try write(filename: Self.containerConfigFilename, value: configuration) + } + + /// Return the full filepath for a named resource in the Bundle. + public func filePath(for name: String) -> URL { + path.appendingPathComponent(name) + } + + public func setContainerRootFs(fs: Filesystem) throws { + let fsData = try JSONEncoder().encode(fs) + try fsData.write(to: self.containerRootfsConfig) + } + + public func cloneContainerRootFs(cloning fs: Filesystem, readonly: Bool = false) throws { + var mutableFs = fs + if readonly && !mutableFs.options.contains("ro") { + mutableFs.options.append("ro") + } + let cloned = try mutableFs.clone(to: self.containerRootfsBlock.absolutePath()) + try setContainerRootFs(fs: cloned) + } + + /// Delete the bundle and all of the resources contained inside. + public func delete() throws { + try FileManager.default.removeItem(at: self.path) + } + + public func write(filename: String, value: Encodable) throws { + try Self.write(self.path.appendingPathComponent(filename), value: value) + } + + private static func write(_ path: URL, value: Encodable) throws { + let data = try JSONEncoder().encode(value) + try data.write(to: path) + } + + public func load(filename: String) throws -> T where T: Decodable { + try load(path: self.path.appendingPathComponent(filename)) + } + + private func load(path: URL) throws -> T where T: Decodable { + let data = try Data(contentsOf: path) + return try JSONDecoder().decode(T.self, from: data) + } +} diff --git a/Sources/ContainerResource/Container/ContainerConfiguration.swift b/Sources/ContainerResource/Container/ContainerConfiguration.swift new file mode 100644 index 0000000..b1f2349 --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerConfiguration.swift @@ -0,0 +1,182 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import Foundation + +public struct ContainerConfiguration: Sendable, Codable { + /// Identifier for the container. + public var id: String + /// Image used to create the container. + public var image: ImageDescription + /// External mounts to add to the container. + public var mounts: [Filesystem] = [] + /// Ports to publish from container to host. + public var publishedPorts: [PublishPort] = [] + /// Sockets to publish from container to host. + public var publishedSockets: [PublishSocket] = [] + /// Key/Value labels for the container. + public var labels: [String: String] = [:] + /// System controls for the container. + public var sysctls: [String: String] = [:] + /// The networks the container will be added to. + public var networks: [AttachmentConfiguration] = [] + /// The DNS configuration for the container. + public var dns: DNSConfiguration? = nil + /// Whether to enable rosetta x86-64 translation for the container. + public var rosetta: Bool = false + /// Initial or main process of the container. + public var initProcess: ProcessConfiguration + /// Platform for the container. + public var platform: ContainerizationOCI.Platform = .current + /// Resource values for the container. + public var resources: Resources = .init() + /// Name of the runtime that supports the container. + public var runtimeHandler: String = "container-runtime-linux" + /// Configure exposing virtualization support in the container. + public var virtualization: Bool = false + /// Enable SSH agent socket forwarding from host to container. + public var ssh: Bool = false + /// Whether to mount the rootfs as read-only. + public var readOnly: Bool = false + /// Whether to use a minimal init process inside the container. + public var useInit: Bool = false + /// Linux capabilities to add (normalized CAP_* strings, or "ALL"). + public var capAdd: [String] = [] + /// Linux capabilities to drop (normalized CAP_* strings, or "ALL"). + public var capDrop: [String] = [] + /// Size of /dev/shm in bytes. When nil, the default size is used. + public var shmSize: UInt64? + /// Signal to send to the container process on stop (from image config). + public var stopSignal: String? + /// The time at which the container was created. + public var creationDate: Date = Date() + + enum CodingKeys: String, CodingKey { + case id + case image + case mounts + case publishedPorts + case publishedSockets + case labels + case sysctls + case networks + case dns + case rosetta + case initProcess + case platform + case resources + case runtimeHandler + case virtualization + case ssh + case readOnly + case useInit + case capAdd + case capDrop + case shmSize + case stopSignal + case creationDate + } + + /// Create a configuration from the supplied Decoder, initializing missing + /// values where possible to reasonable defaults. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + id = try container.decode(String.self, forKey: .id) + image = try container.decode(ImageDescription.self, forKey: .image) + mounts = try container.decodeIfPresent([Filesystem].self, forKey: .mounts) ?? [] + publishedPorts = try container.decodeIfPresent([PublishPort].self, forKey: .publishedPorts) ?? [] + publishedSockets = try container.decodeIfPresent([PublishSocket].self, forKey: .publishedSockets) ?? [] + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] + sysctls = try container.decodeIfPresent([String: String].self, forKey: .sysctls) ?? [:] + + if container.contains(.networks) { + networks = try container.decode([AttachmentConfiguration].self, forKey: .networks) + } else { + networks = [] + } + + dns = try container.decodeIfPresent(DNSConfiguration.self, forKey: .dns) + rosetta = try container.decodeIfPresent(Bool.self, forKey: .rosetta) ?? false + initProcess = try container.decode(ProcessConfiguration.self, forKey: .initProcess) + platform = try container.decodeIfPresent(ContainerizationOCI.Platform.self, forKey: .platform) ?? .current + resources = try container.decodeIfPresent(Resources.self, forKey: .resources) ?? .init() + runtimeHandler = try container.decodeIfPresent(String.self, forKey: .runtimeHandler) ?? "container-runtime-linux" + virtualization = try container.decodeIfPresent(Bool.self, forKey: .virtualization) ?? false + ssh = try container.decodeIfPresent(Bool.self, forKey: .ssh) ?? false + readOnly = try container.decodeIfPresent(Bool.self, forKey: .readOnly) ?? false + useInit = try container.decodeIfPresent(Bool.self, forKey: .useInit) ?? false + capAdd = try container.decodeIfPresent([String].self, forKey: .capAdd) ?? [] + capDrop = try container.decodeIfPresent([String].self, forKey: .capDrop) ?? [] + shmSize = try container.decodeIfPresent(UInt64.self, forKey: .shmSize) + stopSignal = try container.decodeIfPresent(String.self, forKey: .stopSignal) + creationDate = try container.decodeIfPresent(Date.self, forKey: .creationDate) ?? Date(timeIntervalSince1970: 0) + } + + public struct DNSConfiguration: Sendable, Codable { + public static let defaultNameservers = ["1.1.1.1"] + + public let nameservers: [String] + public let domain: String? + public let searchDomains: [String] + public let options: [String] + + public init( + nameservers: [String] = defaultNameservers, + domain: String? = nil, + searchDomains: [String] = [], + options: [String] = [] + ) { + self.nameservers = nameservers + self.domain = domain + self.searchDomains = searchDomains + self.options = options + } + } + + /// Resources like cpu, memory, and storage quota. + public struct Resources: Sendable, Codable { + /// Number of CPU cores allocated. + public var cpus: Int = 4 + /// Memory in bytes allocated. + public var memoryInBytes: UInt64 = 1024.mib() + /// Storage quota/size in bytes. + public var storage: UInt64? + /// Additional CPU cores allocated for VM overhead (guest agent, etc). + public var cpuOverhead: Int = 1 + + public init() {} + + public init(from decoder: any Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.cpus = try c.decodeIfPresent(Int.self, forKey: .cpus) ?? 4 + self.memoryInBytes = try c.decodeIfPresent(UInt64.self, forKey: .memoryInBytes) ?? 1024.mib() + self.storage = try c.decodeIfPresent(UInt64.self, forKey: .storage) + self.cpuOverhead = try c.decodeIfPresent(Int.self, forKey: .cpuOverhead) ?? 1 + } + } + + public init( + id: String, + image: ImageDescription, + process: ProcessConfiguration + ) { + self.id = id + self.image = image + self.initProcess = process + } +} diff --git a/Sources/ContainerResource/Container/ContainerCreateOptions.swift b/Sources/ContainerResource/Container/ContainerCreateOptions.swift new file mode 100644 index 0000000..fe75577 --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerCreateOptions.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public struct ContainerCreateOptions: Codable, Sendable { + /// Remove the container and wipe out its data on container stop + public let autoRemove: Bool + /// Override the rootFs with this one other than the image-cloned version + public let rootFsOverride: Filesystem? + + public init(autoRemove: Bool, rootFsOverride: Filesystem? = nil) { + self.autoRemove = autoRemove + self.rootFsOverride = rootFsOverride + } + + public static let `default` = ContainerCreateOptions(autoRemove: false) + +} diff --git a/Sources/ContainerResource/Container/ContainerListFilters.swift b/Sources/ContainerResource/Container/ContainerListFilters.swift new file mode 100644 index 0000000..eee3518 --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerListFilters.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Filters for listing containers. +public struct ContainerListFilters: Sendable, Codable { + public static func exclude(_ str: String) -> String { + "^(?!\(str)$)" + } + + /// Filter by container IDs. If non-empty, only containers with matching IDs are returned. + public var ids: [String] + /// Filter by container status. + public var status: RuntimeStatus? + /// Filter by labels. All specified labels must match. Values are treated as regular expressions + /// matched against the container's label value. If a container does not have the specified key, + /// the value is treated as an empty string. This means a positive pattern (e.g. ``^b$``) will + /// exclude containers without the label, while a negation pattern (e.g. ``^(?!b$)``) will + /// include them. + public var labels: [String: String] + + /// No filters applied. Will return all containers. + public static let all = ContainerListFilters() + + public init( + ids: [String] = [], + status: RuntimeStatus? = nil, + labels: [String: String] = [:] + ) { + self.ids = ids + self.status = status + self.labels = labels + } +} + +extension ContainerListFilters { + public func withoutMachines() -> ContainerListFilters { + let labels = self.labels.merging([ResourceLabelKeys.plugin: Self.exclude("machine")]) { _, new in new } + return ContainerListFilters(ids: self.ids, status: self.status, labels: labels) + } +} diff --git a/Sources/ContainerResource/Container/ContainerSnapshot.swift b/Sources/ContainerResource/Container/ContainerSnapshot.swift new file mode 100644 index 0000000..bae9924 --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerSnapshot.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import Foundation + +/// A snapshot of a container along with its configuration +/// and any runtime state information. +public struct ContainerSnapshot: Codable, Sendable { + /// The configuration of the container. + public var configuration: ContainerConfiguration + + /// Identifier of the container. + public var id: String { + configuration.id + } + + /// Configured platform for the container. + public var platform: ContainerizationOCI.Platform { + configuration.platform + } + + /// The runtime status of the container. + public var status: RuntimeStatus + /// Network interfaces attached to the sandbox that are provided to the container. + public var networks: [Attachment] + /// When the container was started. + public var startedDate: Date? + + public init( + configuration: ContainerConfiguration, + status: RuntimeStatus, + networks: [Attachment], + startedDate: Date? = nil + ) { + self.configuration = configuration + self.status = status + self.networks = networks + self.startedDate = startedDate + } +} diff --git a/Sources/ContainerResource/Container/ContainerStats.swift b/Sources/ContainerResource/Container/ContainerStats.swift new file mode 100644 index 0000000..e0f9eab --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerStats.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Statistics for a container suitable for CLI display. +public struct ContainerStats: Sendable, Codable { + /// Container ID + public var id: String + /// Physical memory usage in bytes + public var memoryUsageBytes: UInt64? + /// Memory limit in bytes + public var memoryLimitBytes: UInt64? + /// CPU usage in microseconds + public var cpuUsageUsec: UInt64? + /// Network received bytes (sum of all interfaces) + public var networkRxBytes: UInt64? + /// Network transmitted bytes (sum of all interfaces) + public var networkTxBytes: UInt64? + /// Block I/O read bytes (sum of all devices) + public var blockReadBytes: UInt64? + /// Block I/O write bytes (sum of all devices) + public var blockWriteBytes: UInt64? + /// Number of processes in the container + public var numProcesses: UInt64? + + public init( + id: String, + memoryUsageBytes: UInt64?, + memoryLimitBytes: UInt64?, + cpuUsageUsec: UInt64?, + networkRxBytes: UInt64?, + networkTxBytes: UInt64?, + blockReadBytes: UInt64?, + blockWriteBytes: UInt64?, + numProcesses: UInt64? + ) { + self.id = id + self.memoryUsageBytes = memoryUsageBytes + self.memoryLimitBytes = memoryLimitBytes + self.cpuUsageUsec = cpuUsageUsec + self.networkRxBytes = networkRxBytes + self.networkTxBytes = networkTxBytes + self.blockReadBytes = blockReadBytes + self.blockWriteBytes = blockWriteBytes + self.numProcesses = numProcesses + } +} diff --git a/Sources/ContainerResource/Container/ContainerStatus.swift b/Sources/ContainerResource/Container/ContainerStatus.swift new file mode 100644 index 0000000..791f61b --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerStatus.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// The runtime status of a container. Identity-free: the container's id lives +/// on its ``ContainerConfiguration``. +public struct ContainerStatus: Codable, Sendable { + /// The state-machine value for the container (running, stopped, …). + public let state: RuntimeStatus + /// Network attachments provided to the container. + public let networks: [Attachment] + /// When the container was started, if it has been. + public let startedDate: Date? + + public init(state: RuntimeStatus, networks: [Attachment], startedDate: Date? = nil) { + self.state = state + self.networks = networks + self.startedDate = startedDate + } +} diff --git a/Sources/ContainerResource/Container/ContainerStopOptions.swift b/Sources/ContainerResource/Container/ContainerStopOptions.swift new file mode 100644 index 0000000..e87f6ad --- /dev/null +++ b/Sources/ContainerResource/Container/ContainerStopOptions.swift @@ -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 Foundation + +public struct ContainerStopOptions: Sendable, Codable { + public var timeoutInSeconds: Int32 + public var signal: String? + + public static let `default` = ContainerStopOptions( + timeoutInSeconds: 5, + signal: nil + ) + + public init(timeoutInSeconds: Int32, signal: String?) { + self.timeoutInSeconds = timeoutInSeconds + self.signal = signal + } +} diff --git a/Sources/ContainerResource/Container/Filesystem.swift b/Sources/ContainerResource/Container/Filesystem.swift new file mode 100644 index 0000000..445a876 --- /dev/null +++ b/Sources/ContainerResource/Container/Filesystem.swift @@ -0,0 +1,195 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SystemPackage + +/// Options to pass to a mount call. +public typealias MountOptions = [String] + +extension MountOptions { + /// Returns true if the Filesystem should be consumed as read-only. + public var readonly: Bool { + self.contains("ro") + } +} + +/// A host filesystem that will be attached to the sandbox for use. +/// +/// A filesystem will be mounted automatically when starting the sandbox +/// or container. +public struct Filesystem: Sendable, Codable { + /// Type of caching to perform at the host level. + public enum CacheMode: Sendable, Codable { + case on + case off + case auto + } + + /// Sync mode to perform at the host level. + public enum SyncMode: Sendable, Codable { + case full + case fsync + case nosync + } + + /// The type of filesystem attachment for the sandbox. + public enum FSType: Sendable, Codable, Equatable { + package enum VirtiofsType: String, Sendable, Codable, Equatable { + // This is a virtiofs share for the rootfs of a sandbox. + case rootfs + // Data share. This is what all virtiofs shares for anything besides + // the rootfs for a sandbox will be. + case data + } + + case block(format: String, cache: CacheMode, sync: SyncMode) + case volume(name: String, format: String, cache: CacheMode, sync: SyncMode) + case virtiofs + case tmpfs + } + + /// Type of the filesystem. + public var type: FSType + /// Source of the filesystem. + public var source: String + /// Destination where the filesystem should be mounted. + public var destination: String + /// Mount options applied when mounting the filesystem. + public var options: MountOptions + + public init() { + self.type = .tmpfs + self.source = "" + self.destination = "" + self.options = [] + } + + public init(type: FSType, source: String, destination: String, options: MountOptions) { + self.type = type + self.source = source + self.destination = destination + self.options = options + } + + // Defaulting to CachedMode = .on (i.e., cached mode) to fix Linux FS issue when using Virtualization + // * https://github.com/apple/container/issues/614 + // * https://github.com/utmapp/UTM/pull/5919 + + /// A block based filesystem. + public static func block( + format: String, source: String, destination: String, options: MountOptions, cache: CacheMode = .on, + sync: SyncMode = .fsync + ) -> Filesystem { + .init( + type: .block(format: format, cache: cache, sync: sync), + source: absoluteFilePath(for: source).string, + destination: destination, + options: options + ) + } + + /// A named volume filesystem. + public static func volume( + name: String, format: String, source: String, destination: String, options: MountOptions, + cache: CacheMode = .on, sync: SyncMode = .fsync + ) -> Filesystem { + .init( + type: .volume(name: name, format: format, cache: cache, sync: sync), + source: absoluteFilePath(for: source).string, + destination: destination, + options: options + ) + } + + /// A vritiofs backed filesystem providing a directory. + public static func virtiofs(source: String, destination: String, options: MountOptions) -> Filesystem { + .init( + type: .virtiofs, + source: absoluteFilePath(for: source).string, + destination: destination, + options: options + ) + } + + public static func tmpfs(destination: String, options: MountOptions) -> Filesystem { + .init( + type: .tmpfs, + source: "tmpfs", + destination: destination, + options: options + ) + } + + /// Returns true if the Filesystem is backed by a block device. + public var isBlock: Bool { + switch type { + case .block(_, _, _): true + case .volume(_, _, _, _): true + default: false + } + } + + /// Returns true if the Filesystem is a named volume. + public var isVolume: Bool { + switch type { + case .volume(_, _, _, _): true + default: false + } + } + + /// Returns the volume name if this is a volume filesystem, nil otherwise. + public var volumeName: String? { + switch type { + case .volume(let name, _, _, _): name + default: nil + } + } + + /// Returns true if the Filesystem is backed by a in-memory mount type. + public var isTmpfs: Bool { + switch type { + case .tmpfs: true + default: false + } + } + + /// Returns true if the Filesystem is backed by virtioFS. + public var isVirtiofs: Bool { + switch type { + case .virtiofs: true + default: false + } + } + + /// Clone the Filesystem to the provided path. + /// + /// This uses `clonefile` to provide a copy-on-write copy of the Filesystem. + public func clone(to: String) throws -> Self { + let fm = FileManager.default + let src = self.source + try fm.copyItem(atPath: src, toPath: to) + return .init(type: self.type, source: to, destination: self.destination, options: self.options) + } +} + +private func absoluteFilePath(for source: String) -> FilePath { + let path = FilePath(source) + guard path.isRelative else { return path.lexicallyNormalized() } + return FilePath(FileManager.default.currentDirectoryPath) + .pushing(path) + .lexicallyNormalized() +} diff --git a/Sources/ContainerResource/Container/ManagedContainer.swift b/Sources/ContainerResource/Container/ManagedContainer.swift new file mode 100644 index 0000000..76e421b --- /dev/null +++ b/Sources/ContainerResource/Container/ManagedContainer.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import Foundation + +/// A container as a managed resource. Wraps the persistent +/// ``ContainerConfiguration`` and a runtime ``ContainerStatus``. +public struct ManagedContainer: ManagedResource { + public let configuration: ContainerConfiguration + public let status: ContainerStatus + + // MARK: ManagedResource + public var id: String { configuration.id } + public var name: String { configuration.id } // containers have no separate name field today + + public var creationDate: Date { configuration.creationDate } + + /// Typed labels for conformance / filtering. Drops labels that fail + /// validation; the raw dictionary is preserved on `configuration.labels` + /// and is what gets serialized. + public var labels: ResourceLabels { (try? ResourceLabels(configuration.labels)) ?? .init() } + + /// Platform passthrough (parity with the former ContainerSnapshot.platform). + public var platform: ContainerizationOCI.Platform { configuration.platform } + + /// Mint ids the way containers actually mint them (lowercased UUID), + /// not the protocol's 64-hex default. + public static func generateId() -> String { UUID().uuidString.lowercased() } + + /// Container name rule (mixed case, dots, hyphens, underscores). Duplicated from + /// Utility.validEntityName + public static func nameValid(_ name: String) -> Bool { + let pattern = #"^[a-zA-Z0-9][a-zA-Z0-9_.-]+$"# + return name.range(of: pattern, options: .regularExpression) != nil + } + + public init(configuration: ContainerConfiguration, status: ContainerStatus) { + self.configuration = configuration + self.status = status + } + + /// CLI-boundary factory: build from the snapshot the client returns today. + public init(_ snapshot: ContainerSnapshot) { + self.configuration = snapshot.configuration + self.status = ContainerStatus( + state: snapshot.status, + networks: snapshot.networks, + startedDate: snapshot.startedDate + ) + } +} + +extension ManagedContainer { + enum CodingKeys: String, CodingKey { case id, configuration, status } + + public func encode(to encoder: Encoder) throws { + var c = encoder.container(keyedBy: CodingKeys.self) + try c.encode(id, forKey: .id) + try c.encode(configuration, forKey: .configuration) + try c.encode(status, forKey: .status) + } + + public init(from decoder: Decoder) throws { + let c = try decoder.container(keyedBy: CodingKeys.self) + self.configuration = try c.decode(ContainerConfiguration.self, forKey: .configuration) + self.status = try c.decode(ContainerStatus.self, forKey: .status) + } +} diff --git a/Sources/ContainerResource/Container/ProcessConfiguration.swift b/Sources/ContainerResource/Container/ProcessConfiguration.swift new file mode 100644 index 0000000..856b0db --- /dev/null +++ b/Sources/ContainerResource/Container/ProcessConfiguration.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Configuration data for an executable Process. +public struct ProcessConfiguration: Sendable, Codable { + /// The on disk path to the executable binary. + public var executable: String + /// Arguments passed to the Process. + public var arguments: [String] + /// Environment variables for the Process. + public var environment: [String] + /// The current working directory (cwd) for the Process. + public var workingDirectory: String + /// A boolean value indicating if a Terminal or PTY device should + /// be attached to the Process's Standard I/O. + public var terminal: Bool + /// The User a Process should execute under. + public var user: User + /// Supplemental groups for the Process. + public var supplementalGroups: [UInt32] + /// Rlimits for the Process. + public var rlimits: [Rlimit] + + /// Rlimits for Processes. + public struct Rlimit: Sendable, Codable { + /// The Rlimit type of the Process. + /// + /// Values include standard Rlimit resource types, i.e. RLIMIT_NPROC, RLIMIT_NOFILE, ... + public let limit: String + /// The soft limit of the Process + public let soft: UInt64 + /// The hard or max limit of the Process. + public let hard: UInt64 + + public init(limit: String, soft: UInt64, hard: UInt64) { + self.limit = limit + self.soft = soft + self.hard = hard + } + } + + /// The User information for a Process. + public enum User: Sendable, Codable, CustomStringConvertible, Equatable { + /// Given the raw user string of the form or or lookup the uid/gid within + /// the container before setting it for the Process. + case raw(userString: String) + /// Set the provided uid/gid for the Process. + case id(uid: UInt32, gid: UInt32) + + public var description: String { + switch self { + case .id(let uid, let gid): + return "\(uid):\(gid)" + case .raw(let name): + return name + } + } + } + + public init( + executable: String, + arguments: [String], + environment: [String], + workingDirectory: String = "/", + terminal: Bool = false, + user: User = .id(uid: 0, gid: 0), + supplementalGroups: [UInt32] = [], + rlimits: [Rlimit] = [] + ) { + self.executable = executable + self.arguments = arguments + self.environment = environment + self.workingDirectory = workingDirectory + self.terminal = terminal + self.user = user + self.supplementalGroups = supplementalGroups + self.rlimits = rlimits + } +} diff --git a/Sources/ContainerResource/Container/PublishPort.swift b/Sources/ContainerResource/Container/PublishPort.swift new file mode 100644 index 0000000..fe6ad00 --- /dev/null +++ b/Sources/ContainerResource/Container/PublishPort.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras + +/// The network protocols available for port forwarding. +public enum PublishProtocol: String, Sendable, Codable { + case tcp = "tcp" + case udp = "udp" + + /// Initialize a protocol with to default value, `.tcp`. + public init() { + self = .tcp + } + + /// Initialize a protocol value from the provided string. + public init?(_ value: String) { + switch value.lowercased() { + case "tcp": self = .tcp + case "udp": self = .udp + default: return nil + } + } +} + +/// Specifies internet port forwarding from host to container. +public struct PublishPort: Sendable, Codable { + /// The IP address of the proxy listener on the host + public let hostAddress: IPAddress + + /// The port number of the proxy listener on the host + public let hostPort: UInt16 + + /// The port number of the container listener + public let containerPort: UInt16 + + /// The network protocol for the proxy + public let proto: PublishProtocol + + /// The number of ports to publish + public let count: UInt16 + + /// Creates a new port forwarding specification. + public init( + hostAddress: IPAddress, + hostPort: UInt16, + containerPort: UInt16, + proto: PublishProtocol, + count: UInt16 + ) throws { + self.hostAddress = hostAddress + self.hostPort = hostPort + self.containerPort = containerPort + self.proto = proto + self.count = count + try validatePortRange(port: hostPort, count: count) + try validatePortRange(port: containerPort, count: count) + } + + /// Create a configuration from the supplied Decoder, initializing missing + /// values where possible to reasonable defaults. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + hostAddress = try container.decode(IPAddress.self, forKey: .hostAddress) + hostPort = try container.decode(UInt16.self, forKey: .hostPort) + containerPort = try container.decode(UInt16.self, forKey: .containerPort) + proto = try container.decode(PublishProtocol.self, forKey: .proto) + count = try container.decodeIfPresent(UInt16.self, forKey: .count) ?? 1 + try validatePortRange(port: hostPort, count: count) + try validatePortRange(port: containerPort, count: count) + } + + private func validatePortRange(port: UInt16, count: UInt16) throws { + guard count > 0, UInt16.max - port >= count - 1 else { + throw ContainerizationError(.invalidArgument, message: "invalid port and count: \(port), \(count)") + } + } +} + +extension [PublishPort] { + public func hasOverlaps() -> Bool { + var hostPorts = Set() + for publishPort in self { + for offset in 0.., + forKey key: CodingKeys + ) throws -> FilePath { + let raw = try container.decode(String.self, forKey: key) + + let path: String + if raw.hasPrefix("file:") { + guard let url = URL(string: raw), url.isFileURL else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "malformed file URL: \(raw)" + ) + } + if let host = url.host(), !host.isEmpty, host != "localhost" { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "file URL host must be empty or 'localhost': \(raw)" + ) + } + path = url.path(percentEncoded: false) + } else { + path = raw + } + + guard !path.isEmpty else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "decoded socket path is empty: \(raw)" + ) + } + + let filePath = FilePath(path) + guard filePath.isAbsolute else { + throw DecodingError.dataCorruptedError( + forKey: key, + in: container, + debugDescription: "decoded socket path must be absolute: \(raw)" + ) + } + + return filePath + } +} diff --git a/Sources/ContainerResource/Container/RuntimeStatus.swift b/Sources/ContainerResource/Container/RuntimeStatus.swift new file mode 100644 index 0000000..8890073 --- /dev/null +++ b/Sources/ContainerResource/Container/RuntimeStatus.swift @@ -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. +//===----------------------------------------------------------------------===// + +import Foundation + +/// Runtime status for a sandbox or container. +public enum RuntimeStatus: String, CaseIterable, Sendable, Codable { + /// The object is in an unknown status. + case unknown + /// The object is currently stopped. + case stopped + /// The object is currently running. + case running + /// The object is currently stopping. + case stopping +} diff --git a/Sources/ContainerResource/Image/ImageDescription.swift b/Sources/ContainerResource/Image/ImageDescription.swift new file mode 100644 index 0000000..a54f73a --- /dev/null +++ b/Sources/ContainerResource/Image/ImageDescription.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI + +/// A type that represents an OCI image that can be used with sandboxes or containers. +public struct ImageDescription: Sendable, Codable { + /// The public reference/name of the image. + public let reference: String + /// The descriptor of the image. + public let descriptor: Descriptor + + public var digest: String { descriptor.digest } + public var mediaType: String { descriptor.mediaType } + + public init(reference: String, descriptor: Descriptor) { + self.reference = reference + self.descriptor = descriptor + } +} diff --git a/Sources/ContainerResource/Image/ImageResource.swift b/Sources/ContainerResource/Image/ImageResource.swift new file mode 100644 index 0000000..b2c7e97 --- /dev/null +++ b/Sources/ContainerResource/Image/ImageResource.swift @@ -0,0 +1,133 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import Foundation + +/// An image resource, representing an OCI image managed by the system. +/// +/// `ImageResource` conforms to `ManagedResource` and wraps the image's +/// ``ImageDescription`` (its reference and index descriptor) alongside the +/// resolved index descriptor and the per-platform variants that make up the +/// image. +public struct ImageResource: ManagedResource { + /// A single platform-specific variant of an image. + public struct Variant: Sendable, Codable { + /// The platform this variant targets. + public let platform: Platform + /// The digest of this variant's manifest. + public let digest: String + /// The total size of this variant in bytes. + public let size: Int64 + /// The OCI image config for this variant. + public let config: ContainerizationOCI.Image + + public init(platform: Platform, digest: String, size: Int64, config: ContainerizationOCI.Image) { + self.platform = platform + self.digest = digest + self.size = size + self.config = config + } + } + + public struct ImageConfiguration: Sendable, Codable { + public let creationDate: Date + public var name: String + public var descriptor: Descriptor + + public init(description: ImageDescription, creationDate: Date) { + self.creationDate = creationDate + self.name = description.reference + self.descriptor = description.descriptor + } + } + + /// The image's description — its reference and index descriptor. + public let configuration: ImageConfiguration + + /// The platform-specific variants contained in the image. + public let variants: [Variant] + + /// The reference to show in human-facing listings, with default-registry + /// information removed (e.g. `alpine` rather than `docker.io/library/alpine`). + /// Computed by the caller, which has access to the system configuration. + /// Defaults to the full ``name`` when not supplied. + public let displayReference: String + + // MARK: ManagedResource + + /// The scheme-specific value of `configuration.descriptor.digest` (the hex portion + /// after the algorithm prefix). The fully-qualified digest — needed for content-store + /// lookups and XPC transport — is always recoverable as `configuration.descriptor.digest`. + public var id: String { + let digest = configuration.descriptor.digest + guard let colonIndex = digest.firstIndex(of: ":") else { return digest } + return String(digest[digest.index(after: colonIndex)...]) + } + + /// The user-facing reference (`name:tag`) for this image. + public var name: String { configuration.name } + + /// The time at which the image was created, resolved from the OCI image + /// config. Falls back to the Unix epoch when no creation date is recorded. + public var creationDate: Date { configuration.creationDate } + + /// Key-value labels for this image, derived from the index descriptor's + /// annotations. Returns an empty label set if the annotations fail + /// ``ResourceLabels`` validation. + public var labels: ResourceLabels { + (try? ResourceLabels(configuration.descriptor.annotations ?? [:])) ?? ResourceLabels() + } + + // MARK: Initialization + public init(configuration: ImageConfiguration, variants: [Variant], displayReference: String? = nil) { + self.configuration = configuration + self.variants = variants + self.displayReference = displayReference ?? configuration.name + } +} + +extension ImageResource { + /// Returns `true` if `name` is a syntactically valid image reference. + public static func nameValid(_ name: String) -> Bool { + (try? Reference.parse(name)) != nil + } +} + +// MARK: - Codable + +extension ImageResource { + enum CodingKeys: String, CodingKey { + case id + case configuration + case variants + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(configuration, forKey: .configuration) + try container.encode(variants, forKey: .variants) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.configuration = try container.decode(ImageConfiguration.self, forKey: .configuration) + self.variants = try container.decode([Variant].self, forKey: .variants) + // `displayReference` is a display-only value and is not serialized. + self.displayReference = self.configuration.name + } +} diff --git a/Sources/ContainerResource/Network/Attachment.swift b/Sources/ContainerResource/Network/Attachment.swift new file mode 100644 index 0000000..a6351ab --- /dev/null +++ b/Sources/ContainerResource/Network/Attachment.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras + +/// A snapshot of a network interface for a sandbox. +public struct Attachment: Codable, Sendable { + /// The network ID associated with the attachment. + public let network: String + /// The hostname associated with the attachment. + public let hostname: String + /// The CIDR address describing the interface IPv4 address, with the prefix length of the subnet. + public let ipv4Address: CIDRv4 + /// The IPv4 gateway address. + public let ipv4Gateway: IPv4Address + /// The CIDR address describing the interface IPv6 address, with the prefix length of the subnet. + /// The address is nil if the IPv6 subnet could not be determined at network creation time. + public let ipv6Address: CIDRv6? + /// The MAC address associated with the attachment (optional). + public let macAddress: MACAddress? + /// The MTU for the network interface. + public let mtu: UInt32? + /// The network plugin variant, used by the runtime to select an interface strategy. + public let variant: String? + + public init( + network: String, + hostname: String, + ipv4Address: CIDRv4, + ipv4Gateway: IPv4Address, + ipv6Address: CIDRv6?, + macAddress: MACAddress?, + mtu: UInt32? = nil, + variant: String? = nil + ) { + self.network = network + self.hostname = hostname + self.ipv4Address = ipv4Address + self.ipv4Gateway = ipv4Gateway + self.ipv6Address = ipv6Address + self.macAddress = macAddress + self.mtu = mtu + self.variant = variant + } + + enum CodingKeys: String, CodingKey { + case network + case hostname + case ipv4Address + case ipv4Gateway + case ipv6Address + case macAddress + case mtu + case variant + // TODO: retain for deserialization compatibility for now, remove later + case address + case gateway + } + + /// Create a configuration from the supplied Decoder, initializing missing + /// values where possible to reasonable defaults. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + network = try container.decode(String.self, forKey: .network) + hostname = try container.decode(String.self, forKey: .hostname) + if let address = try? container.decode(CIDRv4.self, forKey: .ipv4Address) { + ipv4Address = address + } else { + ipv4Address = try container.decode(CIDRv4.self, forKey: .address) + } + if let gateway = try? container.decode(IPv4Address.self, forKey: .ipv4Gateway) { + ipv4Gateway = gateway + } else { + ipv4Gateway = try container.decode(IPv4Address.self, forKey: .gateway) + } + ipv6Address = try container.decodeIfPresent(CIDRv6.self, forKey: .ipv6Address) + macAddress = try container.decodeIfPresent(MACAddress.self, forKey: .macAddress) + mtu = try container.decodeIfPresent(UInt32.self, forKey: .mtu) + variant = try container.decodeIfPresent(String.self, forKey: .variant) + } + + /// Encode the configuration to the supplied Encoder. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(network, forKey: .network) + try container.encode(hostname, forKey: .hostname) + try container.encode(ipv4Address, forKey: .ipv4Address) + try container.encode(ipv4Gateway, forKey: .ipv4Gateway) + try container.encodeIfPresent(ipv6Address, forKey: .ipv6Address) + try container.encodeIfPresent(macAddress, forKey: .macAddress) + try container.encodeIfPresent(mtu, forKey: .mtu) + try container.encodeIfPresent(variant, forKey: .variant) + } +} diff --git a/Sources/ContainerResource/Network/AttachmentConfiguration.swift b/Sources/ContainerResource/Network/AttachmentConfiguration.swift new file mode 100644 index 0000000..d7e3dff --- /dev/null +++ b/Sources/ContainerResource/Network/AttachmentConfiguration.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras + +/// Configuration information for attaching a container network interface to a network. +public struct AttachmentConfiguration: Codable, Sendable { + /// The network ID associated with the attachment. + public let network: String + + /// The option information for the attachment + public let options: AttachmentOptions + + public init(network: String, options: AttachmentOptions) { + self.network = network + self.options = options + } +} + +// Option information for a network attachment. +public struct AttachmentOptions: Codable, Sendable { + /// The hostname associated with the attachment. + public let hostname: String + + /// The MAC address associated with the attachment (optional). + public let macAddress: MACAddress? + + /// The MTU for the network interface. + public let mtu: UInt32? + + public init(hostname: String, macAddress: MACAddress? = nil, mtu: UInt32? = nil) { + self.hostname = hostname + self.macAddress = macAddress + self.mtu = mtu + } +} diff --git a/Sources/ContainerResource/Network/NetworkConfiguration.swift b/Sources/ContainerResource/Network/NetworkConfiguration.swift new file mode 100644 index 0000000..7678c47 --- /dev/null +++ b/Sources/ContainerResource/Network/NetworkConfiguration.swift @@ -0,0 +1,151 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Foundation + +/// Configuration parameters for network creation. +public struct NetworkConfiguration: Codable, Sendable, Identifiable { + /// The name of the network. + public let name: String + + /// The unique identifier for the network. Identical to ``name``. + public var id: String { name } + + /// The network type + public let mode: NetworkMode + + /// When the network was created. + public let creationDate: Date + + /// The preferred CIDR address for the IPv4 subnet, if specified + public let ipv4Subnet: CIDRv4? + + /// The preferred CIDR address for the IPv6 subnet, if specified + public let ipv6Subnet: CIDRv6? + + /// Key-value labels for the network. + /// Resource labels should not be mutated, except while building a network configurations. + public let labels: ResourceLabels + + /// The network plugin that manages this network. + public let plugin: String + + /// Plugin-specific options for this network. + public let options: [String: String] + + /// Creates a network configuration + public init( + name: String, + mode: NetworkMode, + ipv4Subnet: CIDRv4? = nil, + ipv6Subnet: CIDRv6? = nil, + labels: ResourceLabels = .init(), + plugin: String, + options: [String: String] = [:] + ) throws { + self.name = name + self.creationDate = Date() + self.mode = mode + self.ipv4Subnet = ipv4Subnet + self.ipv6Subnet = ipv6Subnet + self.labels = labels + self.plugin = plugin + self.options = options + try validate() + } + + enum CodingKeys: String, CodingKey { + case name + // Deprecated: As of 1.0.0. Use ``name`` instead of ``id``. + // Note: Will be removed in a later release. + case id + case creationDate + case mode + case ipv4Subnet + case ipv6Subnet + case labels + case plugin + case options + // TODO: retain for deserialization compatibility, remove in next major version + case pluginInfo + case subnet + } + + /// Create a configuration from the supplied Decoder, initializing missing + /// values where possible to reasonable defaults. + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + name = + try container.decodeIfPresent(String.self, forKey: .name) + ?? container.decode(String.self, forKey: .id) + creationDate = try container.decodeIfPresent(Date.self, forKey: .creationDate) ?? Date(timeIntervalSince1970: 0) + mode = try container.decode(NetworkMode.self, forKey: .mode) + let subnetText = + try container.decodeIfPresent(String.self, forKey: .ipv4Subnet) + ?? container.decodeIfPresent(String.self, forKey: .subnet) + ipv4Subnet = try subnetText.map { try CIDRv4($0) } + ipv6Subnet = try container.decodeIfPresent(String.self, forKey: .ipv6Subnet) + .map { try CIDRv6($0) } + let decodedLabels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] + labels = try .init(decodedLabels) + + if let plugin = try container.decodeIfPresent(String.self, forKey: .plugin) { + self.plugin = plugin + self.options = try container.decodeIfPresent([String: String].self, forKey: .options) ?? [:] + } else if let legacy = try container.decodeIfPresent(_LegacyPluginInfo.self, forKey: .pluginInfo) { + // Deprecated: As of 1.0.0. Use ``plugin`` and ``options`` instead. + // Note: Will be removed in a later release. + self.plugin = legacy.plugin + var opts: [String: String] = [:] + if let variant = legacy.variant { opts["variant"] = variant } + self.options = opts + } else { + self.plugin = "container-network-vmnet" + self.options = [:] + } + + try validate() + } + + /// Encode the configuration to the supplied Encoder. + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + + try container.encode(name, forKey: .name) + try container.encode(creationDate, forKey: .creationDate) + try container.encode(mode, forKey: .mode) + try container.encodeIfPresent(ipv4Subnet, forKey: .ipv4Subnet) + try container.encodeIfPresent(ipv6Subnet, forKey: .ipv6Subnet) + try container.encode(labels, forKey: .labels) + try container.encode(plugin, forKey: .plugin) + try container.encode(options, forKey: .options) + } + + private func validate() throws { + guard NetworkResource.nameValid(name) else { + throw ContainerizationError(.invalidArgument, message: "invalid network name: \(name)") + } + } +} + +/// Decode helper for stored configurations that used the old `pluginInfo` key. +private struct _LegacyPluginInfo: Codable { + let plugin: String + let variant: String? +} diff --git a/Sources/ContainerResource/Network/NetworkMode.swift b/Sources/ContainerResource/Network/NetworkMode.swift new file mode 100644 index 0000000..c41ef9c --- /dev/null +++ b/Sources/ContainerResource/Network/NetworkMode.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Networking mode that applies to client containers. +public enum NetworkMode: String, Codable, Sendable { + /// NAT networking mode. + /// Containers do not have routable IPs, and the host performs network + /// address translation to allow containers to reach external services. + case nat = "nat" + + /// Host only networking mode + /// Containers can talk with each other in the same subnet only. + case hostOnly = "hostOnly" +} + +extension NetworkMode { + public init() { + self = .nat + } + + public init?(_ value: String) { + switch value.lowercased() { + case "nat": self = .nat + case "hostOnly": self = .hostOnly + default: return nil + } + } +} diff --git a/Sources/ContainerResource/Network/NetworkResource.swift b/Sources/ContainerResource/Network/NetworkResource.swift new file mode 100644 index 0000000..249c606 --- /dev/null +++ b/Sources/ContainerResource/Network/NetworkResource.swift @@ -0,0 +1,96 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Foundation + +/// A network resource, representing a configured virtual network and its runtime status. +/// +/// `NetworkResource` conforms to `ManagedResource` and separates the network's +/// intrinsic configuration from its runtime status — following the same config/status +/// split used by Kubernetes and Docker. `configuration` is persisted; `status` reflects +/// what the network plugin reports at runtime. +/// +/// JSON encoding produces three top-level keys: `id`, `configuration` (the persistent +/// config), and `status` (runtime address properties assigned by the network plugin). +public struct NetworkResource: ManagedResource { + /// The network's configuration — its persistent, intrinsic properties. + public let configuration: NetworkConfiguration + + /// The network's runtime status — the addresses assigned by the network plugin. + public let status: NetworkStatus + + // MARK: ManagedResource + + /// The unique identifier for this network. Identical to ``configuration/name``. + public var id: String { configuration.name } + + /// The user-assigned name for this network. For networks, name and ID are the same. + public var name: String { configuration.name } + + /// The time at which this network was created. + public var creationDate: Date { configuration.creationDate } + + /// Key-value labels for this network. + public var labels: ResourceLabels { configuration.labels } + + /// Returns `true` for a system-managed network that cannot be deleted by the user. + public var isBuiltin: Bool { labels.isBuiltin } + + /// Returns `true` if `name` is a syntactically valid network identifier. + /// + /// Valid network names are lowercase alphanumeric strings of up to 63 + /// characters, allowing dots, hyphens, and underscores in interior positions. + public static func nameValid(_ name: String) -> Bool { + let pattern = #"^[a-z0-9](?:[a-z0-9._-]{0,61}[a-z0-9])?$"# + return name.range(of: pattern, options: .regularExpression) != nil + } + + // MARK: Initialization + + /// Creates a network resource. + /// + /// - Parameters: + /// - configuration: The network's intrinsic configuration. + /// - status: The runtime status reported by the network plugin. + public init(configuration: NetworkConfiguration, status: NetworkStatus) { + self.configuration = configuration + self.status = status + } +} + +// MARK: - Codable + +extension NetworkResource { + enum CodingKeys: String, CodingKey { + case id + case configuration + case status + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(configuration, forKey: .configuration) + try container.encode(status, forKey: .status) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + configuration = try container.decode(NetworkConfiguration.self, forKey: .configuration) + status = try container.decode(NetworkStatus.self, forKey: .status) + } +} diff --git a/Sources/ContainerResource/Network/NetworkStatus.swift b/Sources/ContainerResource/Network/NetworkStatus.swift new file mode 100644 index 0000000..89164b6 --- /dev/null +++ b/Sources/ContainerResource/Network/NetworkStatus.swift @@ -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 ContainerizationExtras + +/// The runtime status of a network — the addresses assigned once the network +/// plugin is active. Only present after the network has started. +public struct NetworkStatus: Codable, Sendable { + /// The IPv4 subnet assigned to the network. + public let ipv4Subnet: CIDRv4 + + /// The IPv4 gateway address. + public let ipv4Gateway: IPv4Address + + /// The IPv6 subnet assigned to the network, if IPv6 is enabled. + public let ipv6Subnet: CIDRv6? + + public init( + ipv4Subnet: CIDRv4, + ipv4Gateway: IPv4Address, + ipv6Subnet: CIDRv6? + ) { + self.ipv4Subnet = ipv4Subnet + self.ipv4Gateway = ipv4Gateway + self.ipv6Subnet = ipv6Subnet + } +} diff --git a/Sources/ContainerResource/Registry/RegistryResource.swift b/Sources/ContainerResource/Registry/RegistryResource.swift new file mode 100644 index 0000000..d0b9fec --- /dev/null +++ b/Sources/ContainerResource/Registry/RegistryResource.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS +import Foundation + +/// A container registry resource representing a configured registry endpoint. +/// +/// Registry resources store authentication and configuration information for +/// container registries such as Docker Hub, GitHub Container Registry, or +/// private registries. +public struct RegistryResource: ManagedResource { + /// The registry hostname that uniquely identifies this resource. + /// + /// For registry resources, the identifier is the same as the hostname. + public let id: String + + /// The hostname of the registry. + /// + /// This value must be a valid DNS hostname or IPv6 address, optionally + /// followed by a port number (e.g., "docker.io", "localhost:5000", "[::1]:5000"). + public let name: String + + /// The username used for authentication with this registry. + public let username: String + + /// The time at which the system created this registry resource. + public let creationDate: Date + + /// The time at which the registry resource was last modified. + public let modificationDate: Date + + /// Key-value properties for the resource. + /// + /// The user and system may both make use of labels to read and write + /// annotations or other metadata. + public let labels: ResourceLabels + + /// Validates a registry hostname according to OCI distribution specification. + /// + /// This method validates that a registry hostname conforms to the domain pattern + /// used by OCI image references. It supports DNS hostnames, IPv6 addresses, and + /// optional port numbers. + /// + /// - Parameter name: The registry hostname to validate + /// - Returns: `true` if the hostname is syntactically valid, `false` otherwise + /// + /// ## Valid Examples + /// - `docker.io` + /// - `registry.example.com` + /// - `localhost:5000` + /// - `[::1]:5000` + /// + /// ## Implementation Notes + /// The validation logic is based on ContainerizationOCI's `Reference.domainPattern`. + /// See + public static func nameValid(_ name: String) -> Bool { + // Domain validation logic based on ContainerizationOCI Reference.domainPattern + // See: https://github.com/apple/containerization/blob/main/Sources/ContainerizationOCI/Reference.swift + // TODO: if we have domain IP validation API, use that instead + let domainNameComponent = "(?:[a-zA-Z0-9]|[a-zA-Z0-9][a-zA-Z0-9-]*[a-zA-Z0-9])" + let optionalPort = "(?::[0-9]+)?" + let ipv6address = "\\[(?:[a-fA-F0-9:]+)\\]" + let domainName = "\(domainNameComponent)(?:\\.\(domainNameComponent))*" + let host = "(?:\(domainName)|\(ipv6address))" + let pattern = "^\(host)\(optionalPort)$" + + return name.range(of: pattern, options: .regularExpression) != nil + } + + /// Creates a new registry resource. + /// + /// - Parameters: + /// - hostname: The registry hostname (also used as the resource ID) + /// - username: The username for authentication + /// - creationDate: The time the resource was created + /// - modificationDate: The time the resource was last modified + /// - labels: Optional key-value labels for metadata (default: empty dictionary) + public init( + hostname: String, + username: String, + creationDate: Date, + modificationDate: Date, + labels: ResourceLabels = .init() + ) { + self.id = hostname + self.name = hostname + self.username = username + self.creationDate = creationDate + self.modificationDate = modificationDate + self.labels = labels + } +} + +extension RegistryResource { + /// Creates a registry resource from registry information. + /// + /// - Parameter registryInfo: The registry information to convert + public init(from registryInfo: RegistryInfo) { + self.init( + hostname: registryInfo.hostname, + username: registryInfo.username, + creationDate: registryInfo.createdDate, + modificationDate: registryInfo.modifiedDate + ) + } +} diff --git a/Sources/ContainerResource/Volume/VolumeConfiguration.swift b/Sources/ContainerResource/Volume/VolumeConfiguration.swift new file mode 100644 index 0000000..e7c1a66 --- /dev/null +++ b/Sources/ContainerResource/Volume/VolumeConfiguration.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// A named or anonymous volume that can be mounted in containers. +public struct VolumeConfiguration: Sendable, Equatable, Identifiable { + // id of the volume. + public var id: String { name } + // Name of the volume. + public var name: String + // Driver used to create the volume. + public var driver: String + // Filesystem format of the volume. + public var format: String + // The mount point of the volume on the host. + public var source: String + // Timestamp when the volume was created. + public var creationDate: Date + // User-defined key/value metadata. + public var labels: [String: String] + // Driver-specific options. + public var options: [String: String] + // Size of the volume in bytes (optional). + public var sizeInBytes: UInt64? + + public init( + name: String, + driver: String = "local", + format: String = "ext4", + source: String, + creationDate: Date = Date(), + labels: [String: String] = [:], + options: [String: String] = [:], + sizeInBytes: UInt64? = nil + ) { + self.name = name + self.driver = driver + self.format = format + self.source = source + self.creationDate = creationDate + self.labels = labels + self.options = options + self.sizeInBytes = sizeInBytes + } + + enum CodingKeys: String, CodingKey { + case name, driver, format, source, labels, options, sizeInBytes + case creationDate + // TODO: retain for deserialization compatibility, remove in next major version + case createdAt + } +} + +extension VolumeConfiguration: Codable { + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + name = try container.decode(String.self, forKey: .name) + driver = try container.decode(String.self, forKey: .driver) + format = try container.decode(String.self, forKey: .format) + source = try container.decode(String.self, forKey: .source) + // Deprecated: As of 1.0.0. Use ``creationDate`` instead of ``createdAt``. + // Note: Will be removed in a later release. + creationDate = + try container.decodeIfPresent(Date.self, forKey: .creationDate) + ?? container.decodeIfPresent(Date.self, forKey: .createdAt) + ?? Date(timeIntervalSince1970: 0) + labels = try container.decodeIfPresent([String: String].self, forKey: .labels) ?? [:] + options = try container.decodeIfPresent([String: String].self, forKey: .options) ?? [:] + sizeInBytes = try container.decodeIfPresent(UInt64.self, forKey: .sizeInBytes) + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(name, forKey: .name) + try container.encode(driver, forKey: .driver) + try container.encode(format, forKey: .format) + try container.encode(source, forKey: .source) + try container.encode(creationDate, forKey: .creationDate) + try container.encode(labels, forKey: .labels) + try container.encode(options, forKey: .options) + try container.encodeIfPresent(sizeInBytes, forKey: .sizeInBytes) + } +} + +extension VolumeConfiguration { + /// Reserved label key for marking anonymous volumes + public static let anonymousLabel = "com.apple.container.resource.anonymous" + + /// Whether this is an anonymous volume (detected via label) + public var isAnonymous: Bool { + labels[Self.anonymousLabel] != nil + } +} + +/// Error types for volume operations. +public enum VolumeError: Error, LocalizedError { + case volumeNotFound(String) + case volumeAlreadyExists(String) + case volumeInUse(String) + case invalidVolumeName(String) + case driverNotSupported(String) + case storageError(String) + + public var errorDescription: String? { + switch self { + case .volumeNotFound(let name): + return "volume '\(name)' not found" + case .volumeAlreadyExists(let name): + return "volume '\(name)' already exists" + case .volumeInUse(let name): + return "volume '\(name)' is currently in use and cannot be accessed by another container, or deleted" + case .invalidVolumeName(let name): + return "invalid volume name '\(name)'" + case .driverNotSupported(let driver): + return "volume driver '\(driver)' is not supported" + case .storageError(let message): + return "storage error: \(message)" + } + } +} + +/// Volume storage management utilities. +public struct VolumeStorage { + public static let volumeNamePattern = "^[A-Za-z0-9][A-Za-z0-9_.-]*$" + public static let defaultVolumeSizeBytes: UInt64 = 512 * 1024 * 1024 * 1024 // 512GB + + public static func isValidVolumeName(_ name: String) -> Bool { + guard name.count <= 255 else { return false } + + do { + let regex = try Regex(volumeNamePattern) + return (try? regex.wholeMatch(in: name)) != nil + } catch { + return false + } + } + + /// Generates an anonymous volume name with UUID format + public static func generateAnonymousVolumeName() -> String { + UUID().uuidString.lowercased() + } +} diff --git a/Sources/ContainerResource/Volume/VolumeResource.swift b/Sources/ContainerResource/Volume/VolumeResource.swift new file mode 100644 index 0000000..9283e06 --- /dev/null +++ b/Sources/ContainerResource/Volume/VolumeResource.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// A volume resource, representing a configured volume. +public struct VolumeResource: ManagedResource { + /// The volume's configuration — its persistent, intrinsic properties. + public let configuration: VolumeConfiguration + + // MARK: ManagedResource + + /// The unique identifier for this volume. Identical to ``VolumeConfiguration/name``. + public var id: String { configuration.name } + + /// The user-assigned name for this volume. For volumes, name and ID are the same. + public var name: String { configuration.name } + + /// The time at which this volume was created. + public var creationDate: Date { configuration.creationDate } + + /// Key-value labels for this volume. If the underlying + /// ``VolumeConfiguration/labels`` dictionary contains values that fail + /// ``ResourceLabels`` validation, this returns an empty label set. + public var labels: ResourceLabels { + (try? ResourceLabels(configuration.labels)) ?? ResourceLabels() + } + + /// Whether this is an anonymous volume (detected via the configuration's labels). + public var isAnonymous: Bool { configuration.isAnonymous } + + // MARK: Initialization + + /// Creates a volume resource. + /// + /// - Parameters: + /// - configuration: The volume's intrinsic configuration. + public init(configuration: VolumeConfiguration) { + self.configuration = configuration + } +} + +extension VolumeResource { + public static let volumeNamePattern = "^[A-Za-z0-9][A-Za-z0-9_.-]*$" + + /// Returns `true` if `name` is a syntactically valid volume identifier. + public static func nameValid(_ name: String) -> Bool { + guard name.count <= 255 else { return false } + + do { + let regex = try Regex(volumeNamePattern) + return (try? regex.wholeMatch(in: name)) != nil + } catch { + return false + } + } +} + +// MARK: - Codable + +extension VolumeResource { + enum CodingKeys: String, CodingKey { + case id + case configuration + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(id, forKey: .id) + try container.encode(configuration, forKey: .configuration) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.configuration = try container.decode(VolumeConfiguration.self, forKey: .configuration) + } +} diff --git a/Sources/ContainerTestSupport/TemporaryStorage.swift b/Sources/ContainerTestSupport/TemporaryStorage.swift new file mode 100644 index 0000000..a7f290c --- /dev/null +++ b/Sources/ContainerTestSupport/TemporaryStorage.swift @@ -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. +//===----------------------------------------------------------------------===// + +import Foundation +import SystemPackage + +public struct TemporaryStorage { + public static func withTempDir( + _ body: @Sendable (FilePath) async throws -> T + ) async throws -> T { + let url = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: url) } + return try await body(FilePath(url.path)) + } +} diff --git a/Sources/ContainerVersion/Bundle+AppBundle.swift b/Sources/ContainerVersion/Bundle+AppBundle.swift new file mode 100644 index 0000000..9c4941a --- /dev/null +++ b/Sources/ContainerVersion/Bundle+AppBundle.swift @@ -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 Darwin +import Foundation +import SystemPackage + +extension Bundle { + /// Retrieves the application bundle for a path that refers to a macOS executable. + /// + /// Resolves symlinks in `executablePath`, then walks up the standard macOS bundle layout + /// (`MacOS/` → `Contents/` → `Foo.app/`) and verifies the `.app` extension. + /// + /// - Parameter executablePath: The path to a macOS executable inside a bundle. + /// - Returns: The ``Bundle`` at the resolved `.app` directory, or `nil` if the executable + /// is not inside a valid macOS application bundle. + public static func appBundle(executablePath: FilePath) -> Bundle? { + let resolvedPath = + executablePath.withPlatformString { cPath in + Darwin.realpath(cPath, nil).map { ptr -> FilePath in + defer { free(ptr) } + return FilePath(platformString: ptr) + } + } ?? executablePath + let bundlePath = + resolvedPath + .removingLastComponent() // MacOS/ + .removingLastComponent() // Contents/ + .removingLastComponent() // Foo.app/ + guard bundlePath.lastComponent?.extension == "app" else { return nil } + return Bundle(url: URL(fileURLWithPath: bundlePath.string)) + } +} diff --git a/Sources/ContainerVersion/CommandLine+Executable.swift b/Sources/ContainerVersion/CommandLine+Executable.swift new file mode 100644 index 0000000..67d854f --- /dev/null +++ b/Sources/ContainerVersion/CommandLine+Executable.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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 SystemPackage + +extension CommandLine { + /// The path of the running executable. + /// + /// Obtained via `_NSGetExecutablePath`, which returns an absolute, lexically normalized path + /// (no `.` or `..` components). Symlinks are not resolved, so the path is not canonical. + public static var executablePath: FilePath { + /// _NSGetExecutablePath with a zero-length buffer returns the needed buffer length + var bufferSize: Int32 = 0 + var buffer = [CChar](repeating: 0, count: Int(bufferSize)) + _ = _NSGetExecutablePath(&buffer, &bufferSize) + + /// Create the buffer and get the path + buffer = [CChar](repeating: 0, count: Int(bufferSize)) + guard _NSGetExecutablePath(&buffer, &bufferSize) == 0 else { + fatalError("unexpected: failed to get executable path") + } + + /// Return the path with the executable file component removed the last component and + let executablePath = String(cString: &buffer) + return FilePath(executablePath) + } +} diff --git a/Sources/ContainerVersion/ReleaseVersion.swift b/Sources/ContainerVersion/ReleaseVersion.swift new file mode 100644 index 0000000..99e9ad2 --- /dev/null +++ b/Sources/ContainerVersion/ReleaseVersion.swift @@ -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 CVersion +import Foundation + +public struct ReleaseVersion { + public static func singleLine(appName: String) -> String { + var versionDetails: [String: String] = ["build": buildType()] + versionDetails["commit"] = gitCommit().map { String($0.prefix(7)) } ?? "unspecified" + let extras: String = versionDetails.map { "\($0): \($1)" }.sorted().joined(separator: ", ") + + return "\(appName) version \(version()) (\(extras))" + } + + public static func buildType() -> String { + #if DEBUG + return "debug" + #else + return "release" + #endif + } + + public static func version() -> String { + let appBundle = Bundle.appBundle(executablePath: CommandLine.executablePath) + let bundleVersion = appBundle?.infoDictionary?["CFBundleShortVersionString"] as? String + return bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + } + + public static func gitCommit() -> String? { + get_git_commit().map { String(cString: $0) } + } +} diff --git a/Sources/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift new file mode 100644 index 0000000..bab0085 --- /dev/null +++ b/Sources/ContainerXPC/XPCClient.swift @@ -0,0 +1,162 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import ContainerizationError +import Foundation + +public final class XPCClient: Sendable { + /// The maximum amount of time to wait for a request to a recently + /// registered XPC service. Once a service has launched, XPC + /// requests only have milliseconds of overhead, but in some instances, + /// macOS can take 5 seconds (or considerably longer) to launch a + /// service after it has been registered. + public static let xpcRegistrationTimeout: Duration = .seconds(60) + + private nonisolated(unsafe) let connection: xpc_connection_t + private let q: DispatchQueue? + private let service: String + + public init(service: String, queue: DispatchQueue? = nil) { + let connection = xpc_connection_create_mach_service(service, queue, 0) + self.connection = connection + self.q = queue + self.service = service + + xpc_connection_set_event_handler(connection) { _ in } + xpc_connection_set_target_queue(connection, self.q) + xpc_connection_activate(connection) + } + + public init(connection: xpc_connection_t, label: String, queue: DispatchQueue? = nil) { + self.connection = connection + self.q = queue + self.service = label + + xpc_connection_set_event_handler(connection) { _ in } + xpc_connection_set_target_queue(connection, self.q) + xpc_connection_activate(connection) + } + + deinit { + self.close() + } +} + +extension XPCClient { + /// Close the underlying XPC connection. + public func close() { + xpc_connection_cancel(connection) + } + + /// Returns the pid of process to which we have a connection. + /// Note: `xpc_connection_get_pid` returns 0 if no activity + /// has taken place on the connection prior to it being called. + public func remotePid() -> pid_t { + xpc_connection_get_pid(self.connection) + } + + /// Install a handler that is called whenever the connection receives an XPC error event. + /// + /// This replaces the existing (no-op) event handler. Call this before the first + /// `send()` to avoid a disconnect-before-handler race. + /// + /// ```swift + /// let client = XPCClient(service: "com.example.myservice") + /// client.setDisconnectHandler { + /// print("service disconnected, cleaning up") + /// } + /// let response = try await client.send(request) + /// ``` + public func setDisconnectHandler(_ handler: @Sendable @escaping () -> Void) { + xpc_connection_set_event_handler(connection) { object in + if xpc_get_type(object) == XPC_TYPE_ERROR { handler() } + } + } + + /// Create a persistent session backed by this client connection. + /// + /// The session installs a disconnect handler at initialisation time, before + /// any messages are sent, ensuring no server-exit event is missed. + public func openSession() -> XPCClientSession { + XPCClientSession(client: self) + } + + /// Send the provided message to the service. + @discardableResult + public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage { + try await withThrowingTaskGroup(of: XPCMessage.self, returning: XPCMessage.self) { group in + if let responseTimeout { + group.addTask { + try await Task.sleep(for: responseTimeout) + let route = message.string(key: XPCMessage.routeKey) ?? "nil" + throw ContainerizationError( + .internalError, + message: "XPC timeout for request to \(self.service)/\(route)" + ) + } + } + + group.addTask { + try await withCheckedThrowingContinuation { cont in + xpc_connection_send_message_with_reply(self.connection, message.underlying, nil) { reply in + do { + let message = try self.parseReply(reply) + cont.resume(returning: message) + } catch { + cont.resume(throwing: error) + } + } + } + } + + let response = try await group.next() + // once one task has finished, cancel the rest. + group.cancelAll() + // we don't really care about the second error here + // as it's most likely a `CancellationError`. + try? await group.waitForAll() + + guard let response else { + throw ContainerizationError(.invalidState, message: "failed to receive XPC response") + } + return response + } + } + + private func parseReply(_ reply: xpc_object_t) throws -> XPCMessage { + switch xpc_get_type(reply) { + case XPC_TYPE_ERROR: + var code = ContainerizationError.Code.invalidState + if reply.connectionError { + code = .interrupted + } + throw ContainerizationError( + code, + message: "XPC connection error: \(reply.errorDescription ?? "unknown")" + ) + case XPC_TYPE_DICTIONARY: + let message = XPCMessage(object: reply) + // check errors from our protocol + try message.error() + return message + default: + fatalError("unhandled xpc object type: \(xpc_get_type(reply))") + } + } +} + +#endif diff --git a/Sources/ContainerXPC/XPCClientSession.swift b/Sources/ContainerXPC/XPCClientSession.swift new file mode 100644 index 0000000..63222cf --- /dev/null +++ b/Sources/ContainerXPC/XPCClientSession.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Synchronization + +/// Represents a long-lived connection to an XPC service on the client side. +/// +/// Obtain one via `XPCClient.openSession()`. The disconnect handler is +/// installed at initialisation time, before the first `send()`, so there is +/// no window in which a server crash goes undetected. +public final class XPCClientSession: Sendable { + private let client: XPCClient + private let handlers: Mutex<[@Sendable () async -> Void]> = Mutex([]) + + init(client: XPCClient) { + self.client = client + client.setDisconnectHandler { [weak self] in + guard let self else { return } + let snapshot = self.handlers.withLock { $0 } + Task { for handler in snapshot { await handler() } } + } + } + + /// Register a handler to be called when the server disconnects. + public func onDisconnect(_ handler: @Sendable @escaping () async -> Void) { + handlers.withLock { $0.append(handler) } + } + + /// Send a message over the persistent connection. + @discardableResult + public func send(_ message: XPCMessage, responseTimeout: Duration? = nil) async throws -> XPCMessage { + try await client.send(message, responseTimeout: responseTimeout) + } + + /// Cancel the underlying connection. + public func close() { client.close() } +} + +#endif diff --git a/Sources/ContainerXPC/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift new file mode 100644 index 0000000..3c6a3dc --- /dev/null +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -0,0 +1,291 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import ContainerizationError +import Foundation + +/// A message that can be pass across application boundaries via XPC. +public struct XPCMessage: Sendable { + /// Defined message key storing the route value. + public static let routeKey = "com.apple.container.xpc.route" + /// Defined message key storing the error value. + public static let errorKey = "com.apple.container.xpc.error" + + // Access to `object` is protected by a lock + private nonisolated(unsafe) let object: xpc_object_t + private let lock = NSLock() + private let isErr: Bool + + /// The underlying xpc object that the message wraps. + public var underlying: xpc_object_t { + lock.withLock { + object + } + } + public var isErrorType: Bool { isErr } + + public init(object: xpc_object_t) { + self.object = object + self.isErr = xpc_get_type(self.object) == XPC_TYPE_ERROR + } + + public init(route: String) { + self.object = xpc_dictionary_create_empty() + self.isErr = false + xpc_dictionary_set_string(self.object, Self.routeKey, route) + } +} + +extension XPCMessage { + public static func == (lhs: XPCMessage, rhs: xpc_object_t) -> Bool { + xpc_equal(lhs.underlying, rhs) + } + + public func reply() -> XPCMessage { + lock.withLock { + XPCMessage(object: xpc_dictionary_create_reply(object)!) + } + } + + public func errorKeyDescription() -> String? { + guard self.isErr, + let xpcErr = lock.withLock({ + xpc_dictionary_get_string( + self.object, + XPC_ERROR_KEY_DESCRIPTION + ) + }) + else { + return nil + } + return String(cString: xpcErr) + } + + public func error() throws { + let data = data(key: Self.errorKey) + if let data { + let item = try? JSONDecoder().decode(ContainerXPCError.self, from: data) + precondition(item != nil, "expected to receive a ContainerXPCXPCError") + + throw ContainerizationError(item!.code, message: item!.message) + } + } + + public func set(error: ContainerizationError) { + var message = error.message + if let cause = error.cause { + message += " (cause: \"\(cause)\")" + } + let serializableError = ContainerXPCError(code: error.code.description, message: message) + let data = try? JSONEncoder().encode(serializableError) + precondition(data != nil) + + set(key: Self.errorKey, value: data!) + } +} + +struct ContainerXPCError: Codable { + let code: String + let message: String +} + +extension XPCMessage { + public func data(key: String) -> Data? { + var length: Int = 0 + let bytes = lock.withLock { + xpc_dictionary_get_data(self.object, key, &length) + } + + guard let bytes else { + return nil + } + + return Data(bytes: bytes, count: length) + } + + /// dataNoCopy is similar to data, except the data is not copied + /// to a new buffer. What this means in practice is the second the + /// underlying xpc_object_t gets released by ARC the data will be + /// released as well. This variant should be used when you know the + /// data will be used before the object has no more references. + public func dataNoCopy(key: String) -> Data? { + var length: Int = 0 + let bytes = lock.withLock { + xpc_dictionary_get_data(self.object, key, &length) + } + + guard let bytes else { + return nil + } + + return Data( + bytesNoCopy: UnsafeMutableRawPointer(mutating: bytes), + count: length, + deallocator: .none + ) + } + + public func set(key: String, value: Data) { + value.withUnsafeBytes { ptr in + if let addr = ptr.baseAddress { + lock.withLock { + xpc_dictionary_set_data(self.object, key, addr, value.count) + } + } + } + } + + public func string(key: String) -> String? { + let _id = lock.withLock { + xpc_dictionary_get_string(self.object, key) + } + if let _id { + return String(cString: _id) + } + return nil + } + + public func set(key: String, value: String) { + lock.withLock { + xpc_dictionary_set_string(self.object, key, value) + } + } + + public func bool(key: String) -> Bool { + lock.withLock { + xpc_dictionary_get_bool(self.object, key) + } + } + + public func set(key: String, value: Bool) { + lock.withLock { + xpc_dictionary_set_bool(self.object, key, value) + } + } + + public func uint64(key: String) -> UInt64 { + lock.withLock { + xpc_dictionary_get_uint64(self.object, key) + } + } + + public func set(key: String, value: UInt64) { + lock.withLock { + xpc_dictionary_set_uint64(self.object, key, value) + } + } + + public func int64(key: String) -> Int64 { + lock.withLock { + xpc_dictionary_get_int64(self.object, key) + } + } + + public func set(key: String, value: Int64) { + lock.withLock { + xpc_dictionary_set_int64(self.object, key, value) + } + } + + public func date(key: String) -> Date { + lock.withLock { + let nsSinceEpoch = xpc_dictionary_get_date(self.object, key) + return Date(timeIntervalSince1970: TimeInterval(nsSinceEpoch) / 1_000_000_000) + } + } + + public func set(key: String, value: Date) { + lock.withLock { + let nsSinceEpoch = Int64(value.timeIntervalSince1970 * 1_000_000_000) + xpc_dictionary_set_date(self.object, key, nsSinceEpoch) + } + } + + public func fileHandle(key: String) -> FileHandle? { + let fd = lock.withLock { + xpc_dictionary_get_value(self.object, key) + } + if let fd { + let fd2 = xpc_fd_dup(fd) + return FileHandle(fileDescriptor: fd2, closeOnDealloc: false) + } + return nil + } + + public func set(key: String, value: FileHandle) { + let fd = xpc_fd_create(value.fileDescriptor) + close(value.fileDescriptor) + lock.withLock { + xpc_dictionary_set_value(self.object, key, fd) + } + } + + public func fileHandles(key: String) -> [FileHandle]? { + let fds = lock.withLock { + xpc_dictionary_get_value(self.object, key) + } + if let fds { + let fd1 = xpc_array_dup_fd(fds, 0) + let fd2 = xpc_array_dup_fd(fds, 1) + if fd1 == -1 || fd2 == -1 { + return nil + } + return [ + FileHandle(fileDescriptor: fd1, closeOnDealloc: false), + FileHandle(fileDescriptor: fd2, closeOnDealloc: false), + ] + } + return nil + } + + public func set(key: String, value: [FileHandle]) throws { + let fdArray = xpc_array_create(nil, 0) + for fh in value { + guard let xpcFd = xpc_fd_create(fh.fileDescriptor) else { + throw ContainerizationError( + .internalError, + message: "failed to create xpc fd for \(fh.fileDescriptor)" + ) + } + xpc_array_append_value(fdArray, xpcFd) + close(fh.fileDescriptor) + } + lock.withLock { + xpc_dictionary_set_value(self.object, key, fdArray) + } + } + + public func set(key: String, xpcDictionary: xpc_object_t) { + lock.withLock { + xpc_dictionary_set_value(self.object, key, xpcDictionary) + } + } + + public func endpoint(key: String) -> xpc_endpoint_t? { + lock.withLock { + xpc_dictionary_get_value(self.object, key) + } + } + + public func set(key: String, value: xpc_endpoint_t) { + lock.withLock { + xpc_dictionary_set_value(self.object, key, value) + } + } +} + +#endif diff --git a/Sources/ContainerXPC/XPCServer.swift b/Sources/ContainerXPC/XPCServer.swift new file mode 100644 index 0000000..b334531 --- /dev/null +++ b/Sources/ContainerXPC/XPCServer.swift @@ -0,0 +1,288 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import CAuditToken +import ContainerizationError +import Foundation +import Logging +import os +import Synchronization + +public struct XPCServer: Sendable { + public typealias RouteHandler = @Sendable (XPCMessage, XPCServerSession) async throws -> XPCMessage + + /// Wraps a session-unaware handler for use in a route table. + public static func route( + _ fn: @Sendable @escaping (XPCMessage) async throws -> XPCMessage + ) -> RouteHandler { + { message, _ in try await fn(message) } + } + + private let routes: [String: RouteHandler] + // Access to `connection` is protected by a lock. + private nonisolated(unsafe) let connection: xpc_connection_t + private let lock = NSLock() + + let log: Logging.Logger + + public init(identifier: String, routes: [String: RouteHandler], log: Logging.Logger) { + let connection = xpc_connection_create_mach_service( + identifier, + nil, + UInt64(XPC_CONNECTION_MACH_SERVICE_LISTENER) + ) + + self.routes = routes + self.connection = connection + self.log = log + } + + public init(connection: xpc_connection_t, routes: [String: RouteHandler], log: Logging.Logger) { + self.routes = routes + self.connection = connection + self.log = log + } + + public func listen() async throws { + let connections = AsyncStream { cont in + lock.withLock { + xpc_connection_set_event_handler(self.connection) { object in + switch xpc_get_type(object) { + case XPC_TYPE_CONNECTION: + // `object` isn't used concurrently. + nonisolated(unsafe) let object = object + cont.yield(object) + case XPC_TYPE_ERROR: + if object.connectionError { + cont.finish() + } + default: + fatalError("unhandled xpc object type: \(xpc_get_type(object))") + } + } + } + } + + defer { + lock.withLock { + xpc_connection_cancel(self.connection) + } + } + + lock.withLock { + xpc_connection_activate(self.connection) + } + + try await withThrowingDiscardingTaskGroup { group in + for await conn in connections { + // `conn` isn't used concurrently. + nonisolated(unsafe) let conn = conn + let added = group.addTaskUnlessCancelled { @Sendable in + try await self.handleClientConnection(connection: conn) + xpc_connection_cancel(conn) + } + + if !added { + break + } + } + + group.cancelAll() + } + } + + func handleClientConnection(connection: xpc_connection_t) async throws { + let replySent = Mutex(false) + let session = XPCServerSession() + + let objects = AsyncStream { cont in + xpc_connection_set_event_handler(connection) { object in + switch xpc_get_type(object) { + case XPC_TYPE_DICTIONARY: + // `object` isn't used concurrently. + nonisolated(unsafe) let object = object + cont.yield(object) + case XPC_TYPE_ERROR: + if object.connectionError { + cont.finish() + } + if !(replySent.withLock({ $0 }) && object.connectionClosed) { + // When a xpc connection is closed, the framework sends + // a final XPC_ERROR_CONNECTION_INVALID message. + // We can ignore this if we know we have already handled + // the request. + self.log.error( + "xpc client handler connection error", + metadata: [ + "error": "\(object.errorDescription ?? "no description")" + ]) + } + default: + fatalError("unhandled xpc object type: \(xpc_get_type(object))") + } + } + } + defer { + xpc_connection_cancel(connection) + } + + xpc_connection_activate(connection) + try await withThrowingDiscardingTaskGroup { group in + // `connection` isn't used concurrently. + nonisolated(unsafe) let connection = connection + for await object in objects { + // `object` isn't used concurrently. + nonisolated(unsafe) let object = object + let added = group.addTaskUnlessCancelled { @Sendable in + try await self.handleMessage(connection: connection, object: object, session: session) + replySent.withLock { $0 = true } + } + if !added { + break + } + } + group.cancelAll() + } + await session.fireDisconnect() + } + + func handleMessage(connection: xpc_connection_t, object: xpc_object_t, session: XPCServerSession) async throws { + // All requests are dictionary-valued. + guard xpc_get_type(object) == XPC_TYPE_DICTIONARY else { + log.error("invalid request - not a dictionary") + Self.replyWithError( + connection: connection, + object: object, + err: ContainerizationError(.invalidArgument, message: "invalid request") + ) + return + } + + // Ensure that the client has our EUID + var token = audit_token_t() + xpc_dictionary_get_audit_token(object, &token) + let serverEuid = geteuid() + let clientEuid = audit_token_to_euid(token) + guard clientEuid == serverEuid else { + log.error( + "unauthorized request - uid mismatch", + metadata: [ + "server_euid": "\(serverEuid)", + "client_euid": "\(clientEuid)", + ]) + Self.replyWithError( + connection: connection, + object: object, + err: ContainerizationError(.invalidState, message: "unauthorized request") + ) + return + } + + guard let route = object.route else { + log.error("invalid request - empty route") + Self.replyWithError( + connection: connection, + object: object, + err: ContainerizationError(.invalidArgument, message: "invalid request") + ) + return + } + + if let handler = routes[route] { + do { + let message = XPCMessage(object: object) + let response = try await handler(message, session) + xpc_connection_send_message(connection, response.underlying) + } catch let error as ContainerizationError { + log.error( + "route handler threw an error", + metadata: [ + "route": "\(route)", + "error": "\(error)", + ]) + Self.replyWithError( + connection: connection, + object: object, + err: error + ) + } catch { + log.error( + "route handler threw an error", + metadata: [ + "route": "\(route)", + "error": "\(error)", + ]) + let message = XPCMessage(object: object) + let reply = message.reply() + + // Check if this is a VolumeError by looking at the error description + let errorMessage = error.localizedDescription + let errorTypeString = String(describing: type(of: error)) + if errorTypeString.contains("VolumeError") || errorMessage.contains("Volume") { + let err = ContainerizationError(.invalidArgument, message: errorMessage) + reply.set(error: err) + } else { + let err = ContainerizationError(.unknown, message: String(describing: error)) + reply.set(error: err) + } + xpc_connection_send_message(connection, reply.underlying) + } + } + } + + private static func replyWithError(connection: xpc_connection_t, object: xpc_object_t, err: ContainerizationError) { + let message = XPCMessage(object: object) + let reply = message.reply() + reply.set(error: err) + xpc_connection_send_message(connection, reply.underlying) + } +} + +extension xpc_object_t { + var route: String? { + let croute = xpc_dictionary_get_string(self, XPCMessage.routeKey) + guard let croute else { + return nil + } + return String(cString: croute) + } + + var connectionError: Bool { + precondition(isError, "not an error") + return xpc_equal(self, XPC_ERROR_CONNECTION_INVALID) || xpc_equal(self, XPC_ERROR_CONNECTION_INTERRUPTED) + } + + var connectionClosed: Bool { + precondition(isError, "not an error") + return xpc_equal(self, XPC_ERROR_CONNECTION_INVALID) + } + + var isError: Bool { + xpc_get_type(self) == XPC_TYPE_ERROR + } + + var errorDescription: String? { + precondition(isError, "not an error") + let cstring = xpc_dictionary_get_string(self, XPC_ERROR_KEY_DESCRIPTION) + guard let cstring else { + return nil + } + return String(cString: cstring) + } +} + +#endif diff --git a/Sources/ContainerXPC/XPCServerSession.swift b/Sources/ContainerXPC/XPCServerSession.swift new file mode 100644 index 0000000..60e3751 --- /dev/null +++ b/Sources/ContainerXPC/XPCServerSession.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) + +/// Represents a single client connection on the server side. +/// +/// The server creates one `XPCServerSession` per accepted client connection. +/// Handlers that need to associate state with a connection (e.g. resource +/// tracking for automatic cleanup) receive the session as a parameter and +/// may register disconnect handlers via `onDisconnect(_:)`. +public actor XPCServerSession { + private var disconnectHandlers: [@Sendable () async -> Void] = [] + + public init() {} + + /// Register a handler to be called when the client connection closes. + public func onDisconnect(_ handler: @Sendable @escaping () async -> Void) { + disconnectHandlers.append(handler) + } + + func fireDisconnect() async { + for handler in disconnectHandlers { await handler() } + } +} + +extension XPCServerSession: Hashable { + public nonisolated static func == (lhs: XPCServerSession, rhs: XPCServerSession) -> Bool { + lhs === rhs + } + + public nonisolated func hash(into hasher: inout Hasher) { + hasher.combine(ObjectIdentifier(self)) + } +} + +#endif diff --git a/Sources/DNSServer/DNSHandler.swift b/Sources/DNSServer/DNSHandler.swift new file mode 100644 index 0000000..4a5008c --- /dev/null +++ b/Sources/DNSServer/DNSHandler.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Protocol for implementing custom DNS handlers. +public protocol DNSHandler { + /// Attempt to answer a DNS query + /// - Parameter query: the query message + /// - Throws: a server failure occurred during the query + /// - Returns: The response message for the query, or nil if the request + /// is not within the scope of the handler. + func answer(query: Message) async throws -> Message? +} diff --git a/Sources/DNSServer/DNSServer+Handle.swift b/Sources/DNSServer/DNSServer+Handle.swift new file mode 100644 index 0000000..9b2bc9f --- /dev/null +++ b/Sources/DNSServer/DNSServer+Handle.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// 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 NIOCore +import NIOPosix + +extension DNSServer { + /// Handles the DNS request. + /// - Parameters: + /// - outbound: The NIOAsyncChannelOutboundWriter for which to respond. + /// - packet: The request packet. + func handle( + outbound: NIOAsyncChannelOutboundWriter>, + packet: inout AddressedEnvelope + ) async throws { + // RFC 1035 §2.3.4 limits UDP DNS messages to 512 bytes. We don't implement + // EDNS0 (RFC 6891), and this server only resolves host A/AAAA queries, so a + // legitimate query will never approach this limit. Reject oversized packets + // before reading to avoid allocating memory for malformed or malicious datagrams. + let maxPacketSize = 512 + guard packet.data.readableBytes <= maxPacketSize else { + self.log?.error("dropping oversized DNS packet: \(packet.data.readableBytes) bytes") + return + } + + var data = Data() + self.log?.debug("reading data") + while packet.data.readableBytes > 0 { + if let chunk = packet.data.readBytes(length: packet.data.readableBytes) { + data.append(contentsOf: chunk) + } + } + + self.log?.debug("deserializing message") + + // always send response + let responseData: Data + do { + let query = try Message(deserialize: data) + self.log?.debug("processing query: \(query.questions)") + + self.log?.debug("awaiting processing") + var response = + try await handler.answer(query: query) + ?? Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions, + answers: [] + ) + + // Only set NXDOMAIN if handler didn't explicitly set noError (NODATA response). + // This preserves NODATA responses for AAAA queries when A record exists, + // which prevents musl libc from treating empty AAAA as "domain doesn't exist". + if response.answers.isEmpty && response.returnCode != .noError { + response.returnCode = .nonExistentDomain + } + + self.log?.debug("serializing response") + responseData = try response.serialize() + } catch let error as DNSBindError { + // Best-effort: echo the transaction ID from the first two bytes of the raw packet. + let rawId = data.count >= 2 ? data[0..<2].withUnsafeBytes { $0.load(as: UInt16.self) } : 0 + let id = UInt16(bigEndian: rawId) + let returnCode: ReturnCode + switch error { + case .unsupportedValue: + self.log?.error("not implemented processing DNS message: \(error)") + returnCode = .notImplemented + default: + self.log?.error("format error processing DNS message: \(error)") + returnCode = .formatError + } + let response = Message( + id: id, + type: .response, + returnCode: returnCode, + questions: [], + answers: [] + ) + responseData = try response.serialize() + } catch { + let rawId = data.count >= 2 ? data[0..<2].withUnsafeBytes { $0.load(as: UInt16.self) } : 0 + let id = UInt16(bigEndian: rawId) + self.log?.error("error processing DNS message: \(error)") + let response = Message( + id: id, + type: .response, + returnCode: .serverFailure, + questions: [], + answers: [] + ) + responseData = try response.serialize() + } + + self.log?.debug("sending response") + let rData = ByteBuffer(bytes: responseData) + do { + try await outbound.write(AddressedEnvelope(remoteAddress: packet.remoteAddress, data: rData)) + } catch { + self.log?.error("failed to send DNS response: \(error)") + } + + self.log?.debug("processing done") + + } +} diff --git a/Sources/DNSServer/DNSServer.swift b/Sources/DNSServer/DNSServer.swift new file mode 100644 index 0000000..d38e554 --- /dev/null +++ b/Sources/DNSServer/DNSServer.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// 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 +import NIOCore +import NIOPosix + +/// Provides a DNS server. +/// - Parameters: +/// - host: The host address on which to listen. +/// - port: The port for the server to listen. +public struct DNSServer { + public var handler: DNSHandler + let log: Logger? + + public init( + handler: DNSHandler, + log: Logger? = nil + ) { + self.handler = handler + self.log = log + } + + public func run(host: String, port: Int) async throws { + // TODO: TCP server + let srv = try await DatagramBootstrap(group: NIOSingletons.posixEventLoopGroup) + .channelOption(.socketOption(.so_reuseaddr), value: 1) + .bind(host: host, port: port) + .flatMapThrowing { channel in + try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: NIOAsyncChannel.Configuration( + inboundType: AddressedEnvelope.self, + outboundType: AddressedEnvelope.self + ) + ) + } + .get() + + try await srv.executeThenClose { inbound, outbound in + for try await var packet in inbound { + try await self.handle(outbound: outbound, packet: &packet) + } + } + } + + public func run(socketPath: String) async throws { + // TODO: TCP server + let srv = try await DatagramBootstrap(group: NIOSingletons.posixEventLoopGroup) + .bind(unixDomainSocketPath: socketPath, cleanupExistingSocketFile: true) + .flatMapThrowing { channel in + try NIOAsyncChannel( + wrappingChannelSynchronously: channel, + configuration: NIOAsyncChannel.Configuration( + inboundType: AddressedEnvelope.self, + outboundType: AddressedEnvelope.self + ) + ) + } + .get() + + try await srv.executeThenClose { inbound, outbound in + for try await var packet in inbound { + log?.debug("received packet from \(packet.remoteAddress)") + try await self.handle(outbound: outbound, packet: &packet) + log?.debug("sent packet") + } + } + } + + public func stop() async throws {} +} diff --git a/Sources/DNSServer/Handlers/CompositeResolver.swift b/Sources/DNSServer/Handlers/CompositeResolver.swift new file mode 100644 index 0000000..e090ce1 --- /dev/null +++ b/Sources/DNSServer/Handlers/CompositeResolver.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Delegates a query sequentially to handlers until one provides a response. +public struct CompositeResolver: DNSHandler { + private let handlers: [DNSHandler] + + public init(handlers: [DNSHandler]) { + self.handlers = handlers + } + + public func answer(query: Message) async throws -> Message? { + for handler in self.handlers { + if let response = try await handler.answer(query: query) { + return response + } + } + + return nil + } +} diff --git a/Sources/DNSServer/Handlers/HostTableResolver.swift b/Sources/DNSServer/Handlers/HostTableResolver.swift new file mode 100644 index 0000000..41a6796 --- /dev/null +++ b/Sources/DNSServer/Handlers/HostTableResolver.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras + +/// Handler that uses table lookup to resolve hostnames. +/// +/// Keys in `hosts4` are normalized to `DNSName` on construction, so lookups +/// are case-insensitive and trailing dots are optional. +public struct HostTableResolver: DNSHandler { + public let hosts4: [DNSName: IPv4Address] + private let ttl: UInt32 + + /// Creates a resolver backed by a static IPv4 host table. + /// + /// - Parameter hosts4: A dictionary mapping domain names to IPv4 addresses. + /// Keys are normalized to `DNSName` (lowercased, trailing dot stripped), so + /// `"FOO."`, `"foo."`, and `"foo"` all refer to the same entry. + /// - Parameter ttl: The TTL in seconds to set on answer records (default is 300). + /// - Throws: `DNSBindError.invalidName` if any key is not a valid DNS name. + public init(hosts4: [String: IPv4Address], ttl: UInt32 = 300) throws { + self.hosts4 = try Dictionary(uniqueKeysWithValues: hosts4.map { (try DNSName($0.key), $0.value) }) + self.ttl = ttl + } + + 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)) + let record: ResourceRecord? + switch question.type { + case ResourceRecordType.host: + record = answerHost(question: question, key: key) + case ResourceRecordType.host6: + // Return NODATA (noError with empty answers) for AAAA queries ONLY if A record exists. + // 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". + if hosts4[key] != nil { + return Message( + id: query.id, + type: .response, + returnCode: .noError, + questions: query.questions, + answers: [] + ) + } + // If hostname doesn't exist, return nil which will become NXDOMAIN + return nil + 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, key: DNSName) -> ResourceRecord? { + guard let ip = hosts4[key] else { + return nil + } + + return HostRecord(name: question.name, ttl: ttl, ip: ip) + } +} diff --git a/Sources/DNSServer/Handlers/NxDomainResolver.swift b/Sources/DNSServer/Handlers/NxDomainResolver.swift new file mode 100644 index 0000000..8fa9c05 --- /dev/null +++ b/Sources/DNSServer/Handlers/NxDomainResolver.swift @@ -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. +//===----------------------------------------------------------------------===// + +/// Handler that returns NXDOMAIN for all hostnames. +public struct NxDomainResolver: DNSHandler { + private let ttl: UInt32 + + public init(ttl: UInt32 = 300) { + self.ttl = ttl + } + + public func answer(query: Message) async throws -> Message? { + let question = query.questions[0] + switch question.type { + case ResourceRecordType.host: + return Message( + id: query.id, + type: .response, + returnCode: .nonExistentDomain, + questions: query.questions, + answers: [] + ) + default: + return Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions, + answers: [] + ) + } + } +} diff --git a/Sources/DNSServer/Handlers/StandardQueryValidator.swift b/Sources/DNSServer/Handlers/StandardQueryValidator.swift new file mode 100644 index 0000000..fb0d74a --- /dev/null +++ b/Sources/DNSServer/Handlers/StandardQueryValidator.swift @@ -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. +//===----------------------------------------------------------------------===// + +/// Pass standard queries to a delegate handler. +public struct StandardQueryValidator: DNSHandler { + private let handler: DNSHandler + + /// Create the handler. + /// - Parameter delegate: the handler that receives valid queries + public init(handler: DNSHandler) { + self.handler = handler + } + + /// Ensures the query is valid before forwarding it to the delegate. + /// - Parameter msg: the query message + /// - Returns: the delegate response if the query is valid, and an + /// error response otherwise + public func answer(query: Message) async throws -> Message? { + // Reject response messages. + guard query.type == .query else { + return Message( + id: query.id, + type: .response, + returnCode: .formatError, + questions: query.questions + ) + } + + // Standard DNS servers handle only query operations. + guard query.operationCode == .query else { + return Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions + ) + } + + // Standard DNS servers only handle messages with exactly one question. + guard query.questions.count == 1 else { + return Message( + id: query.id, + type: .response, + returnCode: .formatError, + questions: query.questions + ) + } + + return try await handler.answer(query: query) + } +} diff --git a/Sources/DNSServer/Records/DNSBindError.swift b/Sources/DNSServer/Records/DNSBindError.swift new file mode 100644 index 0000000..b054d42 --- /dev/null +++ b/Sources/DNSServer/Records/DNSBindError.swift @@ -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. +//===----------------------------------------------------------------------===// + +/// Errors that can occur during DNS message serialization/deserialization. +public enum DNSBindError: Error, CustomStringConvertible { + case marshalFailure(type: String, field: String) + case unmarshalFailure(type: String, field: String) + case unsupportedValue(type: String, field: String) + case invalidName(String) + case unexpectedOffset(type: String, expected: Int, actual: Int) + + public var description: String { + switch self { + case .marshalFailure(let type, let field): + return "failed to marshal \(type).\(field)" + case .unmarshalFailure(let type, let field): + return "failed to unmarshal \(type).\(field)" + case .unsupportedValue(let type, let field): + return "unsupported value for \(type).\(field)" + case .invalidName(let reason): + return "invalid DNS name: \(reason)" + case .unexpectedOffset(let type, let expected, let actual): + return "unexpected offset serializing \(type): expected \(expected), got \(actual)" + } + } +} diff --git a/Sources/DNSServer/Records/DNSEnums.swift b/Sources/DNSServer/Records/DNSEnums.swift new file mode 100644 index 0000000..abea14a --- /dev/null +++ b/Sources/DNSServer/Records/DNSEnums.swift @@ -0,0 +1,167 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// DNS message type (query or response). +public enum MessageType: UInt16, Sendable { + case query = 0 + case response = 1 +} + +/// DNS operation code (RFC 1035, 1996, 2136). +public enum OperationCode: UInt8, Sendable { + case query = 0 // Standard query (RFC 1035) + case inverseQuery = 1 // Inverse query (obsolete, RFC 3425) + case status = 2 // Server status request (RFC 1035) + // 3 is reserved + case notify = 4 // Zone change notification (RFC 1996) + case update = 5 // Dynamic update (RFC 2136) + case dso = 6 // DNS Stateful Operations (RFC 8490) + // 7-15 reserved +} + +/// DNS response return codes (RFC 1035, 2136, 2845, 6895). +public enum ReturnCode: UInt8, Sendable { + case noError = 0 // No error + case formatError = 1 // Format error - unable to interpret query + case serverFailure = 2 // Server failure + case nonExistentDomain = 3 // Name error - domain does not exist (NXDOMAIN) + case notImplemented = 4 // Not implemented - query type not supported + case refused = 5 // Refused - policy restriction + case yxDomain = 6 // Name exists when it should not (RFC 2136) + case yxRRSet = 7 // RR set exists when it should not (RFC 2136) + case nxRRSet = 8 // RR set does not exist when it should (RFC 2136) + case notAuthoritative = 9 // Server not authoritative (RFC 2136) / Not authorized (RFC 2845) + case notZone = 10 // Name not in zone (RFC 2136) + case dsoTypeNotImplemented = 11 // DSO-TYPE not implemented (RFC 8490) + // 12-15 reserved + case badSignature = 16 // TSIG signature failure (RFC 2845) + case badKey = 17 // Key not recognized (RFC 2845) + case badTime = 18 // Signature out of time window (RFC 2845) + case badMode = 19 // Bad TKEY mode (RFC 2930) + case badName = 20 // Duplicate key name (RFC 2930) + case badAlgorithm = 21 // Algorithm not supported (RFC 2930) + case badTruncation = 22 // Bad truncation (RFC 4635) + case badCookie = 23 // Bad/missing server cookie (RFC 7873) +} + +/// DNS resource record types (RFC 1035, 3596, 2782, and others). +public enum ResourceRecordType: UInt16, Sendable { + case host = 1 // A - IPv4 address (RFC 1035) + case nameServer = 2 // NS - Authoritative name server (RFC 1035) + case mailDestination = 3 // MD - Mail destination (obsolete, RFC 1035) + case mailForwarder = 4 // MF - Mail forwarder (obsolete, RFC 1035) + case alias = 5 // CNAME - Canonical name (RFC 1035) + case startOfAuthority = 6 // SOA - Start of authority (RFC 1035) + case mailbox = 7 // MB - Mailbox domain name (experimental, RFC 1035) + case mailGroup = 8 // MG - Mail group member (experimental, RFC 1035) + case mailRename = 9 // MR - Mail rename domain name (experimental, RFC 1035) + case null = 10 // NULL - Null RR (experimental, RFC 1035) + case wellKnownService = 11 // WKS - Well known service (RFC 1035) + case pointer = 12 // PTR - Domain name pointer (RFC 1035) + case hostInfo = 13 // HINFO - Host information (RFC 1035) + case mailInfo = 14 // MINFO - Mailbox information (RFC 1035) + case mailExchange = 15 // MX - Mail exchange (RFC 1035) + case text = 16 // TXT - Text strings (RFC 1035) + case responsiblePerson = 17 // RP - Responsible person (RFC 1183) + case afsDatabase = 18 // AFSDB - AFS database location (RFC 1183) + case x25 = 19 // X25 - X.25 PSDN address (RFC 1183) + case isdn = 20 // ISDN - ISDN address (RFC 1183) + case routeThrough = 21 // RT - Route through (RFC 1183) + case nsapAddress = 22 // NSAP - NSAP address (RFC 1706) + case nsapPointer = 23 // NSAP-PTR - NSAP pointer (RFC 1706) + case signature = 24 // SIG - Security signature (RFC 2535) + case key = 25 // KEY - Security key (RFC 2535) + case pxRecord = 26 // PX - X.400 mail mapping (RFC 2163) + case gpos = 27 // GPOS - Geographical position (RFC 1712) + case host6 = 28 // AAAA - IPv6 address (RFC 3596) + case location = 29 // LOC - Location information (RFC 1876) + case nextDomain = 30 // NXT - Next domain (obsolete, RFC 2535) + case endpointId = 31 // EID - Endpoint identifier + case nimrodLocator = 32 // NIMLOC - Nimrod locator + case service = 33 // SRV - Service locator (RFC 2782) + case atma = 34 // ATMA - ATM address + case namingPointer = 35 // NAPTR - Naming authority pointer (RFC 3403) + case keyExchange = 36 // KX - Key exchange (RFC 2230) + case cert = 37 // CERT - Certificate (RFC 4398) + case a6Record = 38 // A6 - IPv6 address (obsolete, RFC 2874) + case dname = 39 // DNAME - Delegation name (RFC 6672) + case sink = 40 // SINK - Kitchen sink + case opt = 41 // OPT - EDNS option (RFC 6891) + case apl = 42 // APL - Address prefix list (RFC 3123) + case delegationSigner = 43 // DS - Delegation signer (RFC 4034) + case sshFingerprint = 44 // SSHFP - SSH key fingerprint (RFC 4255) + case ipsecKey = 45 // IPSECKEY - IPsec key (RFC 4025) + case resourceSignature = 46 // RRSIG - Resource record signature (RFC 4034) + case nsec = 47 // NSEC - Next secure record (RFC 4034) + case dnsKey = 48 // DNSKEY - DNS key (RFC 4034) + case dhcid = 49 // DHCID - DHCP identifier (RFC 4701) + case nsec3 = 50 // NSEC3 - NSEC3 (RFC 5155) + case nsec3Param = 51 // NSEC3PARAM - NSEC3 parameters (RFC 5155) + case tlsa = 52 // TLSA - TLSA certificate (RFC 6698) + case smimea = 53 // SMIMEA - S/MIME cert association (RFC 8162) + // 54 unassigned + case hip = 55 // HIP - Host identity protocol (RFC 8005) + case ninfo = 56 // NINFO + case rkey = 57 // RKEY + case taLink = 58 // TALINK - Trust anchor link + case cds = 59 // CDS - Child DS (RFC 7344) + case cdnsKey = 60 // CDNSKEY - Child DNSKEY (RFC 7344) + case openPGPKey = 61 // OPENPGPKEY - OpenPGP key (RFC 7929) + case csync = 62 // CSYNC - Child-to-parent sync (RFC 7477) + case zoneDigest = 63 // ZONEMD - Zone message digest (RFC 8976) + case svcBinding = 64 // SVCB - Service binding (RFC 9460) + case httpsBinding = 65 // HTTPS - HTTPS binding (RFC 9460) + // 66-98 unassigned + case spf = 99 // SPF - Sender policy framework (RFC 7208) + case uinfo = 100 // UINFO + case uid = 101 // UID + case gid = 102 // GID + case unspec = 103 // UNSPEC + case nid = 104 // NID - Node identifier (RFC 6742) + case l32 = 105 // L32 - Locator32 (RFC 6742) + case l64 = 106 // L64 - Locator64 (RFC 6742) + case lp = 107 // LP - Locator FQDN (RFC 6742) + case eui48 = 108 // EUI48 - 48-bit MAC (RFC 7043) + case eui64 = 109 // EUI64 - 64-bit MAC (RFC 7043) + // 110-248 unassigned + case tkey = 249 // TKEY - Transaction key (RFC 2930) + case tsig = 250 // TSIG - Transaction signature (RFC 2845) + case incrementalZoneTransfer = 251 // IXFR - Incremental zone transfer (RFC 1995) + case standardZoneTransfer = 252 // AXFR - Full zone transfer (RFC 1035) + case mailboxRecords = 253 // MAILB - Mailbox-related records (RFC 1035) + case mailAgentRecords = 254 // MAILA - Mail agent RRs (obsolete, RFC 1035) + case all = 255 // * - All records (RFC 1035) + case uri = 256 // URI - Uniform resource identifier (RFC 7553) + case caa = 257 // CAA - Certification authority authorization (RFC 8659) + case avc = 258 // AVC - Application visibility and control + case doa = 259 // DOA - Digital object architecture + case amtRelay = 260 // AMTRELAY - Automatic multicast tunneling relay (RFC 8777) + case resInfo = 261 // RESINFO - Resolver information + // ... + case ta = 32768 // TA - DNSSEC trust authorities + case dlv = 32769 // DLV - DNSSEC lookaside validation (RFC 4431) +} + +/// DNS resource record class (RFC 1035). +public enum ResourceRecordClass: UInt16, Sendable { + case internet = 1 // IN - Internet (RFC 1035) + // 2 unassigned + case chaos = 3 // CH - Chaos (RFC 1035) + case hesiod = 4 // HS - Hesiod (RFC 1035) + // 5-253 unassigned + case none = 254 // NONE - None (RFC 2136) + case any = 255 // * - Any class (RFC 1035) +} diff --git a/Sources/DNSServer/Records/DNSName.swift b/Sources/DNSServer/Records/DNSName.swift new file mode 100644 index 0000000..164a5f1 --- /dev/null +++ b/Sources/DNSServer/Records/DNSName.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// A DNS name encoded as a sequence of labels. +/// +/// DNS names are encoded as: `[length][label][length][label]...[0]` +/// For example, "example.com" becomes: `[7]example[3]com[0]` +public struct DNSName: Sendable, Hashable, CustomStringConvertible { + /// The labels that make up this name (e.g., ["example", "com"]). + public private(set) var labels: [String] + + /// Creates a DNS name representing the root (empty label list). + public init() { + self.labels = [] + } + + /// Creates a validated DNS name from an array of labels. + /// + /// Validates structural RFC 1035 constraints only: no empty labels, each label ≤ 63 + /// bytes, total wire length ≤ 255 bytes. Does not enforce hostname character rules. + /// Labels are lowercased to normalize for case-insensitive DNS comparison. + /// + /// - Throws: `DNSBindError.invalidName` if any label is empty, exceeds 63 bytes, + /// or if the total wire representation exceeds 255 bytes. + public init(labels: [String]) throws { + for label in labels { + guard !label.isEmpty else { + throw DNSBindError.invalidName("empty label") + } + guard label.utf8.count <= 63 else { + throw DNSBindError.invalidName("label too long: \"\(label)\"") + } + } + let wireLength = labels.reduce(1) { $0 + 1 + $1.utf8.count } + guard wireLength <= 255 else { + throw DNSBindError.invalidName("name too long") + } + self.labels = labels.map { $0.lowercased() } + } + + /// Creates a validated DNS name from a dot-separated hostname string + /// (e.g., `"example.com."` or `"example.com"`). + /// + /// A trailing dot is accepted but not required. + /// An empty string produces the root name without error. + /// + /// Labels must start and end with a letter or digit (LDH hostname rule). + /// Use `init(labels:)` directly when working with wire-decoded names that + /// may contain non-hostname labels (e.g. service-discovery labels like `"_dns"`). + /// + /// - Throws: `DNSBindError.invalidName` if any label violates the character rules, + /// or if structural limits are exceeded (see `init(labels:)`). + public init(_ hostname: String) throws { + let normalized = hostname.hasSuffix(".") ? String(hostname.dropLast()) : hostname + guard !normalized.isEmpty else { + self.init() + return + } + let parts = normalized.split(separator: ".", omittingEmptySubsequences: false).map { String($0) } + let hostnameRegex = /[a-zA-Z0-9](?:[a-zA-Z0-9\-_]*[a-zA-Z0-9])?/ + for part in parts { + guard part.wholeMatch(of: hostnameRegex) != nil else { + throw DNSBindError.invalidName( + "label must start and end with a letter or digit: \"\(part)\"" + ) + } + } + try self.init(labels: parts) + } + + /// The wire format size of this name in bytes. + public var size: Int { + // Each label: 1 byte length + label bytes, plus 1 byte for null terminator + labels.reduce(1) { $0 + 1 + $1.utf8.count } + } + + /// The fully-qualified domain name with trailing dot. + public var description: String { + labels.joined(separator: ".") + "." + } + + /// The partially-qualified domain name, which is the FQDN less the trailing dot. + public var pqdn: String { + labels.joined(separator: ".") + } + + /// Serialize this name into the buffer at the given offset. + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + var offset = offset + + for label in labels { + let bytes = Array(label.utf8) + guard bytes.count <= 63 else { + throw DNSBindError.marshalFailure(type: "DNSName", field: "label") + } + + guard let newOffset = buffer.copyIn(as: UInt8.self, value: UInt8(bytes.count), offset: offset) else { + throw DNSBindError.marshalFailure(type: "DNSName", field: "label") + } + offset = newOffset + + guard let newOffset = buffer.copyIn(buffer: bytes, offset: offset) else { + throw DNSBindError.marshalFailure(type: "DNSName", field: "label") + } + offset = newOffset + } + + // Null terminator + guard let newOffset = buffer.copyIn(as: UInt8.self, value: 0, offset: offset) else { + throw DNSBindError.marshalFailure(type: "DNSName", field: "terminator") + } + + guard newOffset == startOffset + size else { + throw DNSBindError.unexpectedOffset(type: "DNSName", expected: startOffset + size, actual: newOffset) + } + return newOffset + } + + /// Deserialize a name from the buffer at the given offset. + /// + /// - Parameters: + /// - buffer: The buffer to read from. + /// - offset: The offset to start reading. + /// - messageStart: The start of the DNS message (for compression pointer resolution). + /// - Returns: The new offset after reading. + public mutating func bindBuffer( + _ buffer: inout [UInt8], + offset: Int, + messageStart: Int = 0 + ) throws -> Int { + var offset = offset + var collectedLabels: [String] = [] + var jumped = false + var returnOffset = offset + var pointerHops = 0 + + while true { + guard offset < buffer.count else { + throw DNSBindError.unmarshalFailure(type: "DNSName", field: "name") + } + + let length = buffer[offset] + + // Check for compression pointer (top 2 bits set) + if (length & 0xC0) == 0xC0 { + guard offset + 1 < buffer.count else { + throw DNSBindError.unmarshalFailure(type: "DNSName", field: "pointer") + } + + pointerHops += 1 + guard pointerHops <= 10 else { + throw DNSBindError.unmarshalFailure(type: "DNSName", field: "pointer") + } + + if !jumped { + returnOffset = offset + 2 + } + + // Calculate pointer offset from message start + let pointer = Int(length & 0x3F) << 8 | Int(buffer[offset + 1]) + let pointerTarget = messageStart + pointer + guard pointerTarget >= 0 && pointerTarget < offset && pointerTarget < buffer.count else { + throw DNSBindError.unmarshalFailure(type: "DNSName", field: "pointer") + } + offset = pointerTarget + jumped = true + continue + } + + offset += 1 + + // Null terminator - end of name + if length == 0 { + break + } + + guard offset + Int(length) <= buffer.count else { + throw DNSBindError.unmarshalFailure(type: "DNSName", field: "label") + } + + let labelBytes = Array(buffer[offset..> 11) & 0x0F)) else { + throw DNSBindError.unsupportedValue(type: "Message", field: "opcode") + } + self.operationCode = opCode + self.authoritativeAnswer = (flags & 0x0400) != 0 + self.truncation = (flags & 0x0200) != 0 + self.recursionDesired = (flags & 0x0100) != 0 + self.recursionAvailable = (flags & 0x0080) != 0 + guard let returnCode = ReturnCode(rawValue: UInt8(flags & 0x000F)) else { + throw DNSBindError.unsupportedValue(type: "Message", field: "rcode") + } + self.returnCode = returnCode + + // Read counts + guard let (newOffset, rawQdCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw DNSBindError.unmarshalFailure(type: "Message", field: "qdcount") + } + let qdCount = UInt16(bigEndian: rawQdCount) + offset = newOffset + + guard let (newOffset, rawAnCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw DNSBindError.unmarshalFailure(type: "Message", field: "ancount") + } + let anCount = UInt16(bigEndian: rawAnCount) + offset = newOffset + + guard let (newOffset, rawNsCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw DNSBindError.unmarshalFailure(type: "Message", field: "nscount") + } + // nsCount not used for now, but we need to read past it + _ = UInt16(bigEndian: rawNsCount) + offset = newOffset + + guard let (newOffset, rawArCount) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw DNSBindError.unmarshalFailure(type: "Message", field: "arcount") + } + // arCount not used for now, but we need to read past it + _ = UInt16(bigEndian: rawArCount) + offset = newOffset + + // Read questions + self.questions = [] + for _ in 0.. Data { + // Calculate exact buffer size. + var bufferSize = Self.headerSize + for question in questions { + // name + type + class + let n = question.name.hasSuffix(".") ? String(question.name.dropLast()) : question.name + bufferSize += (try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))).size + 4 + } + for answer in answers { + // name + type + class + ttl + rdlen + rdata + let n = answer.name.hasSuffix(".") ? String(answer.name.dropLast()) : answer.name + let rdataSize = answer.type == .host ? 4 : 16 + bufferSize += (try DNSName(labels: n.isEmpty ? [] : n.split(separator: ".", omittingEmptySubsequences: false).map(String.init))).size + 10 + rdataSize + } + + var buffer = [UInt8](repeating: 0, count: bufferSize) + var offset = 0 + + // Write ID + guard let newOffset = buffer.copyIn(as: UInt16.self, value: id.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Message", field: "id") + } + offset = newOffset + + // Build and write flags + var flags: UInt16 = 0 + flags |= type == .response ? 0x8000 : 0 + flags |= UInt16(operationCode.rawValue) << 11 + flags |= authoritativeAnswer ? 0x0400 : 0 + flags |= truncation ? 0x0200 : 0 + flags |= recursionDesired ? 0x0100 : 0 + flags |= recursionAvailable ? 0x0080 : 0 + flags |= UInt16(returnCode.rawValue) + + guard let newOffset = buffer.copyIn(as: UInt16.self, value: flags.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Message", field: "flags") + } + offset = newOffset + + // Write counts + guard questions.count <= UInt16.max else { + throw DNSBindError.marshalFailure(type: "Message", field: "qdcount") + } + guard answers.count <= UInt16.max else { + throw DNSBindError.marshalFailure(type: "Message", field: "ancount") + } + guard authorities.count <= UInt16.max else { + throw DNSBindError.marshalFailure(type: "Message", field: "nscount") + } + guard additional.count <= UInt16.max else { + throw DNSBindError.marshalFailure(type: "Message", field: "arcount") + } + + guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(questions.count).bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Message", field: "qdcount") + } + offset = newOffset + + guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(answers.count).bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Message", field: "ancount") + } + offset = newOffset + + guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(authorities.count).bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Message", field: "nscount") + } + offset = newOffset + + guard let newOffset = buffer.copyIn(as: UInt16.self, value: UInt16(additional.count).bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Message", field: "arcount") + } + offset = newOffset + + // Write questions + for question in questions { + offset = try question.appendBuffer(&buffer, offset: offset) + } + + // Write answers + for answer in answers { + offset = try answer.appendBuffer(&buffer, offset: offset) + } + + // Write authorities + for authority in authorities { + offset = try authority.appendBuffer(&buffer, offset: offset) + } + + // Write additional + for record in additional { + offset = try record.appendBuffer(&buffer, offset: offset) + } + + guard offset == bufferSize else { + throw DNSBindError.unexpectedOffset(type: "Message", expected: bufferSize, actual: offset) + } + return Data(buffer[0.. Int { + let startOffset = offset + var offset = offset + + // Write name + let normalized = name.hasSuffix(".") ? String(name.dropLast()) : name + let dnsName = try DNSName(labels: normalized.isEmpty ? [] : normalized.split(separator: ".", omittingEmptySubsequences: false).map(String.init)) + offset = try dnsName.appendBuffer(&buffer, offset: offset) + + // Write type (big-endian) + guard let newOffset = buffer.copyIn(as: UInt16.self, value: type.rawValue.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Question", field: "type") + } + offset = newOffset + + // Write class (big-endian) + guard let newOffset = buffer.copyIn(as: UInt16.self, value: recordClass.rawValue.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "Question", field: "class") + } + + let expectedOffset = startOffset + dnsName.size + 4 + guard newOffset == expectedOffset else { + throw DNSBindError.unexpectedOffset(type: "Question", expected: expectedOffset, actual: newOffset) + } + return newOffset + } + + /// Deserialize a question from the buffer. + public mutating func bindBuffer(_ buffer: inout [UInt8], offset: Int, messageStart: Int = 0) throws -> Int { + var offset = offset + + // Read name + var dnsName = DNSName() + offset = try dnsName.bindBuffer(&buffer, offset: offset, messageStart: messageStart) + self.name = dnsName.description + + // Read type (big-endian) + guard let (newOffset, rawType) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw DNSBindError.unmarshalFailure(type: "Question", field: "type") + } + guard let qtype = ResourceRecordType(rawValue: UInt16(bigEndian: rawType)) else { + throw DNSBindError.unsupportedValue(type: "Question", field: "type") + } + self.type = qtype + offset = newOffset + + // Read class (big-endian) + guard let (newOffset, rawClass) = buffer.copyOut(as: UInt16.self, offset: offset) else { + throw DNSBindError.unmarshalFailure(type: "Question", field: "class") + } + guard let qclass = ResourceRecordClass(rawValue: UInt16(bigEndian: rawClass)) else { + throw DNSBindError.unsupportedValue(type: "Question", field: "class") + } + self.recordClass = qclass + + return newOffset + } +} diff --git a/Sources/DNSServer/Records/ResourceRecord.swift b/Sources/DNSServer/Records/ResourceRecord.swift new file mode 100644 index 0000000..37df99a --- /dev/null +++ b/Sources/DNSServer/Records/ResourceRecord.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Protocol for DNS resource records. +public protocol ResourceRecord: Sendable { + /// The domain name this record applies to. + var name: String { get } + + /// The record type. + var type: ResourceRecordType { get } + + /// The record class. + var recordClass: ResourceRecordClass { get } + + /// Time to live in seconds. + var ttl: UInt32 { get } + + /// Serialize this record into the buffer. + func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int +} + +/// A host record (A or AAAA) containing an IP address. +public struct HostRecord: ResourceRecord { + public let name: String + public let type: ResourceRecordType + public let recordClass: ResourceRecordClass + public let ttl: UInt32 + public let ip: T + + public init( + name: String, + ttl: UInt32 = 300, + ip: T, + recordClass: ResourceRecordClass = .internet + ) { + self.name = name + self.type = T.recordType + self.recordClass = recordClass + self.ttl = ttl + self.ip = ip + } + + public func appendBuffer(_ buffer: inout [UInt8], offset: Int) throws -> Int { + let startOffset = offset + var offset = offset + + // Write name + let normalized = name.hasSuffix(".") ? String(name.dropLast()) : name + let dnsName = try DNSName(labels: normalized.isEmpty ? [] : normalized.split(separator: ".", omittingEmptySubsequences: false).map(String.init)) + offset = try dnsName.appendBuffer(&buffer, offset: offset) + + // Write type (big-endian) + guard let newOffset = buffer.copyIn(as: UInt16.self, value: type.rawValue.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "HostRecord", field: "type") + } + offset = newOffset + + // Write class (big-endian) + guard let newOffset = buffer.copyIn(as: UInt16.self, value: recordClass.rawValue.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "HostRecord", field: "class") + } + offset = newOffset + + // Write TTL (big-endian) + guard let newOffset = buffer.copyIn(as: UInt32.self, value: ttl.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "HostRecord", field: "ttl") + } + offset = newOffset + + // Write rdlength (big-endian) + let rdlength = UInt16(T.size) + guard let newOffset = buffer.copyIn(as: UInt16.self, value: rdlength.bigEndian, offset: offset) else { + throw DNSBindError.marshalFailure(type: "HostRecord", field: "rdlength") + } + offset = newOffset + + // Write IP address bytes + guard let newOffset = buffer.copyIn(buffer: ip.bytes, offset: offset) else { + throw DNSBindError.marshalFailure(type: "HostRecord", field: "rdata") + } + + let expectedOffset = startOffset + dnsName.size + 10 + T.size + guard newOffset == expectedOffset else { + throw DNSBindError.unexpectedOffset(type: "HostRecord", expected: expectedOffset, actual: newOffset) + } + return newOffset + } +} diff --git a/Sources/DNSServer/Records/UInt8+Binding.swift b/Sources/DNSServer/Records/UInt8+Binding.swift new file mode 100644 index 0000000..cd9b91b --- /dev/null +++ b/Sources/DNSServer/Records/UInt8+Binding.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +// TODO: This copies some of the Bindable code from Containerization, +// but we can't use Bindable as it presumes a fixed length record. +// We can look at refining this later to see if we can use some common +// bit fiddling code everywhere. + +extension [UInt8] { + /// Copy a value into the buffer at the given offset. + /// - Returns: The new offset after writing, or nil if the buffer is too small. + package mutating func copyIn(as type: T.Type, value: T, offset: Int = 0) -> Int? { + let size = MemoryLayout.size + guard self.count >= size + offset else { + return nil + } + return self.withUnsafeMutableBytes { + $0.baseAddress?.advanced(by: offset).assumingMemoryBound(to: T.self).pointee = value + return offset + size + } + } + + /// Copy a value out of the buffer at the given offset. + /// - Returns: A tuple of (new offset, value), or nil if the buffer is too small. + package func copyOut(as type: T.Type, offset: Int = 0) -> (Int, T)? { + let size = MemoryLayout.size + guard self.count >= size + offset else { + return nil + } + return self.withUnsafeBytes { + guard let value = $0.baseAddress?.advanced(by: offset).assumingMemoryBound(to: T.self).pointee else { + return nil + } + return (offset + size, value) + } + } + + /// Copy a byte array into the buffer at the given offset. + /// - Returns: The new offset after writing, or nil if the buffer is too small. + package mutating func copyIn(buffer: [UInt8], offset: Int = 0) -> Int? { + guard offset + buffer.count <= self.count else { + return nil + } + self[offset.. Int? { + guard offset + buffer.count <= self.count else { + return nil + } + buffer[0../dev/null 2>&1; then + echo "${CONTAINER_USER}:x:${CONTAINER_GID}:" >> /etc/group +fi + +if ! getent passwd "${CONTAINER_UID}" >/dev/null 2>&1; then + echo "${CONTAINER_USER}:x:${CONTAINER_UID}:${CONTAINER_GID}::${CONTAINER_HOME}:${CONTAINER_SHELL}" >> /etc/passwd + echo "${CONTAINER_USER}:!:19000:0:99999:7:::" >> /etc/shadow +fi + +mkdir -p "${CONTAINER_HOME}" +if [ -d /etc/skel ]; then + cp -a /etc/skel/. "${CONTAINER_HOME}" +fi +chown -R "${CONTAINER_UID}:${CONTAINER_GID}" "${CONTAINER_HOME}" + +mkdir -p /etc/sudoers.d +echo "${CONTAINER_USER} ALL=(ALL) NOPASSWD:ALL" > "/etc/sudoers.d/${CONTAINER_USER}" +chmod 440 "/etc/sudoers.d/${CONTAINER_USER}" diff --git a/Sources/Plugins/MachineAPIServer/Resources/init b/Sources/Plugins/MachineAPIServer/Resources/init new file mode 100755 index 0000000..3cf4511 --- /dev/null +++ b/Sources/Plugins/MachineAPIServer/Resources/init @@ -0,0 +1,75 @@ +#!/bin/sh +# +# Container machine init script — replaces /sbin/init as the first process (PID 1) +# executed when a container machine boots. Performs container machine-specific setup +# before handing off to the real system init: +# +# 1. Resolve the system default shell. +# Debian/Ubuntu systems use DSHELL from /etc/adduser.conf; other images +# use SHELL from /etc/default/useradd. Falls back to /bin/bash or /bin/sh. +# +# 2. Open a user shell when invoked with the "-s" flag. +# Looks up the current user's shell from /etc/passwd and execs into it, +# falling back to the system default shell. If additional arguments follow +# "-s", runs them as a command via " -c " instead of dropping +# into an interactive session. +# +# 3. Run first-time user setup when invoked with the "-u" flag (only once). +# If /etc/.machine.initialized does not exist, runs the create-user script +# to create the container user and apply one-time configuration. A custom +# script at /etc/machine/create-user.sh takes precedence over the built-in +# one at /sbin.machine/create-user.sh. Marks initialization complete by +# touching /etc/.machine.initialized. +# +# 4. Boot the system when invoked with no flags. +# Sets the hostname to CONTAINER_MACHINE_ID and execs /sbin/init to hand +# off to the real system init. +# + +set -e + +MACHINE_INITIALIZED=/etc/.machine.initialized +CUSTOM_SETUP=/etc/machine/create-user.sh +DEFAULT_SETUP=/sbin.machine/create-user.sh + +. /etc/os-release 2>/dev/null +case "${ID:-}" in + ubuntu|debian) + SHELL=$(unset DSHELL; . /etc/adduser.conf 2>/dev/null \ + && [ -n "${DSHELL:-}" ] \ + && echo "${DSHELL}") || SHELL=/bin/bash ;; + *) + SHELL=$(unset SHELL; . /etc/default/useradd 2>/dev/null \ + && [ -n "${SHELL:-}" ] \ + && echo "${SHELL}") || SHELL=/bin/sh ;; +esac +export CONTAINER_SHELL=${SHELL} + +if [ "$1" = "-s" ]; then + shift + USER_SHELL=$(grep "^$(id -un):" /etc/passwd 2>/dev/null | cut -d: -f7) + if [ $# -gt 0 ]; then + exec "${USER_SHELL:-${SHELL}}" -c "$*" + else + exec "${USER_SHELL:-${SHELL}}" + fi +elif [ "$1" = "-u" ]; then + # DEPRECATED 0.11.0.0 - use `id` instead of checking `${MACHINE_INITIALIZED}` for backward compatibility, remove in 0.13.0.0 + if ! id "${CONTAINER_USER}" >/dev/null 2>&1; then + if [ -f ${CUSTOM_SETUP} ]; then + ${CUSTOM_SETUP} + else + ${DEFAULT_SETUP} + fi + fi + + echo 1 > ${MACHINE_INITIALIZED} +else + echo "${CONTAINER_MACHINE_ID}" > /etc/hostname + + if [ -S ${SSH_AUTH_SOCK} ]; then + chown ${CONTAINER_UID}:${CONTAINER_GID} ${SSH_AUTH_SOCK} + fi + + exec /sbin/init +fi diff --git a/Sources/Plugins/MachineAPIServer/config.toml b/Sources/Plugins/MachineAPIServer/config.toml new file mode 100644 index 0000000..c23c27c --- /dev/null +++ b/Sources/Plugins/MachineAPIServer/config.toml @@ -0,0 +1,11 @@ +abstract = "Container machine management API plugin" +author = "Apple" + +[servicesConfig] +loadAtBoot = true +runAtLoad = false +defaultArguments = [] + +[[servicesConfig.services]] +type = "core" +description = "Provide an XPC interface to interact with the container machine API server." diff --git a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift new file mode 100644 index 0000000..7d67f1f --- /dev/null +++ b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper+Start.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerLog +import ContainerNetworkClient +import ContainerNetworkServer +import ContainerNetworkVmnetServer +import ContainerPlugin +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging + +enum Variant: String, ExpressibleByArgument { + case reserved + case allocationOnly +} + +extension NetworkMode: ExpressibleByArgument {} + +extension NetworkVmnetHelper { + struct Start: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Starts the network plugin" + ) + + @Flag(name: .long, help: "Enable debug logging") + var debug = false + + @Option(name: .long, help: "XPC service identifier") + var serviceIdentifier: String + + @Option(name: .shortAndLong, help: "Network identifier") + var id: String + + @Option(name: .long, help: "Network mode") + var mode: NetworkMode = .nat + + @Option(name: .customLong("subnet"), help: "CIDR address for the IPv4 subnet") + var ipv4Subnet: String? + + @Option(name: .customLong("subnet-v6"), help: "CIDR address for the IPv6 prefix") + var ipv6Subnet: String? + + @Option(name: .long, help: "Variant of the network helper to use.") + var variant: Variant = { + guard #available(macOS 26, *) else { + return .allocationOnly + } + return .reserved + }() + + var logRoot = LogRoot.path + + func run() async throws { + let commandName = NetworkVmnetHelper._commandName + let logPath = logRoot.map { $0.appending("\(commandName)-\(id).log") } + let log = ServiceLogger.bootstrap(category: "NetworkVmnetHelper", metadata: ["id": "\(id)"], 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") + let ipv4Subnet = try self.ipv4Subnet.map { try CIDRv4($0) } + let ipv6Subnet = try self.ipv6Subnet.map { try CIDRv6($0) } + + let configuration = try NetworkConfiguration( + name: id, + mode: mode, + ipv4Subnet: ipv4Subnet, + ipv6Subnet: ipv6Subnet, + plugin: NetworkVmnetHelper._commandName, + options: ["variant": self.variant.rawValue] + ) + let network = try Self.createNetwork( + configuration: configuration, + variant: self.variant, + log: log + ) + try await network.start() + let service = try await DefaultNetworkService(network: network, log: log) + let harness = NetworkHarness(service: service) + let xpc = XPCServer( + identifier: serviceIdentifier, + routes: [ + NetworkRoutes.status.rawValue: XPCServer.route(harness.status), + NetworkRoutes.allocate.rawValue: harness.allocate, + NetworkRoutes.lookup.rawValue: XPCServer.route(harness.lookup), + ], + log: log + ) + + log.info("starting XPC server") + try await xpc.listen() + } catch { + log.error( + "helper failed", + metadata: [ + "name": "\(commandName)", + "error": "\(error)", + ]) + NetworkVmnetHelper.exit(withError: error) + } + } + + private static func createNetwork(configuration: NetworkConfiguration, variant: Variant, log: Logger) throws -> Network { + switch variant { + case .allocationOnly: + return try AllocationOnlyVmnetNetwork(configuration: configuration, log: log) + case .reserved: + guard #available(macOS 26, *) else { + throw ContainerizationError( + .invalidArgument, + message: "variant ReservedVmnetNetwork is only available on macOS 26+" + ) + } + return try ReservedVmnetNetwork(configuration: configuration, log: log) + } + } + } +} diff --git a/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper.swift b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper.swift new file mode 100644 index 0000000..dab7084 --- /dev/null +++ b/Sources/Plugins/NetworkVmnet/NetworkVmnetHelper.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion + +@main +struct NetworkVmnetHelper: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "container-network-vmnet", + abstract: "XPC service for managing a vmnet network", + version: ReleaseVersion.singleLine(appName: "container-network-vmnet"), + subcommands: [ + Start.self + ] + ) +} diff --git a/Sources/Plugins/NetworkVmnet/config.toml b/Sources/Plugins/NetworkVmnet/config.toml new file mode 100644 index 0000000..5bb747c --- /dev/null +++ b/Sources/Plugins/NetworkVmnet/config.toml @@ -0,0 +1,11 @@ +abstract = "vmnet network management plugin" +author = "Apple" +version = 0.1 + +[servicesConfig] +loadAtBoot = false +runAtLoad = true +defaultArguments = [] + +[[servicesConfig.services]] +type = "network" diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift new file mode 100644 index 0000000..3c7938b --- /dev/null +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper+Start.swift @@ -0,0 +1,149 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerLog +import ContainerPlugin +import ContainerResource +import ContainerRuntimeClient +import ContainerRuntimeLinuxServer +import ContainerXPC +import Foundation +import Logging +import NIO + +extension RuntimeLinuxHelper { + struct Start: AsyncParsableCommand { + static let label = "com.apple.container.runtime.container-runtime-linux" + + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start helper for a Linux container" + ) + + @Flag(name: .long, help: "Enable debug logging") + var debug = false + + @Option(name: .shortAndLong, help: "Sandbox UUID") + var uuid: String + + @Option(name: .shortAndLong, help: "Root directory for the sandbox") + var root: String + + var logRoot = LogRoot.path + + var machServiceLabel: String { + "\(Self.label).\(uuid)" + } + + func run() async throws { + let commandName = RuntimeLinuxHelper._commandName + let logPath = logRoot.map { $0.appending("\(commandName)-\(uuid).log") } + let log = ServiceLogger.bootstrap(category: "RuntimeLinuxHelper", metadata: ["uuid": "\(uuid)"], debug: debug, logPath: logPath) + log.info("starting helper", metadata: ["name": "\(commandName)"]) + defer { + log.info("stopping helper", metadata: ["name": "\(commandName)"]) + } + + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + do { + try adjustLimits() + signal(SIGPIPE, SIG_IGN) + + // FIXME: The network plugins that the runtime supports should be configurable elsewhere + var interfaceStrategies: [NetworkInterfaceKey: InterfaceStrategy] = [ + NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "allocationOnly"): IsolatedInterfaceStrategy() + ] + if #available(macOS 26, *) { + interfaceStrategies[NetworkInterfaceKey(plugin: "container-network-vmnet", variant: "reserved")] = NonisolatedInterfaceStrategy(log: log) + } + + log.info("configuring XPC server") + nonisolated(unsafe) let anonymousConnection = xpc_connection_create(nil, nil) + + let server = RuntimeService( + root: .init(fileURLWithPath: root), + interfaceStrategies: interfaceStrategies, + eventLoopGroup: eventLoopGroup, + connection: anonymousConnection, + log: log + ) + + let endpointServer = XPCServer( + identifier: machServiceLabel, + routes: [ + RuntimeRoutes.createEndpoint.rawValue: XPCServer.route(server.createEndpoint) + ], + log: log + ) + + let mainServer = XPCServer( + connection: anonymousConnection, + routes: [ + RuntimeRoutes.bootstrap.rawValue: XPCServer.route(server.bootstrap), + RuntimeRoutes.createProcess.rawValue: XPCServer.route(server.createProcess), + RuntimeRoutes.state.rawValue: XPCServer.route(server.state), + RuntimeRoutes.stop.rawValue: XPCServer.route(server.stop), + RuntimeRoutes.kill.rawValue: XPCServer.route(server.kill), + RuntimeRoutes.resize.rawValue: XPCServer.route(server.resize), + RuntimeRoutes.wait.rawValue: XPCServer.route(server.wait), + RuntimeRoutes.start.rawValue: XPCServer.route(server.startProcess), + RuntimeRoutes.dial.rawValue: XPCServer.route(server.dial), + RuntimeRoutes.shutdown.rawValue: XPCServer.route(server.shutdown), + RuntimeRoutes.statistics.rawValue: XPCServer.route(server.statistics), + RuntimeRoutes.copyIn.rawValue: XPCServer.route(server.copyIn), + RuntimeRoutes.copyOut.rawValue: XPCServer.route(server.copyOut), + ], + log: log + ) + + log.info("starting XPC server") + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await endpointServer.listen() + } + group.addTask { + try await mainServer.listen() + } + defer { group.cancelAll() } + + _ = try await group.next() + } + } catch { + log.error( + "helper failed", + metadata: [ + "name": "\(commandName)", + "error": "\(error)", + ]) + try? await eventLoopGroup.shutdownGracefully() + RuntimeLinuxHelper.Start.exit(withError: error) + } + } + + private func adjustLimits() throws { + var limits = rlimit() + guard getrlimit(RLIMIT_NOFILE, &limits) == 0 else { + throw POSIXError(.init(rawValue: errno)!) + } + limits.rlim_cur = 65536 + limits.rlim_max = 65536 + guard setrlimit(RLIMIT_NOFILE, &limits) == 0 else { + throw POSIXError(.init(rawValue: errno)!) + } + } + } +} diff --git a/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift new file mode 100644 index 0000000..bd322cd --- /dev/null +++ b/Sources/Plugins/RuntimeLinux/RuntimeLinuxHelper.swift @@ -0,0 +1,30 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerVersion + +@main +struct RuntimeLinuxHelper: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "container-runtime-linux", + abstract: "XPC Service for managing a Linux sandbox", + version: ReleaseVersion.singleLine(appName: "container-runtime-linux"), + subcommands: [ + Start.self + ] + ) +} diff --git a/Sources/Plugins/RuntimeLinux/config.toml b/Sources/Plugins/RuntimeLinux/config.toml new file mode 100644 index 0000000..b902025 --- /dev/null +++ b/Sources/Plugins/RuntimeLinux/config.toml @@ -0,0 +1,11 @@ +abstract = "Linux container runtime plugin" +author = "Apple" +version = 0.1 + +[servicesConfig] +loadAtBoot = false +runAtLoad = false +defaultArguments = [] + +[[servicesConfig.services]] +type = "runtime" diff --git a/Sources/Services/ContainerAPIService/Client/Arch.swift b/Sources/Services/ContainerAPIService/Client/Arch.swift new file mode 100644 index 0000000..bc90dea --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/Arch.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum Arch: String { + case arm64, amd64 + + public init?(rawValue: String) { + switch rawValue.lowercased() { + case "arm64", "aarch64": + self = .arm64 + case "amd64", "x86_64", "x86-64": + self = .amd64 + default: + return nil + } + } + + public static func hostArchitecture() -> Arch { + #if arch(arm64) + return .arm64 + #elseif arch(x86_64) + return .amd64 + #endif + } +} diff --git a/Sources/Services/ContainerAPIService/Client/Archiver.swift b/Sources/Services/ContainerAPIService/Client/Archiver.swift new file mode 100644 index 0000000..10dd215 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/Archiver.swift @@ -0,0 +1,279 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationArchive +import ContainerizationOS +import CryptoKit +import Foundation + +public final class Archiver: Sendable { + public struct ArchiveEntryInfo: Sendable, Codable { + public let pathOnHost: URL + public let pathInArchive: URL + + public let owner: UInt32? + public let group: UInt32? + public let permissions: UInt16? + + public init( + pathOnHost: URL, + pathInArchive: URL, + owner: UInt32? = nil, + group: UInt32? = nil, + permissions: UInt16? = nil + ) { + self.pathOnHost = pathOnHost + self.pathInArchive = pathInArchive + self.owner = owner + self.group = group + self.permissions = permissions + } + } + + public static func compress( + source: URL, + destination: URL, + followSymlinks: Bool = false, + writerConfiguration: ArchiveWriterConfiguration = ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip), + closure: (URL) -> ArchiveEntryInfo? + ) throws -> SHA256.Digest { + let source = source.standardizedFileURL + let destination = destination.standardizedFileURL + + let fileManager = FileManager.default + try? fileManager.removeItem(at: destination) + + var hasher = SHA256() + + do { + let directory = destination.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + + guard + let enumerator = FileManager.default.enumerator(atPath: source.path) + else { + throw Error.fileDoesNotExist(source) + } + + var entryInfo = [ArchiveEntryInfo]() + if !source.isDirectory { + if let info = closure(source) { + entryInfo.append(info) + } + } else { + let relPaths = enumerator.compactMap { $0 as? String } + for relPath in relPaths.sorted(by: { $0 < $1 }) { + let url = source.appending(path: relPath).standardizedFileURL + guard let info = closure(url) else { + continue + } + entryInfo.append(info) + } + } + + let archiver = try ArchiveWriter( + configuration: writerConfiguration + ) + try archiver.open(file: destination) + + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + + for info in entryInfo { + guard let entry = try Self._createEntry(entryInfo: info) else { + throw Error.failedToCreateEntry + } + hasher.update(data: try encoder.encode(entry)) + try Self._compressFile(item: info.pathOnHost, entry: entry, archiver: archiver, hasher: &hasher) + } + try archiver.finishEncoding() + } catch { + try? fileManager.removeItem(at: destination) + throw error + } + + return hasher.finalize() + } + + // MARK: private functions + private static func _compressFile(item: URL, entry: WriteEntry, archiver: ArchiveWriter, hasher: inout SHA256) throws { + let writer = archiver.makeTransactionWriter() + let bufferSize = Int(1.mib()) + let readBuffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + defer { readBuffer.deallocate() } + try writer.writeHeader(entry: entry) + if entry.fileType == .regular { + // We need to write the data into the archive only if its a regular file + // Symlinks and directories require us to only write the archive header + guard let stream = InputStream(url: item) else { + throw Error.failedToCreateInputStream(item) + } + stream.open() + defer { stream.close() } + while true { + let byteRead = stream.read(readBuffer, maxLength: bufferSize) + if byteRead < 0 { + // stream.read returns -1 on error (e.g. TCC access denial under /Users/) + let streamError = stream.streamError + throw Error.failedToReadFile(item, streamError) + } + if byteRead == 0 { + break + } + let data = Data(bytesNoCopy: UnsafeMutableRawPointer(mutating: readBuffer), count: byteRead, deallocator: .none) + hasher.update(data: data) + try data.withUnsafeBytes { pointer in + try writer.writeChunk(data: pointer) + } + } + } + try writer.finish() + } + + private static func _createEntry(entryInfo: ArchiveEntryInfo, pathPrefix: String = "") throws -> WriteEntry? { + let entry = WriteEntry() + let fileManager = FileManager.default + let attributes = try fileManager.attributesOfItem(atPath: entryInfo.pathOnHost.path) + + if let fileType = attributes[.type] as? FileAttributeType { + switch fileType { + case .typeBlockSpecial, .typeCharacterSpecial, .typeSocket: + return nil + case .typeDirectory: + entry.fileType = .directory + case .typeRegular: + entry.fileType = .regular + case .typeSymbolicLink: + entry.fileType = .symbolicLink + let symlinkTarget = try fileManager.destinationOfSymbolicLink(atPath: entryInfo.pathOnHost.path) + entry.symlinkTarget = symlinkTarget + default: + return nil + } + } + if let posixPermissions = attributes[.posixPermissions] as? NSNumber { + #if os(macOS) + entry.permissions = posixPermissions.uint16Value + #else + entry.permissions = posixPermissions.uint32Value + #endif + } + if let fileSize = attributes[.size] as? UInt64 { + entry.size = Int64(fileSize) + } + if let uid = attributes[.ownerAccountID] as? NSNumber { + entry.owner = uid.uint32Value + } + if let gid = attributes[.groupOwnerAccountID] as? NSNumber { + entry.group = gid.uint32Value + } + if let creationDate = attributes[.creationDate] as? Date { + entry.creationDate = creationDate + } + if let modificationDate = attributes[.modificationDate] as? Date { + entry.modificationDate = modificationDate + } + + // Apply explicit overrides from ArchiveEntryInfo when provided + if let overrideOwner = entryInfo.owner { + entry.owner = overrideOwner + } + if let overrideGroup = entryInfo.group { + entry.group = overrideGroup + } + if let overridePerm = entryInfo.permissions { + #if os(macOS) + entry.permissions = overridePerm + #else + entry.permissions = UInt32(overridePerm) + #endif + } + + let pathTrimmed = Self._trimPathPrefix(entryInfo.pathInArchive.relativePath, pathPrefix: pathPrefix) + entry.path = pathTrimmed + return entry + } + + private static func _trimPathPrefix(_ path: String, pathPrefix: String) -> String { + guard !path.isEmpty && !pathPrefix.isEmpty else { + return path + } + + let decodedPath = path.removingPercentEncoding ?? path + + guard decodedPath.hasPrefix(pathPrefix) else { + return decodedPath + } + let trimmedPath = String(decodedPath.suffix(from: pathPrefix.endIndex)) + return trimmedPath + } +} + +extension Archiver { + public enum Error: Swift.Error, CustomStringConvertible { + case failedToCreateEntry + case fileDoesNotExist(_ url: URL) + case failedToCreateInputStream(_ url: URL) + case failedToReadFile(_ url: URL, _ underlying: Swift.Error?) + + public var description: String { + switch self { + case .failedToCreateEntry: + return "failed to create entry" + case .fileDoesNotExist(let url): + return "file \(url.path) does not exist" + case .failedToCreateInputStream(let url): + return "failed to create input stream for \(url.path)" + case .failedToReadFile(let url, let underlying): + if let underlying { + return "failed to read file \(url.path): \(underlying)" + } + return "failed to read file \(url.path)" + } + } + } +} + +extension WriteEntry: @retroactive Encodable { + enum CodingKeys: String, CodingKey { + case path + case fileType + case size + case permissions + case owner + case group + case symlinkTarget + case hardlink + case creationDate + case modificationDate + case contentAccessDate + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encodeIfPresent(fileType.rawValue, forKey: .fileType) + try container.encodeIfPresent(permissions, forKey: .permissions) + try container.encodeIfPresent(path, forKey: .path) + try container.encodeIfPresent(size, forKey: .size) + try container.encodeIfPresent(owner, forKey: .owner) + try container.encodeIfPresent(group, forKey: .group) + try container.encodeIfPresent(symlinkTarget, forKey: .symlinkTarget) + try container.encodeIfPresent(hardlink, forKey: .hardlink) + try container.encodeIfPresent(creationDate, forKey: .creationDate) + try container.encodeIfPresent(modificationDate, forKey: .modificationDate) + try container.encodeIfPresent(contentAccessDate, forKey: .contentAccessDate) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/Array+Dedupe.swift b/Sources/Services/ContainerAPIService/Client/Array+Dedupe.swift new file mode 100644 index 0000000..160a395 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/Array+Dedupe.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +extension Array where Element: Hashable { + func dedupe() -> [Element] { + var elems = Set() + return filter { elems.insert($0).inserted } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientDiskUsage.swift b/Sources/Services/ContainerAPIService/Client/ClientDiskUsage.swift new file mode 100644 index 0000000..9d0a004 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientDiskUsage.swift @@ -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 ContainerXPC +import ContainerizationError +import Foundation + +/// Client API for disk usage operations +public struct ClientDiskUsage { + static let serviceIdentifier = "com.apple.container.apiserver" + + /// Get disk usage statistics for all resource types + public static func get() async throws -> DiskUsageStats { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .systemDiskUsage) + let reply = try await client.send(message) + + guard let responseData = reply.dataNoCopy(key: .diskUsageStats) else { + throw ContainerizationError( + .internalError, + message: "invalid response from server: missing disk usage data" + ) + } + + return try JSONDecoder().decode(DiskUsageStats.self, from: responseData) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift b/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift new file mode 100644 index 0000000..a6814e8 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientHealthCheck.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import Foundation +import SystemPackage + +public enum ClientHealthCheck { + static let serviceIdentifier = "com.apple.container.apiserver" +} + +extension ClientHealthCheck { + private static func newClient() -> XPCClient { + XPCClient(service: serviceIdentifier) + } + + public static func ping(timeout: Duration? = XPCClient.xpcRegistrationTimeout) async throws -> SystemHealth { + let client = Self.newClient() + let request = XPCMessage(route: .ping) + let reply = try await client.send(request, responseTimeout: timeout) + guard let appRootValue = reply.string(key: .appRoot), let appRoot = URL(string: appRootValue) else { + throw ContainerizationError(.internalError, message: "failed to decode appRoot in health check") + } + guard let installRootValue = reply.string(key: .installRoot), let installRoot = URL(string: installRootValue) else { + throw ContainerizationError(.internalError, message: "failed to decode installRoot in health check") + } + let logRoot = reply.string(key: .logRoot).map { FilePath($0) } + guard let apiServerVersion = reply.string(key: .apiServerVersion) else { + throw ContainerizationError(.internalError, message: "failed to decode apiServerVersion in health check") + } + guard let apiServerCommit = reply.string(key: .apiServerCommit) else { + throw ContainerizationError(.internalError, message: "failed to decode apiServerCommit in health check") + } + guard let apiServerBuild = reply.string(key: .apiServerBuild) else { + throw ContainerizationError(.internalError, message: "failed to decode apiServerBuild in health check") + } + guard let apiServerAppName = reply.string(key: .apiServerAppName) else { + throw ContainerizationError(.internalError, message: "failed to decode apiServerAppName in health check") + } + return .init( + appRoot: appRoot, + installRoot: installRoot, + logRoot: logRoot, + apiServerVersion: apiServerVersion, + apiServerCommit: apiServerCommit, + apiServerBuild: apiServerBuild, + apiServerAppName: apiServerAppName + ) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage+ImageResource.swift b/Sources/Services/ContainerAPIService/Client/ClientImage+ImageResource.swift new file mode 100644 index 0000000..94b86f6 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientImage+ImageResource.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerizationOCI +import Foundation + +extension ClientImage { + /// Resolves, from the content store, the index descriptor and per-variant + /// manifest content needed to build an ``ImageResource``. + /// + /// Manifests without a platform, or whose config/manifest cannot be + /// fetched, are skipped. + public func toImageResource(containerSystemConfig: ContainerSystemConfig) async throws -> ImageResource { + var variants: [ImageResource.Variant] = [] + var earliest: Date? + + for desc in try await self.index().manifests { + guard let platform = desc.platform else { + continue + } + let config: ContainerizationOCI.Image + let manifest: ContainerizationOCI.Manifest + do { + config = try await self.config(for: platform) + manifest = try await self.manifest(for: platform) + } catch { + continue + } + let size = + desc.size + manifest.config.size + + manifest.layers.reduce(0) { $0 + $1.size } + + variants.append(.init(platform: platform, digest: desc.digest, size: size, config: config)) + + // Use the earliest variant's creation timestamp as the image's date. + if let date = config.created.flatMap(Self.parseCreated) { + earliest = min(earliest ?? date, date) + } + } + + let created = earliest ?? Date(timeIntervalSince1970: 0) + let displayName = try Self.denormalizeReference(self.description.reference, containerSystemConfig: containerSystemConfig) + let configuration = ImageResource.ImageConfiguration(description: self.description, creationDate: created) + return ImageResource(configuration: configuration, variants: variants, displayReference: displayName) + } + + private static func parseCreated(_ value: String) -> Date? { + let withFractional = ISO8601DateFormatter() + withFractional.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + if let date = withFractional.date(from: value) { + return date + } + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime] + return formatter.date(from: value) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientImage.swift b/Sources/Services/ContainerAPIService/Client/ClientImage.swift new file mode 100644 index 0000000..3516d0e --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientImage.swift @@ -0,0 +1,552 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerImagesServiceClient +import ContainerPersistence +import ContainerResource +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +// MARK: ClientImage structure + +public struct ClientImage: Sendable { + private let contentStore: ContentStore = RemoteContentStoreClient() + public let description: ImageDescription + + public var digest: String { description.digest } + public var descriptor: Descriptor { description.descriptor } + public var reference: String { description.reference } + + public init(description: ImageDescription) { + self.description = description + } + + /// Returns the underlying OCI index for the image. + public func index() async throws -> Index { + guard let content: Content = try await contentStore.get(digest: description.digest) else { + throw ContainerizationError(.notFound, message: "content with digest \(description.digest)") + } + return try content.decode() + } + + /// Returns the manifest for the specified platform. + public func manifest(for platform: Platform) async throws -> Manifest { + let index = try await self.index() + let desc = index.manifests.first { desc in + desc.platform == platform + } + guard let desc else { + throw ContainerizationError(.unsupported, message: "platform \(platform.description)") + } + guard let content: Content = try await contentStore.get(digest: desc.digest) else { + throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)") + } + return try content.decode() + } + + /// Returns the OCI config for the specified platform. + public func config(for platform: Platform) async throws -> ContainerizationOCI.Image { + let manifest = try await self.manifest(for: platform) + let desc = manifest.config + guard let content: Content = try await contentStore.get(digest: desc.digest) else { + throw ContainerizationError(.notFound, message: "content with digest \(desc.digest)") + } + return try content.decode() + } + + /// Returns the resolved OCI descriptor for the image. + package func resolved() async throws -> Descriptor { + let index = try await self.index() + let indirect = index.annotations?[AnnotationKeys.containerizationIndexIndirect] + // If this is not an indirect index, return its own descriptor + guard let indirect, ["1", "true"].contains(indirect.lowercased()) else { + return self.descriptor + } + // For indirect indices, return the first (and only) manifest + guard let manifest = index.manifests.first else { + throw ContainerizationError( + .internalError, + message: "failed to resolve indirect index \(self.digest): no manifests found" + ) + } + return manifest + } +} + +// MARK: ClientImage constants + +extension ClientImage { + private static let serviceIdentifier = "com.apple.container.core.container-core-images" + + private static func newXPCClient() -> XPCClient { + XPCClient(service: Self.serviceIdentifier) + } + + private static func newRequest(_ route: ImagesServiceXPCRoute) -> XPCMessage { + XPCMessage(route: route) + } + +} + +// MARK: Static methods + +extension ClientImage { + private static let legacyDockerRegistryHost = "docker.io" + private static let dockerRegistryHost = "registry-1.docker.io" + private static let defaultDockerRegistryRepo = "library" + + public static func normalizeReference(_ ref: String, containerSystemConfig: ContainerSystemConfig) throws -> String { + guard ref != containerSystemConfig.vminit.image else { + // Don't modify the default init image reference. + // This is to allow for easier local development against + // an updated containerization. + return ref + } + // Check if the input reference has a domain specified + var updatedRawReference: String = ref + let r = try Reference.parse(ref) + if r.domain == nil { + updatedRawReference = "\(containerSystemConfig.registry.domain)/\(ref)" + } + + let updatedReference = try Reference.parse(updatedRawReference) + + // Handle adding the :latest tag if it isn't specified, + // as well as adding the "library/" repository if it isn't set only if the host is docker.io + updatedReference.normalize() + return updatedReference.description + } + + public static func denormalizeReference(_ ref: String, containerSystemConfig: ContainerSystemConfig) throws -> String { + var updatedRawReference: String = ref + let r = try Reference.parse(ref) + let defaultRegistry = containerSystemConfig.registry.domain + if r.domain == defaultRegistry { + updatedRawReference = "\(r.path)" + if let tag = r.tag { + updatedRawReference += ":\(tag)" + } else if let digest = r.digest { + updatedRawReference += "@\(digest)" + } + if defaultRegistry == dockerRegistryHost || defaultRegistry == legacyDockerRegistryHost { + updatedRawReference.trimPrefix("\(defaultDockerRegistryRepo)/") + } + } + return updatedRawReference + } + + public static func list() async throws -> [ClientImage] { + let client = newXPCClient() + let request = newRequest(.imageList) + let response = try await client.send(request) + + let imageDescriptions = try response.imageDescriptions() + return imageDescriptions.map { desc in + ClientImage(description: desc) + } + } + + public static func get(names: [String], containerSystemConfig: ContainerSystemConfig) async throws -> (images: [ClientImage], error: [String]) { + let all = try await self.list() + var errors: [String] = [] + var found: [ClientImage] = [] + for name in names { + do { + guard let img = try Self._search(reference: name, in: all, containerSystemConfig: containerSystemConfig) else { + errors.append(name) + continue + } + found.append(img) + } catch { + errors.append(name) + } + } + return (found, errors) + } + + public static func get(reference: String, containerSystemConfig: ContainerSystemConfig) async throws -> ClientImage { + let all = try await self.list() + guard let found = try self._search(reference: reference, in: all, containerSystemConfig: containerSystemConfig) else { + throw ContainerizationError(.notFound, message: "image with reference \(reference)") + } + return found + } + + /// Returns the total size of an image in bytes. + /// - Parameter image: The image to get the size for. + /// - Returns: The full image size in bytes. + /// - Throws: An error if the image cannot be retrieved. + public static func getFullImageSize(image: ClientImage) async throws -> Int64 { + for descriptor in try await image.index().manifests { + if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], + referenceType == "attestation-manifest" + { + continue + } + + guard let platform = descriptor.platform else { + continue + } + + do { + let manifest = try await image.manifest(for: platform) + return + descriptor.size + manifest.config.size + manifest.layers.reduce(0) { $0 + $1.size } + } catch { + continue + } + } + return 0 + } + + private static func _search(reference: String, in all: [ClientImage], containerSystemConfig: ContainerSystemConfig) throws -> ClientImage? { + let locallyBuiltImage = try { + // Check if we have an image whose index descriptor contains the image name + // as an annotation. Prefer this in all cases, since these are locally built images. + let r = try Reference.parse(reference) + r.normalize() + let withDefaultTag = r.description + + let localImageMatches = all.filter { $0.description.nameFromAnnotation() == withDefaultTag } + guard localImageMatches.count > 1 else { + return localImageMatches.first + } + // More than one image matched. Check against the tagged reference + return localImageMatches.first { $0.reference == withDefaultTag } + }() + if let locallyBuiltImage { + return locallyBuiltImage + } + // If we don't find a match, try matching `ImageDescription.name` against the given + // input string, while also checking against its normalized form. + // Return the first match. + let normalizedReference = try Self.normalizeReference(reference, containerSystemConfig: containerSystemConfig) + return all.first(where: { image in + image.reference == reference || image.reference == normalizedReference + }) + } + + public static func pull( + reference: String, + platform: Platform? = nil, + scheme: RequestScheme = .auto, + containerSystemConfig: ContainerSystemConfig, + progressUpdate: ProgressUpdateHandler? = nil, + maxConcurrentDownloads: Int = 3 + ) async throws -> ClientImage { + guard maxConcurrentDownloads > 0 else { + throw ContainerizationError(.invalidArgument, message: "maximum number of concurrent downloads must be greater than 0, got \(maxConcurrentDownloads)") + } + + let client = newXPCClient() + let request = newRequest(.imagePull) + + let reference = try self.normalizeReference(reference, containerSystemConfig: containerSystemConfig) + guard let host = try Reference.parse(reference).domain else { + throw ContainerizationError(.invalidArgument, message: "could not extract host from reference \(reference)") + } + + request.set(key: .imageReference, value: reference) + try request.set(platform: platform) + + let insecure = try scheme.schemeFor(host: host, internalDnsDomain: containerSystemConfig.dns.domain) == .http + request.set(key: .insecureFlag, value: insecure) + request.set(key: .maxConcurrentDownloads, value: Int64(maxConcurrentDownloads)) + + var progressUpdateClient: ProgressUpdateClient? + if let progressUpdate { + progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: request) + } + + let response = try await client.send(request) + let description = try response.imageDescription() + let image = ClientImage(description: description) + + await progressUpdateClient?.finish() + return image + } + + public static func delete(reference: String, garbageCollect: Bool = false) async throws { + let client = newXPCClient() + let request = newRequest(.imageDelete) + request.set(key: .imageReference, value: reference) + request.set(key: .garbageCollect, value: garbageCollect) + let _ = try await client.send(request) + } + + public static func save(references: [String], out: String, platform: Platform? = nil, containerSystemConfig: ContainerSystemConfig) async throws { + let (clientImages, errors) = try await get(names: references, containerSystemConfig: containerSystemConfig) + guard errors.isEmpty else { + // TODO: Improve error handling here + throw ContainerizationError(.invalidArgument, message: "one or more image references are invalid: \(errors.joined(separator: ", "))") + } + + let descriptions = clientImages.map { $0.description } + let client = Self.newXPCClient() + let request = Self.newRequest(.imageSave) + try request.set(descriptions: descriptions) + request.set(key: .filePath, value: out) + try request.set(platform: platform) + let _ = try await client.send(request) + } + + public static func load(from tarFile: String, force: Bool = false) async throws -> ImageLoadResult { + let client = newXPCClient() + let request = newRequest(.imageLoad) + request.set(key: .filePath, value: tarFile) + request.set(key: .forceLoad, value: force) + let reply = try await client.send(request) + + let (descriptions, rejectedMembers) = try reply.loadResults() + let images = descriptions.map { desc in + ClientImage(description: desc) + } + return ImageLoadResult(images: images, rejectedMembers: rejectedMembers) + } + + public static func cleanUpOrphanedBlobs() async throws -> ([String], UInt64) { + let client = newXPCClient() + let request = newRequest(.imageCleanupOrphanedBlobs) + let response = try await client.send(request) + let digests = try response.digests() + let size = response.uint64(key: .imageSize) + return (digests, size) + } + + /// Calculate disk usage for images + /// - Parameter activeReferences: Set of image references currently in use by containers + /// - Returns: Tuple of (total count, active count, total size, reclaimable size) + public static func calculateDiskUsage(activeReferences: Set) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) { + let client = newXPCClient() + let request = newRequest(.imageDiskUsage) + + // Encode active references + let activeRefsData = try JSONEncoder().encode(activeReferences) + request.set(key: .activeImageReferences, value: activeRefsData) + + let response = try await client.send(request) + let total = Int(response.int64(key: .totalCount)) + let active = Int(response.int64(key: .activeCount)) + let size = response.uint64(key: .imageSize) + let reclaimable = response.uint64(key: .reclaimableSize) + + return (totalCount: total, activeCount: active, totalSize: size, reclaimableSize: reclaimable) + } + + public static func fetch( + reference: String, + platform: Platform? = nil, + scheme: RequestScheme = .auto, + containerSystemConfig: ContainerSystemConfig, + progressUpdate: ProgressUpdateHandler? = nil, + maxConcurrentDownloads: Int = 3 + ) async throws -> ClientImage { + do { + let match = try await self.get(reference: reference, containerSystemConfig: containerSystemConfig) + if let platform { + // The image exists, but we dont know if we have the right platform pulled + // Check if we do, if not pull the requested platform + _ = try await match.config(for: platform) + } + return match + } catch let err as ContainerizationError { + guard err.isCode(.notFound) else { + throw err + } + return try await Self.pull( + reference: reference, platform: platform, scheme: scheme, containerSystemConfig: containerSystemConfig, progressUpdate: progressUpdate, + maxConcurrentDownloads: maxConcurrentDownloads) + } + } +} + +// MARK: Instance methods + +extension ClientImage { + public func push(platform: Platform? = nil, scheme: RequestScheme, containerSystemConfig: ContainerSystemConfig, progressUpdate: ProgressUpdateHandler?) async throws { + let client = Self.newXPCClient() + let request = Self.newRequest(.imagePush) + + guard let host = try Reference.parse(reference).domain else { + throw ContainerizationError(.invalidArgument, message: "could not extract host from reference \(reference)") + } + request.set(key: .imageReference, value: reference) + + let insecure = try scheme.schemeFor(host: host, internalDnsDomain: containerSystemConfig.dns.domain) == .http + request.set(key: .insecureFlag, value: insecure) + + try request.set(platform: platform) + + var progressUpdateClient: ProgressUpdateClient? + if let progressUpdate { + progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: request) + } + _ = try await client.send(request) + await progressUpdateClient?.finish() + } + + @discardableResult + public func tag(new: String) async throws -> ClientImage { + let client = Self.newXPCClient() + let request = Self.newRequest(.imageTag) + request.set(key: .imageReference, value: self.description.reference) + request.set(key: .imageNewReference, value: new) + let reply = try await client.send(request) + let description = try reply.imageDescription() + return ClientImage(description: description) + } + + // MARK: Snapshot Methods + + public func unpack(platform: Platform?, progressUpdate: ProgressUpdateHandler? = nil) async throws { + let client = Self.newXPCClient() + let request = Self.newRequest(.imageUnpack) + + try request.set(description: description) + try request.set(platform: platform) + + var progressUpdateClient: ProgressUpdateClient? + if let progressUpdate { + progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: request) + } + + try await client.send(request) + + await progressUpdateClient?.finish() + } + + public func deleteSnapshot(platform: Platform?) async throws { + let client = Self.newXPCClient() + let request = Self.newRequest(.snapshotDelete) + + try request.set(description: description) + try request.set(platform: platform) + + try await client.send(request) + } + + public func getSnapshot(platform: Platform) async throws -> Filesystem { + let client = Self.newXPCClient() + let request = Self.newRequest(.snapshotGet) + + try request.set(description: description) + try request.set(platform: platform) + + let response = try await client.send(request) + let fs = try response.filesystem() + return fs + } + + @discardableResult + public func getCreateSnapshot(platform: Platform, progressUpdate: ProgressUpdateHandler? = nil) async throws -> Filesystem { + do { + return try await self.getSnapshot(platform: platform) + } catch let err as ContainerizationError { + guard err.code == .notFound else { + throw err + } + try await self.unpack(platform: platform, progressUpdate: progressUpdate) + return try await self.getSnapshot(platform: platform) + } + } +} + +extension XPCMessage { + fileprivate func set(description: ImageDescription) throws { + let descData = try JSONEncoder().encode(description) + self.set(key: .imageDescription, value: descData) + } + + fileprivate func set(descriptions: [ImageDescription]) throws { + let descData = try JSONEncoder().encode(descriptions) + self.set(key: .imageDescriptions, value: descData) + } + + fileprivate func set(platform: Platform?) throws { + guard let platform else { + return + } + let platformData = try JSONEncoder().encode(platform) + self.set(key: .ociPlatform, value: platformData) + } + + fileprivate func imageDescription() throws -> ImageDescription { + let responseData = self.dataNoCopy(key: .imageDescription) + guard let responseData else { + throw ContainerizationError(.empty, message: "imageDescription not received") + } + let description = try JSONDecoder().decode(ImageDescription.self, from: responseData) + return description + } + + fileprivate func imageDescriptions() throws -> [ImageDescription] { + let imagesData = self.dataNoCopy(key: .imageDescriptions) + guard let imagesData else { + throw ContainerizationError(.empty, message: "imageDescriptions not received") + } + let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData) + return descriptions + } + + fileprivate func loadResults() throws -> ([ImageDescription], [String]) { + let imagesData = self.dataNoCopy(key: .imageDescriptions) + guard let imagesData else { + throw ContainerizationError(.empty, message: "imageDescriptions not received") + } + let descriptions = try JSONDecoder().decode([ImageDescription].self, from: imagesData) + let rejectedMembersData = self.dataNoCopy(key: .rejectedMembers) + guard let rejectedMembersData else { + throw ContainerizationError(.empty, message: "rejectedMembers not received") + } + let rejectedMembers = try JSONDecoder().decode([String].self, from: rejectedMembersData) + return (descriptions, rejectedMembers) + } + + fileprivate func filesystem() throws -> Filesystem { + let responseData = self.dataNoCopy(key: .filesystem) + guard let responseData else { + throw ContainerizationError(.empty, message: "filesystem not received") + } + let fs = try JSONDecoder().decode(Filesystem.self, from: responseData) + return fs + } + + fileprivate func digests() throws -> [String] { + let responseData = self.dataNoCopy(key: .digests) + guard let responseData else { + throw ContainerizationError(.empty, message: "digests not received") + } + let digests = try JSONDecoder().decode([String].self, from: responseData) + return digests + } +} + +extension ImageDescription { + fileprivate func nameFromAnnotation() -> String? { + guard let annotations = self.descriptor.annotations else { + return nil + } + guard let name = annotations[AnnotationKeys.containerizationImageName] else { + return nil + } + return name + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientKernel.swift b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift new file mode 100644 index 0000000..3cac469 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientKernel.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation +import TerminalProgress + +public struct ClientKernel { + static let serviceIdentifier = "com.apple.container.apiserver" +} + +extension ClientKernel { + private static func newClient() -> XPCClient { + XPCClient(service: serviceIdentifier) + } + + public static func installKernel(kernelFilePath: String, platform: SystemPlatform, force: Bool) async throws { + let client = newClient() + let message = XPCMessage(route: .installKernel) + + message.set(key: .kernelFilePath, value: kernelFilePath) + message.set(key: .kernelForce, value: force) + + let platformData = try JSONEncoder().encode(platform) + message.set(key: .systemPlatform, value: platformData) + try await client.send(message) + } + + public static func installKernelFromTar(tarFile: String, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler? = nil, force: Bool) + async throws + { + let client = newClient() + let message = XPCMessage(route: .installKernel) + + message.set(key: .kernelTarURL, value: tarFile) + message.set(key: .kernelFilePath, value: kernelFilePath) + message.set(key: .kernelForce, value: force) + + let platformData = try JSONEncoder().encode(platform) + message.set(key: .systemPlatform, value: platformData) + + var progressUpdateClient: ProgressUpdateClient? + if let progressUpdate { + progressUpdateClient = await ProgressUpdateClient(for: progressUpdate, request: message) + } + + try await client.send(message) + await progressUpdateClient?.finish() + } + + @discardableResult + public static func getDefaultKernel(for platform: SystemPlatform) async throws -> Kernel { + let client = newClient() + let message = XPCMessage(route: .getDefaultKernel) + + let platformData = try JSONEncoder().encode(platform) + message.set(key: .systemPlatform, value: platformData) + do { + let reply = try await client.send(message) + guard let kData = reply.dataNoCopy(key: .kernel) else { + throw ContainerizationError(.internalError, message: "missing kernel data from XPC response") + } + + let kernel = try JSONDecoder().decode(Kernel.self, from: kData) + return kernel + } catch let err as ContainerizationError { + guard err.isCode(.notFound) else { + throw err + } + throw ContainerizationError( + .notFound, message: "default kernel not configured for architecture \(platform.architecture), please use the `container system kernel set` command to configure it") + } + } +} + +extension SystemPlatform { + public static var current: SystemPlatform { + switch Platform.current.architecture { + case "arm64": + return .linuxArm + case "amd64": + return .linuxAmd + default: + fatalError("unknown architecture") + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientProcess.swift b/Sources/Services/ContainerAPIService/Client/ClientProcess.swift new file mode 100644 index 0000000..5a5f854 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientProcess.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOCI +import ContainerizationOS +import Foundation +import NIOCore +import NIOPosix +import TerminalProgress + +/// A protocol that defines the methods and data members available to a process +/// started inside of a container. +public protocol ClientProcess: Sendable { + /// Identifier for the process. + var id: String { get } + + /// Start the underlying process inside of the container. + func start() async throws + /// Send a terminal resize request to the process `id`. + func resize(_ size: Terminal.Size) async throws + /// Send a signal to the process `id`. + /// Kill does not wait for the process to exit, it only delivers the signal. + func kill(_ signal: Int32) async throws + /// Wait for the process `id` to complete and return its exit code. + /// This method blocks until the process exits and the code is obtained. + func wait() async throws -> Int32 +} + +struct ClientProcessImpl: ClientProcess, Sendable { + static let serviceIdentifier = "com.apple.container.apiserver" + + /// ID of the process. + public var id: String { + processId ?? containerId + } + + /// Identifier of the container. + public let containerId: String + + /// Identifier of a process. That is running inside of a container. + /// This field is nil if the process this objects refers to is the + /// init process of the container. + public let processId: String? + + private let xpcClient: XPCClient + + init(containerId: String, processId: String? = nil, xpcClient: XPCClient) { + self.containerId = containerId + self.processId = processId + self.xpcClient = xpcClient + } + + /// Start the process. + public func start() async throws { + let request = XPCMessage(route: .containerStartProcess) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + + try await xpcClient.send(request) + } + + /// Send a signal to the process. + public func kill(_ signal: Int32) async throws { + let request = XPCMessage(route: .containerKill) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + request.set(key: .signal, value: Int64(signal)) + + try await xpcClient.send(request) + } + + /// Resize the processes PTY if it has one. + public func resize(_ size: Terminal.Size) async throws { + let request = XPCMessage(route: .containerResize) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + request.set(key: .width, value: UInt64(size.width)) + request.set(key: .height, value: UInt64(size.height)) + + try await xpcClient.send(request) + } + + /// Wait for the process to exit. + public func wait() async throws -> Int32 { + let request = XPCMessage(route: .containerWait) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: id) + + let response = try await xpcClient.send(request) + let code = response.int64(key: .exitCode) + return Int32(code) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ClientVolume.swift b/Sources/Services/ContainerAPIService/Client/ClientVolume.swift new file mode 100644 index 0000000..36cf244 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ClientVolume.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization +import Foundation + +public struct ClientVolume { + static let serviceIdentifier = "com.apple.container.apiserver" + + public static func create( + name: String, + driver: String = "local", + driverOpts: [String: String] = [:], + labels: [String: String] = [:] + ) async throws -> VolumeConfiguration { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .volumeCreate) + message.set(key: .volumeName, value: name) + message.set(key: .volumeDriver, value: driver) + + let driverOptsData = try JSONEncoder().encode(driverOpts) + message.set(key: .volumeDriverOpts, value: driverOptsData) + + let labelsData = try JSONEncoder().encode(labels) + message.set(key: .volumeLabels, value: labelsData) + + let reply = try await client.send(message) + + guard let responseData = reply.dataNoCopy(key: .volume) else { + throw VolumeError.storageError("invalid response from server") + } + + return try JSONDecoder().decode(VolumeConfiguration.self, from: responseData) + } + + public static func delete(name: String) async throws { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .volumeDelete) + message.set(key: .volumeName, value: name) + + _ = try await client.send(message) + } + + public static func list() async throws -> [VolumeConfiguration] { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .volumeList) + let reply = try await client.send(message) + + guard let responseData = reply.dataNoCopy(key: .volumes) else { + return [] + } + + return try JSONDecoder().decode([VolumeConfiguration].self, from: responseData) + } + + public static func inspect(_ name: String) async throws -> VolumeConfiguration { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .volumeInspect) + message.set(key: .volumeName, value: name) + + let reply = try await client.send(message) + + guard let responseData = reply.dataNoCopy(key: .volume) else { + throw VolumeError.volumeNotFound(name) + } + + return try JSONDecoder().decode(VolumeConfiguration.self, from: responseData) + } + + public static func volumeDiskUsage(name: String) async throws -> UInt64 { + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .volumeDiskUsage) + message.set(key: .volumeName, value: name) + let reply = try await client.send(message) + + let size = reply.uint64(key: .volumeSize) + return size + } +} diff --git a/Sources/Services/ContainerAPIService/Client/Constants.swift b/Sources/Services/ContainerAPIService/Client/Constants.swift new file mode 100644 index 0000000..c5ab8fe --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/Constants.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// Global constants for the container API clients. +public enum Constants { + /// The keychain ID to use for registry credentials. + public static let keychainID = "com.apple.container.registry" +} diff --git a/Sources/Services/ContainerAPIService/Client/ContainerClient.swift b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift new file mode 100644 index 0000000..b1b64a6 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ContainerClient.swift @@ -0,0 +1,391 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation + +/// A client for interacting with the container API server. +/// +/// This client holds a reusable XPC connection and provides methods for +/// container lifecycle operations. All methods that operate on a specific +/// container take an `id` parameter. +public struct ContainerClient: Sendable { + private static let serviceIdentifier = "com.apple.container.apiserver" + + private let xpcClient: XPCClient + + /// Creates a new container client with a connection to the API server. + public init() { + self.xpcClient = XPCClient(service: Self.serviceIdentifier) + } + + @discardableResult + private func xpcSend( + message: XPCMessage, + timeout: Duration? = XPCClient.xpcRegistrationTimeout + ) async throws -> XPCMessage { + try await xpcClient.send(message, responseTimeout: timeout) + } + + /// Create a new container with the given configuration. + public func create( + configuration: ContainerConfiguration, + options: ContainerCreateOptions = .default, + kernel: Kernel, + initImage: String? = nil, + runtimeData: Data? = nil + ) async throws { + do { + let request = XPCMessage(route: .containerCreate) + + let data = try JSONEncoder().encode(configuration) + let kdata = try JSONEncoder().encode(kernel) + let odata = try JSONEncoder().encode(options) + request.set(key: .containerConfig, value: data) + request.set(key: .kernel, value: kdata) + request.set(key: .containerOptions, value: odata) + + if let initImage { + request.set(key: .initImage, value: initImage) + } + + if let runtimeData { + request.set(key: .runtimeData, value: runtimeData) + } + + try await xpcSend(message: request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create container", + cause: error + ) + } + } + + /// List containers matching the given filters. + public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] { + do { + let request = XPCMessage(route: .containerList) + let filterData = try JSONEncoder().encode(filters) + request.set(key: .listFilters, value: filterData) + + let response = try await xpcSend( + message: request, + timeout: .seconds(10) + ) + let data = response.dataNoCopy(key: .containers) + guard let data else { + return [] + } + return try JSONDecoder().decode([ContainerSnapshot].self, from: data) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to list containers", + cause: error + ) + } + } + + /// Get the container for the provided id. + public func get(id: String) async throws -> ContainerSnapshot { + let containers = try await list(filters: ContainerListFilters(ids: [id])) + guard let container = containers.first else { + throw ContainerizationError( + .notFound, + message: "get failed: container \(id) not found" + ) + } + return container + } + + /// Bootstrap the container's init process. + public func bootstrap( + id: String, + stdio: [FileHandle?], + dynamicEnv: [String: String] = [:] + ) async throws -> ClientProcess { + let request = XPCMessage(route: .containerBootstrap) + + for (i, h) in stdio.enumerated() { + let key: XPCKeys = try { + switch i { + case 0: .stdin + case 1: .stdout + case 2: .stderr + default: + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") + } + }() + + if let h { + request.set(key: key, value: h) + } + } + + do { + let dynamicEnv = try JSONEncoder().encode(dynamicEnv) + request.set(key: .dynamicEnv, value: dynamicEnv) + + request.set(key: .id, value: id) + try await xpcClient.send(request) + return ClientProcessImpl(containerId: id, xpcClient: xpcClient) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to bootstrap container", + cause: error + ) + } + } + + /// Send a signal to the container. + public func kill(id: String, signal: String) async throws { + do { + let request = XPCMessage(route: .containerKill) + request.set(key: .id, value: id) + request.set(key: .processIdentifier, value: id) + request.set(key: .signal, value: signal) + + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to kill container", + cause: error + ) + } + } + + /// Stop the container and all processes currently executing inside. + public func stop(id: String, opts: ContainerStopOptions = ContainerStopOptions.default) async throws { + do { + let request = XPCMessage(route: .containerStop) + let data = try JSONEncoder().encode(opts) + request.set(key: .id, value: id) + request.set(key: .stopOptions, value: data) + + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to stop container", + cause: error + ) + } + } + + /// Delete the container along with any resources. + public func delete(id: String, force: Bool = false) async throws { + do { + let request = XPCMessage(route: .containerDelete) + request.set(key: .id, value: id) + request.set(key: .forceDelete, value: force) + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to delete container", + cause: error + ) + } + } + + /// Get the disk usage for a container. + public func diskUsage(id: String) async throws -> UInt64 { + let request = XPCMessage(route: .containerDiskUsage) + request.set(key: .id, value: id) + let reply = try await xpcClient.send(request) + + let size = reply.uint64(key: .containerSize) + return size + } + + /// Create a new process inside a running container. + /// The process is in a created state and must still be started. + public func createProcess( + containerId: String, + processId: String, + configuration: ProcessConfiguration, + stdio: [FileHandle?] + ) async throws -> ClientProcess { + do { + let request = XPCMessage(route: .containerCreateProcess) + request.set(key: .id, value: containerId) + request.set(key: .processIdentifier, value: processId) + + let data = try JSONEncoder().encode(configuration) + request.set(key: .processConfig, value: data) + + for (i, h) in stdio.enumerated() { + let key: XPCKeys = try { + switch i { + case 0: .stdin + case 1: .stdout + case 2: .stderr + default: + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") + } + }() + + if let h { + request.set(key: key, value: h) + } + } + + try await xpcClient.send(request) + return ClientProcessImpl(containerId: containerId, processId: processId, xpcClient: xpcClient) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create process in container", + cause: error + ) + } + } + + /// Get the log file handles for a container. + public func logs(id: String) async throws -> [FileHandle] { + do { + let request = XPCMessage(route: .containerLogs) + request.set(key: .id, value: id) + + let response = try await xpcClient.send(request) + let fds = response.fileHandles(key: .logs) + guard let fds else { + throw ContainerizationError( + .internalError, + message: "no log fds returned" + ) + } + return fds + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get logs for container \(id)", + cause: error + ) + } + } + + /// Dial a port on the container via vsock. + public func dial(id: String, port: UInt32) async throws -> FileHandle { + let request = XPCMessage(route: .containerDial) + request.set(key: .id, value: id) + request.set(key: .port, value: UInt64(port)) + + let response: XPCMessage + do { + response = try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to dial port \(port) on container", + cause: error + ) + } + guard let fh = response.fileHandle(key: .fd) else { + throw ContainerizationError( + .internalError, + message: "failed to get fd for vsock port \(port)" + ) + } + return fh + } + + /// Copy a file or directory from the host into the container. + public func copyIn(id: String, source: String, destination: String, mode: UInt32 = 0o644, createParents: Bool = true) async throws { + let request = XPCMessage(route: .containerCopyIn) + request.set(key: .id, value: id) + request.set(key: .sourcePath, value: source) + request.set(key: .destinationPath, value: destination) + request.set(key: .fileMode, value: UInt64(mode)) + request.set(key: .createParents, value: createParents) + + do { + try await xpcSend(message: request, timeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy into container \(id)", + cause: error + ) + } + } + + /// Copy a file or directory from the container to the host. + public func copyOut(id: String, source: String, destination: String, createParents: Bool = true) async throws { + let request = XPCMessage(route: .containerCopyOut) + request.set(key: .id, value: id) + request.set(key: .sourcePath, value: source) + request.set(key: .destinationPath, value: destination) + request.set(key: .createParents, value: createParents) + + do { + try await xpcSend(message: request, timeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy from container \(id)", + cause: error + ) + } + } + + /// Get resource usage statistics for a container. + public func stats(id: String) async throws -> ContainerStats { + let request = XPCMessage(route: .containerStats) + request.set(key: .id, value: id) + + do { + let response = try await xpcClient.send(request) + guard let data = response.dataNoCopy(key: .statistics) else { + throw ContainerizationError( + .internalError, + message: "no statistics data returned" + ) + } + return try JSONDecoder().decode(ContainerStats.self, from: data) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get statistics for container \(id)", + cause: error + ) + } + } + + public func export(id: String, archive: URL) async throws { + let request = XPCMessage(route: .containerExport) + request.set(key: .id, value: id) + request.set(key: .archive, value: archive.absolutePath()) + + do { + try await xpcClient.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to export container", + cause: error + ) + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ContainerizationProgressAdapter.swift b/Sources/Services/ContainerAPIService/Client/ContainerizationProgressAdapter.swift new file mode 100644 index 0000000..ce407d5 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ContainerizationProgressAdapter.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import TerminalProgress + +public enum ContainerizationProgressAdapter: ProgressAdapter { + public static func handler(from progressUpdate: ProgressUpdateHandler?) -> ProgressHandler? { + guard let progressUpdate else { + return nil + } + return { events in + var updateEvents = [ProgressUpdateEvent]() + for event in events { + if event.event == "add-items" { + if let items = event.value as? Int { + updateEvents.append(.addItems(items)) + } + } else if event.event == "add-total-items" { + if let totalItems = event.value as? Int { + updateEvents.append(.addTotalItems(totalItems)) + } + } else if event.event == "add-size" { + if let size = event.value as? Int64 { + updateEvents.append(.addSize(size)) + } + } else if event.event == "add-total-size" { + if let totalSize = event.value as? Int64 { + updateEvents.append(.addTotalSize(totalSize)) + } + } + } + await progressUpdate(updateEvents) + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/DefaultPlatform.swift b/Sources/Services/ContainerAPIService/Client/DefaultPlatform.swift new file mode 100644 index 0000000..f8135de --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/DefaultPlatform.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import Foundation +import Logging + +/// Resolves the default platform from the `CONTAINER_DEFAULT_PLATFORM` environment variable. +/// +/// When set, this variable overrides the native platform as the default for commands +/// that support `--platform`. Explicit `--platform` flags always take precedence. +public enum DefaultPlatform { + /// The name of the environment variable checked for a default platform. + public static let environmentVariable = "CONTAINER_DEFAULT_PLATFORM" + + /// Reads and parses the `CONTAINER_DEFAULT_PLATFORM` environment variable. + /// + /// When a valid platform is found and a logger is provided, a warning is emitted + /// to inform the user that the environment variable is being used. + /// + /// - Parameters: + /// - environment: The environment dictionary to read from. Defaults to the current process environment. + /// - log: An optional logger. When provided, a warning is logged if the environment variable is active. + /// - Returns: The parsed platform, or `nil` if the variable is not set or empty. + /// - Throws: ContainerizationError if the variable is set but contains an invalid platform string. + public static func fromEnvironment( + environment: [String: String] = ProcessInfo.processInfo.environment, + log: Logger? = nil + ) throws -> ContainerizationOCI.Platform? { + guard let value = environment[environmentVariable], + !value.isEmpty + else { + return nil + } + let platform: ContainerizationOCI.Platform + do { + platform = try ContainerizationOCI.Platform(from: value) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "invalid platform \"\(value)\" in \(environmentVariable) environment variable", + cause: error + ) + } + logNotice(platform, log: log) + return platform + } + + /// Resolves the platform for commands where `--os` and `--arch` are optional (image pull, push, save). + /// + /// Precedence: `--platform` > `--os`/`--arch` > `CONTAINER_DEFAULT_PLATFORM` > `nil`. + /// + /// - Parameters: + /// - platform: The value of the `--platform` flag, if provided. + /// - os: The value of the `--os` flag, if provided. + /// - arch: The value of the `--arch` flag, if provided. + /// - environment: The environment dictionary to read from. Defaults to the current process environment. + /// - log: An optional logger for environment variable notices. + /// - Returns: The resolved platform, or `nil` if no platform information is available. + /// - Throws: ContainerizationError if a platform string (from flags or environment) is invalid. + public static func resolve( + platform: String?, + os: String?, + arch: String?, + environment: [String: String] = ProcessInfo.processInfo.environment, + log: Logger? = nil + ) throws -> ContainerizationOCI.Platform? { + if let platform { + return try ContainerizationOCI.Platform(from: platform) + } + if let arch { + return try ContainerizationOCI.Platform(from: "\(os ?? "linux")/\(arch)") + } + if let os { + return try ContainerizationOCI.Platform(from: "\(os)/\(arch ?? Arch.hostArchitecture().rawValue)") + } + return try fromEnvironment(environment: environment, log: log) + } + + /// Resolves the platform for commands where `--os` and `--arch` have defaults (run, create). + /// + /// Precedence: `--platform` > `CONTAINER_DEFAULT_PLATFORM` > `--os`/`--arch` defaults. + /// + /// - Parameters: + /// - platform: The value of the `--platform` flag, if provided. + /// - os: The default OS value (always present). + /// - arch: The default architecture value (always present). + /// - environment: The environment dictionary to read from. Defaults to the current process environment. + /// - log: An optional logger for environment variable notices. + /// - Returns: The resolved platform. Always returns a value since os/arch defaults are provided. + /// - Throws: ContainerizationError if a platform string (from flags or environment) is invalid. + public static func resolveWithDefaults( + platform: String?, + os: String, + arch: String, + environment: [String: String] = ProcessInfo.processInfo.environment, + log: Logger? = nil + ) throws -> ContainerizationOCI.Platform { + if let platform { + return try Parser.platform(from: platform) + } + if let envPlatform = try fromEnvironment(environment: environment, log: log) { + return envPlatform + } + return Parser.platform(os: os, arch: arch) + } + + private static func logNotice(_ platform: ContainerizationOCI.Platform, log: Logger?) { + guard let log else { return } + log.warning( + "using platform from environment variable", + metadata: [ + "platform": "\(platform.description)", + "variable": "\(environmentVariable)", + ] + ) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/DiskUsage.swift b/Sources/Services/ContainerAPIService/Client/DiskUsage.swift new file mode 100644 index 0000000..6a9c86c --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/DiskUsage.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Disk usage statistics for all resource types +public struct DiskUsageStats: Sendable, Codable { + /// Disk usage for images + public var images: ResourceUsage + + /// Disk usage for containers + public var containers: ResourceUsage + + /// Disk usage for volumes + public var volumes: ResourceUsage + + public init(images: ResourceUsage, containers: ResourceUsage, volumes: ResourceUsage) { + self.images = images + self.containers = containers + self.volumes = volumes + } +} + +/// Disk usage statistics for a specific resource type +public struct ResourceUsage: Sendable, Codable { + /// Total number of resources + public var total: Int + + /// Number of active/running resources + public var active: Int + + /// Total size in bytes + public var sizeInBytes: UInt64 + + /// Reclaimable size in bytes (from unused/inactive resources) + public var reclaimable: UInt64 + + public init(total: Int, active: Int, sizeInBytes: UInt64, reclaimable: UInt64) { + self.total = total + self.active = active + self.sizeInBytes = sizeInBytes + self.reclaimable = reclaimable + } +} diff --git a/Sources/Services/ContainerAPIService/Client/FileDownloader.swift b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift new file mode 100644 index 0000000..83aabfc --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/FileDownloader.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// 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 AsyncHTTPClient +import ContainerizationError +import ContainerizationExtras +import Foundation +import TerminalProgress + +public struct FileDownloader { + public static func downloadFile(url: URL, to destination: URL, progressUpdate: ProgressUpdateHandler? = nil) async throws { + let request = try HTTPClient.Request(url: url) + + let delegate = try FileDownloadDelegate( + path: destination.path(), + reportHead: { + let expectedSizeString = $0.headers["Content-Length"].first ?? "" + if let expectedSize = Int64(expectedSizeString) { + if let progressUpdate { + Task { + await progressUpdate([ + .addTotalSize(expectedSize) + ]) + } + } + } + }, + reportProgress: { + let receivedBytes = Int64($0.receivedBytes) + if let progressUpdate { + Task { + await progressUpdate([ + .setSize(receivedBytes) + ]) + } + } + }) + + let client = FileDownloader.createClient(url: url) + do { + _ = try await client.execute(request: request, delegate: delegate).get() + } catch { + try? await client.shutdown() + throw error + } + try await client.shutdown() + } + + private static func createClient(url: URL) -> HTTPClient { + var httpConfiguration = HTTPClient.Configuration() + // for large file downloads we keep a generous connect timeout, and + // no read timeout since download durations can vary + httpConfiguration.timeout = HTTPClient.Configuration.Timeout( + connect: .seconds(30), + read: .none + ) + if let host = url.host { + let proxyURL = ProxyUtils.proxyFromEnvironment(scheme: url.scheme, host: host) + if let proxyURL, let proxyHost = proxyURL.host { + httpConfiguration.proxy = HTTPClient.Configuration.Proxy.server(host: proxyHost, port: proxyURL.port ?? 8080) + } + } + + return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/Flags.swift b/Sources/Services/ContainerAPIService/Client/Flags.swift new file mode 100644 index 0000000..d26eddd --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/Flags.swift @@ -0,0 +1,395 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation + +public struct Flags { + public struct Logging: ParsableArguments { + public init() {} + + public init(debug: Bool) { + self.debug = debug + } + + @Flag(name: .long, help: "Enable debug output [environment: CONTAINER_DEBUG]") + public var debug = false + } + + public struct Process: ParsableArguments { + public init() {} + + public init( + cwd: String?, + env: [String], + envFile: [String], + gid: UInt32?, + interactive: Bool, + tty: Bool, + uid: UInt32?, + ulimits: [String], + user: String? + ) { + self.cwd = cwd + self.env = env + self.envFile = envFile + self.gid = gid + self.interactive = interactive + self.tty = tty + self.uid = uid + self.ulimits = ulimits + self.user = user + } + + @Option(name: .shortAndLong, help: "Set environment variables (key=value, or just key to inherit from host)") + public var env: [String] = [] + + @Option( + name: .long, + help: "Read in a file of environment variables (key=value format, ignores # comments and blank lines)" + ) + public var envFile: [String] = [] + + @Option(name: .long, help: "Set the group ID for the process") + public var gid: UInt32? + + @Flag(name: .shortAndLong, help: "Keep the standard input open even if not attached") + public var interactive = false + + @Flag(name: .shortAndLong, help: "Open a TTY with the process") + public var tty = false + + @Option(name: .shortAndLong, help: "Set the user for the process (format: name|uid[:gid])") + public var user: String? + + @Option(name: .long, help: "Set the user ID for the process") + public var uid: UInt32? + + @Option( + name: [.customShort("w"), .customLong("workdir"), .long], + help: .init( + "Set the initial working directory inside the container", + valueName: "dir" + ) + ) + public var cwd: String? + + @Option( + name: .customLong("ulimit"), + help: .init( + "Set resource limits (format: =[:])", + valueName: "limit" + ) + ) + public var ulimits: [String] = [] + } + + public struct Resource: ParsableArguments { + public init() {} + + public init(cpus: Int64?, memory: String?) { + self.cpus = cpus + self.memory = memory + } + + @Option(name: .shortAndLong, help: "Number of CPUs to allocate to the container") + public var cpus: Int64? + + @Option( + name: .shortAndLong, + help: "Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix" + ) + public var memory: String? + } + + public struct DNS: ParsableArguments { + public init() {} + + public init(domain: String?, nameservers: [String], options: [String], searchDomains: [String]) { + self.domain = domain + self.nameservers = nameservers + self.options = options + self.searchDomains = searchDomains + } + + @Option( + name: .customLong("dns"), + help: .init("DNS nameserver IP address", valueName: "ip") + ) + public var nameservers: [String] = [] + + @Option( + name: .customLong("dns-domain"), + help: .init("Default DNS domain", valueName: "domain") + ) + public var domain: String? = nil + + @Option( + name: .customLong("dns-option"), + help: .init("DNS options", valueName: "option") + ) + public var options: [String] = [] + + @Option( + name: .customLong("dns-search"), + help: .init("DNS search domains", valueName: "domain") + ) + public var searchDomains: [String] = [] + } + + public struct Registry: ParsableArguments { + public init() {} + + public init(scheme: String) { + self.scheme = scheme + } + + @Option(help: "Scheme to use when connecting to the container registry. One of (http, https, auto)") + public var scheme: String = "auto" + } + + public struct Management: ParsableArguments { + public init() {} + + public init( + arch: String, + capAdd: [String], + capDrop: [String], + cidfile: String, + detach: Bool, + dns: Flags.DNS, + dnsDisabled: Bool, + entrypoint: String?, + initImage: String?, + kernel: String?, + labels: [String], + mounts: [String], + name: String?, + networks: [String], + os: String, + platform: String?, + publishPorts: [String], + publishSockets: [String], + readOnly: Bool, + remove: Bool, + rosetta: Bool, + runtime: String?, + ssh: Bool, + shmSize: String?, + tmpFs: [String], + useInit: Bool, + virtualization: Bool, + volumes: [String] + ) { + self.arch = arch + self.capAdd = capAdd + self.capDrop = capDrop + self.cidfile = cidfile + self.detach = detach + self.dns = dns + self.dnsDisabled = dnsDisabled + self.entrypoint = entrypoint + self.initImage = initImage + self.kernel = kernel + self.labels = labels + self.mounts = mounts + self.name = name + self.networks = networks + self.os = os + self.platform = platform + self.publishPorts = publishPorts + self.publishSockets = publishSockets + self.readOnly = readOnly + self.remove = remove + self.rosetta = rosetta + self.runtime = runtime + self.ssh = ssh + self.shmSize = shmSize + self.tmpFs = tmpFs + self.useInit = useInit + self.virtualization = virtualization + self.volumes = volumes + } + + @Option(name: .shortAndLong, help: "Set arch if image can target multiple architectures") + public var arch: String = Arch.hostArchitecture().rawValue + + @Option( + name: .customLong("cap-add"), + help: .init("Add a Linux capability (e.g. CAP_NET_RAW, or ALL)", valueName: "cap") + ) + public var capAdd: [String] = [] + + @Option( + name: .customLong("cap-drop"), + help: .init("Drop a Linux capability (e.g. CAP_NET_RAW, or ALL)", valueName: "cap") + ) + public var capDrop: [String] = [] + + @Option(name: .long, help: "Write the container ID to the path provided") + public var cidfile = "" + + @Flag(name: .shortAndLong, help: "Run the container and detach from the process") + public var detach = false + + @OptionGroup + public var dns: Flags.DNS + + @Option( + name: .long, + help: .init( + "Override the entrypoint of the image", + valueName: "cmd" + ) + ) + public var entrypoint: String? + + @Flag(name: .customLong("init"), help: "Run an init process inside the container that forwards signals and reaps processes") + public var useInit = false + + @Option( + name: .long, + help: .init("Use a custom init image instead of the default", valueName: "image") + ) + public var initImage: String? + + @Option( + name: .shortAndLong, + help: .init("Set a custom kernel path", valueName: "path"), + completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + } + ) + public var kernel: String? + + @Option(name: [.short, .customLong("label")], help: "Add a key=value label to the container") + public var labels: [String] = [] + + @Option(name: .customLong("mount"), help: "Add a mount to the container (format: type=<>,source=<>,target=<>,readonly)") + public var mounts: [String] = [] + + @Option(name: .long, help: "Use the specified name as the container ID") + public var name: String? + + @Option(name: [.customLong("network")], help: "Attach the container to a network (format: [,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE])") + public var networks: [String] = [] + + @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") + public var dnsDisabled = false + + @Option(name: .long, help: "Set OS if image can target multiple operating systems") + public var os = "linux" + + @Option( + name: [.customShort("p"), .customLong("publish")], + help: .init( + "Publish a port from container to host (format: [host-ip:]host-port:container-port[/protocol])", + valueName: "spec" + ) + ) + public var publishPorts: [String] = [] + + @Option(name: .long, help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch [environment: CONTAINER_DEFAULT_PLATFORM]") + public var platform: String? + + @Option( + name: .customLong("publish-socket"), + help: .init( + "Publish a socket from container to host (format: host_path:container_path)", + valueName: "spec" + ) + ) + public var publishSockets: [String] = [] + + @Flag(name: .long, help: "Mount the container's root filesystem as read-only") + public var readOnly = false + + @Flag(name: [.customLong("rm"), .long], help: "Remove the container after it stops") + public var remove = false + + @Flag(name: .long, help: "Enable Rosetta in the container") + public var rosetta = false + + @Option(name: .long, help: "Set the runtime handler for the container (default: container-runtime-linux)") + public var runtime: String? + + @Flag(name: .long, help: "Forward SSH agent socket to container") + public var ssh = false + + @Option(name: .customLong("shm-size"), help: "Size of /dev/shm (e.g. 64M, 1G)") + public var shmSize: String? + + @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") + public var tmpFs: [String] = [] + + @Flag( + name: .long, + help: + "Expose virtualization capabilities to the container (requires host and guest support)" + ) + public var virtualization: Bool = false + + @Option(name: [.customLong("volume"), .short], help: "Bind mount a volume into the container") + public var volumes: [String] = [] + + public func validate() throws { + if dnsDisabled { + let hasDNSConfig = + !dns.nameservers.isEmpty + || dns.domain != nil + || !dns.options.isEmpty + || !dns.searchDomains.isEmpty + if hasDNSConfig { + throw ValidationError( + "`--no-dns` cannot be used with DNS configuration flags (`--dns`, `--dns-domain`, `--dns-option`, `--dns-search`)" + ) + } + } + } + } + + public struct Progress: ParsableArguments { + public init() {} + + public init(progress: ProgressType) { + self.progress = progress + } + + public enum ProgressType: String, ExpressibleByArgument { + case auto + case none + case ansi + case plain + case color + } + + @Option(name: .long, help: ArgumentHelp("Progress type (format: auto|none|ansi|plain|color)", valueName: "type")) + public var progress: ProgressType = .auto + } + + public struct ImageFetch: ParsableArguments { + public init() {} + + public init(maxConcurrentDownloads: Int) { + self.maxConcurrentDownloads = maxConcurrentDownloads + } + + @Option(name: .long, help: "Maximum number of concurrent downloads") + public var maxConcurrentDownloads: Int = 3 + } +} diff --git a/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift new file mode 100644 index 0000000..a72b04d --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/HostDNSResolver.swift @@ -0,0 +1,161 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import DNSServer +import Foundation +import SystemPackage + +/// Functions for managing local DNS domains for containers. +public struct HostDNSResolver { + public static let defaultConfigPath = FilePath("/etc/resolver") + + // prefix used to mark our files as /etc/resolver/{prefix}{domainName} + public static let containerizationPrefix = "containerization." + public static let localhostOptionsRegex = #"options localhost:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})"# + + private let configPath: FilePath + + public init(configPath: FilePath = Self.defaultConfigPath) { + self.configPath = configPath + } + + /// Creates a DNS resolver configuration file for domain resolved by the application. + public func createDomain(name: DNSName, localhost: IPAddress? = nil) throws { + let name = name.pqdn + + let resolverFilename = "\(Self.containerizationPrefix)\(name)" + guard let component = FilePath.Component(resolverFilename) else { + throw ContainerizationError(.invalidState, message: "invalid resolver filename \(resolverFilename)") + } + let path = self.configPath.appending(component) + let fm: FileManager = FileManager.default + + var isDirectory: ObjCBool = false + if fm.fileExists(atPath: self.configPath.string, isDirectory: &isDirectory) { + guard isDirectory.boolValue else { + throw ContainerizationError(.invalidState, message: "expected \(self.configPath.string) to be a directory, but found a file") + } + } else { + try fm.createDirectory(atPath: self.configPath.string, withIntermediateDirectories: true) + } + + guard !fm.fileExists(atPath: path.string) else { + throw ContainerizationError(.exists, message: "domain \(name) already exists") + } + + let dnsPort = localhost == nil ? "2053" : "1053" + let options = + localhost.map { + HostDNSResolver.localhostOptionsRegex.replacingOccurrences( + of: #"\((.*?)\)"#, with: $0.description, options: .regularExpression) + } ?? "" + let resolverText = """ + domain \(name) + search \(name) + nameserver 127.0.0.1 + port \(dnsPort) + \(options) + """ + + try resolverText.write(toFile: path.string, atomically: true, encoding: .utf8) + } + + /// Removes a DNS resolver configuration file for domain resolved by the application. + public func deleteDomain(name: DNSName) throws -> IPAddress? { + let name = name.pqdn + + let resolverFilename = "\(Self.containerizationPrefix)\(name)" + guard let component = FilePath.Component(resolverFilename) else { + throw ContainerizationError(.invalidState, message: "invalid resolver filename \(resolverFilename)") + } + let path = self.configPath.appending(component) + let fm = FileManager.default + guard fm.fileExists(atPath: path.string) else { + throw ContainerizationError(.notFound, message: "domain \(name) at \(path) not found") + } + + var localhost: IPAddress? + let content = try String(contentsOfFile: path.string, encoding: .utf8) + if let match = content.firstMatch(of: try Regex(HostDNSResolver.localhostOptionsRegex)) { + localhost = try? IPAddress(String(match[1].substring ?? "")) + } + + do { + try fm.removeItem(atPath: path.string) + } catch { + throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)") + } + + return localhost + } + + /// Lists application-created local DNS domains. + public func listDomains() -> [DNSName] { + let fm: FileManager = FileManager.default + guard let filenames = try? fm.contentsOfDirectory(atPath: self.configPath.string) else { + return [] + } + + return + filenames + .filter { $0.starts(with: Self.containerizationPrefix) } + .compactMap { filename -> DNSName? in + guard let component = FilePath.Component(filename) else { return nil } + return try? getDomainFromResolver(path: self.configPath.appending(component)) + } + .sorted { a, b in a.pqdn < b.pqdn } + } + + /// Reinitializes the macOS DNS daemon. + public static func reinitialize() throws { + do { + let kill = Foundation.Process() + kill.executableURL = URL(fileURLWithPath: "/usr/bin/killall") + kill.arguments = ["-HUP", "mDNSResponder"] + + let null = FileHandle.nullDevice + kill.standardOutput = null + kill.standardError = null + + try kill.run() + kill.waitUntilExit() + let status = kill.terminationStatus + guard status == 0 else { + throw ContainerizationError(.internalError, message: "mDNSResponder restart failed with status \(status)") + } + } + } + + private func getDomainFromResolver(path: FilePath) throws -> DNSName? { + let text = try String(contentsOfFile: path.string, encoding: .utf8) + for line in text.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let components = trimmed.split(whereSeparator: { $0.isWhitespace }) + guard components.count == 2 else { + continue + } + guard components[0] == "domain" else { + continue + } + + return try? DNSName(String(components[1])) + } + + return nil + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift b/Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift new file mode 100644 index 0000000..a0cccf2 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ImageLoadResult.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// The result of loading an archive file into the image store. +public struct ImageLoadResult { + /// The successfully loaded images + public let images: [ClientImage] + + /// The archive member files that were not extracted due + /// to invalid paths or attempted symlink traversal. + public let rejectedMembers: [String] +} diff --git a/Sources/Services/ContainerAPIService/Client/NetworkClient.swift b/Sources/Services/ContainerAPIService/Client/NetworkClient.swift new file mode 100644 index 0000000..b7ea51f --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/NetworkClient.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation + +/// A client for managing virtual networks through the container API server. +/// +/// `NetworkClient` communicates with `container-apiserver` over XPC to create, +/// list, inspect, and delete networks. Each instance holds a dedicated XPC +/// connection; create one client and reuse it across related operations. +/// +/// ```swift +/// let client = NetworkClient() +/// let network = try await client.create(configuration: config) +/// let networks = try await client.list() +/// try await client.delete(id: network.id) +/// ``` +public struct NetworkClient: Sendable { + /// The Mach service name used to locate the container API server. + /// + /// Pass a different value to ``init(serviceIdentifier:)`` to connect to an + /// alternative service endpoint, for example during testing. + public static let defaultServiceIdentifier = "com.apple.container.apiserver" + + /// The name of the default network created automatically on first use. + public static let defaultNetworkName = "default" + + /// The reserved name that indicates a container should have no network attachment. + public static let noNetworkName = "none" + + private let xpcClient: XPCClient + + /// Creates a new network client connected to the given service endpoint. + /// + /// - Parameter serviceIdentifier: The Mach service name of the API server. + /// Defaults to ``defaultServiceIdentifier``. + public init(serviceIdentifier: String = Self.defaultServiceIdentifier) { + self.xpcClient = XPCClient(service: serviceIdentifier) + } + + @discardableResult + private func xpcSend( + message: XPCMessage, + timeout: Duration? = XPCClient.xpcRegistrationTimeout + ) async throws -> XPCMessage { + try await xpcClient.send(message, responseTimeout: timeout) + } + + /// Creates a new network with the given configuration. + /// + /// The API server launches a network plugin instance for the new network and + /// returns a ``NetworkResource`` reflecting the network once it is running. + /// + /// - Parameter configuration: The configuration describing the network to create. + /// - Returns: The running state of the newly created network. + /// - Throws: ``ContainerizationError`` if the server does not return a valid + /// network resource, or if the underlying XPC call fails. + public func create(configuration: NetworkConfiguration) async throws -> NetworkResource { + let request = XPCMessage(route: .networkCreate) + request.set(key: .networkId, value: configuration.id) + + let data = try JSONEncoder().encode(configuration) + request.set(key: .networkConfig, value: data) + + let response = try await xpcSend(message: request) + + guard let resourceData = response.dataNoCopy(key: .networkResource) else { + throw ContainerizationError(.invalidArgument, message: "network configuration not received") + } + return try JSONDecoder().decode(NetworkResource.self, from: resourceData) + } + + /// Returns the current state of all networks known to the API server. + /// + /// - Returns: An array of ``NetworkResource`` values, or an empty array if no + /// networks exist or the server returns no data. + /// - Throws: ``ContainerizationError`` if the underlying XPC call fails. + public func list() async throws -> [NetworkResource] { + let request = XPCMessage(route: .networkList) + + let response = try await xpcSend(message: request, timeout: .seconds(1)) + + guard let resourceData = response.dataNoCopy(key: .networkResources) else { + return [] + } + return try JSONDecoder().decode([NetworkResource].self, from: resourceData) + } + + /// Returns the network with the given identifier. + /// + /// - Parameter id: The identifier of the network to look up. + /// - Returns: The ``NetworkResource`` for the matching network. + /// - Throws: ``ContainerizationError/notFound`` if no network with the given + /// identifier exists, or a communication error if the XPC call fails. + public func get(id: String) async throws -> NetworkResource { + let networks = try await list() + guard let network = networks.first(where: { $0.id == id }) else { + throw ContainerizationError(.notFound, message: "network \(id) not found") + } + return network + } + + /// Deletes the network with the given identifier. + /// + /// Deletion succeeds only when no containers are currently attached to the + /// network. The default network cannot be deleted. + /// + /// - Parameter id: The identifier of the network to delete. + /// - Throws: ``ContainerizationError`` if the network has active attachments, + /// if the network is the built-in default, or if the XPC call fails. + public func delete(id: String) async throws { + let request = XPCMessage(route: .networkDelete) + request.set(key: .networkId, value: id) + try await xpcSend(message: request) + } + + /// The built-in network, if one exists. + /// + /// The built-in network is created automatically on first use and cannot be + /// deleted. Returns `nil` if the API server cannot find a network with the + /// built-in resource labels. + /// + /// - Throws: ``ContainerizationError`` if the underlying XPC call fails. + public var builtin: NetworkResource? { + get async throws { + try await list().first { $0.isBuiltin } + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/PacketFilter.swift b/Sources/Services/ContainerAPIService/Client/PacketFilter.swift new file mode 100644 index 0000000..bd4035d --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/PacketFilter.swift @@ -0,0 +1,204 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import DNSServer +import Foundation +import SystemPackage + +public struct PacketFilter { + public static let anchor = "com.apple.container" + public static let defaultConfigPath = FilePath("/etc/pf.conf") + public static let defaultAnchorsPath = FilePath("/etc/pf.anchors") + + private let configPath: FilePath + private let anchorsPath: FilePath + + public init(configPath: FilePath = Self.defaultConfigPath, anchorsPath: FilePath = Self.defaultAnchorsPath) { + self.configPath = configPath + self.anchorsPath = anchorsPath + } + + public func createRedirectRule(from: IPAddress, to: IPAddress, domain: DNSName) throws { + guard type(of: from) == type(of: to) else { + throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)") + } + + let fm: FileManager = FileManager.default + + let anchorPath = self.anchorsPath.appending(Self.anchor) + + let inet: String + switch from { + case .v4: inet = "inet" + case .v6: inet = "inet6" + } + let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain.pqdn)" + + var content = "" + if fm.fileExists(atPath: anchorPath.string) { + content = try String(contentsOfFile: anchorPath.string, encoding: .utf8) + } else { + try addAnchorToConfig() + } + + var lines = content.components(separatedBy: .newlines) + if !content.contains(redirectRule) { + lines.insert(redirectRule, at: lines.endIndex - 1) + } + + try lines.joined(separator: "\n").write(toFile: anchorPath.string, atomically: true, encoding: .utf8) + } + + public func removeRedirectRule(from: IPAddress, to: IPAddress, domain: DNSName) throws { + guard type(of: from) == type(of: to) else { + throw ContainerizationError(.invalidArgument, message: "protocol does not match: \(from) vs. \(to)") + } + + let fm: FileManager = FileManager.default + + let anchorPath = self.anchorsPath.appending(Self.anchor) + + let inet: String + switch from { + case .v4: inet = "inet" + case .v6: inet = "inet6" + } + let redirectRule = "rdr \(inet) from any to \(from.description) -> \(to.description) # \(domain.pqdn)" + + guard fm.fileExists(atPath: anchorPath.string) else { + return + } + + let content = try String(contentsOfFile: anchorPath.string, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let removedLines = lines.filter { l in + l != redirectRule + } + + if removedLines == [""] { + try fm.removeItem(atPath: anchorPath.string) + try removeAnchorFromConfig() + } else { + try removedLines.joined(separator: "\n").write(toFile: anchorPath.string, atomically: true, encoding: .utf8) + } + } + + private func addAnchorToConfig() throws { + let fm: FileManager = FileManager.default + + let anchorPath = self.anchorsPath.appending(Self.anchor) + + /* PF requires strict ordering of anchors: + scrub-anchor, nat-anchor, rdr-anchor, dummynet-anchor, anchor, load anchor + */ + let anchorKeywords = ["scrub-anchor", "nat-anchor", "rdr-anchor", "dummynet-anchor", "anchor", "load anchor"] + let loadAnchorText = "load anchor \"\(Self.anchor)\" from \"\(anchorPath.string)\"" + + var content: String = "" + var lines: [String] = [] + if fm.fileExists(atPath: self.configPath.string) { + content = try String(contentsOfFile: self.configPath.string, encoding: .utf8) + } + lines = content.components(separatedBy: .newlines) + + for (i, keyword) in anchorKeywords[..<(anchorKeywords.endIndex - 1)].enumerated() { + let anchorText = "\(keyword) \"\(Self.anchor)\"" + + if content.contains(anchorText) { + continue + } + + let idx = lines.firstIndex { l in + anchorKeywords[i...].map { k in l.starts(with: k) }.contains(true) + } + lines.insert(anchorText, at: idx ?? lines.endIndex - 1) + } + + if !content.contains(loadAnchorText) { + lines.insert(loadAnchorText, at: lines.endIndex - 1) + } + + do { + try lines.joined(separator: "\n").write(toFile: self.configPath.string, atomically: true, encoding: .utf8) + } catch { + throw ContainerizationError(.invalidState, message: "failed to write \"\(self.configPath.string)\"") + } + } + + private func removeAnchorFromConfig() throws { + let fm: FileManager = FileManager.default + + guard fm.fileExists(atPath: configPath.string) else { + return + } + + let content = try String(contentsOfFile: configPath.string, encoding: .utf8) + let lines = content.components(separatedBy: .newlines) + + let removedLines = lines.filter { l in !l.contains(Self.anchor) } + + do { + try removedLines.joined(separator: "\n").write(toFile: configPath.string, atomically: true, encoding: .utf8) + } catch { + throw ContainerizationError(.invalidState, message: "failed to write \"\(configPath.string)\"") + } + } + + public func reinitialize() throws { + let null = FileHandle.nullDevice + + let checkProcess = Foundation.Process() + var checkStatus: Int32 + checkProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + checkProcess.arguments = ["-n", "-f", configPath.string] + checkProcess.standardOutput = null + checkProcess.standardError = null + + do { + try checkProcess.run() + } catch { + throw ContainerizationError(.internalError, message: "pfctl rule check exec failed: \"\(error)\"") + } + + checkProcess.waitUntilExit() + checkStatus = checkProcess.terminationStatus + guard checkStatus == 0 else { + throw ContainerizationError(.internalError, message: "invalid pf config \"\(configPath.string)\"") + } + + let reloadProcess = Foundation.Process() + var reloadStatus: Int32 + + reloadProcess.executableURL = URL(fileURLWithPath: "/sbin/pfctl") + reloadProcess.arguments = ["-f", configPath.string] + reloadProcess.standardOutput = null + reloadProcess.standardError = null + + do { + try reloadProcess.run() + } catch { + throw ContainerizationError(.internalError, message: "pfctl reload exec failed: \"\(error)\"") + } + reloadProcess.waitUntilExit() + reloadStatus = reloadProcess.terminationStatus + guard reloadStatus == 0 else { + throw ContainerizationError(.invalidState, message: "pfctl -f \"\(configPath.string)\" failed with status \(reloadStatus)") + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/Parser.swift b/Sources/Services/ContainerAPIService/Client/Parser.swift new file mode 100644 index 0000000..ef209df --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/Parser.swift @@ -0,0 +1,1062 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import SystemPackage + +// MARK: - Collection capacity hints +// Methods in this file build arrays and dictionaries in loops where the final +// size is known from the input parameter count. reserveCapacity() and +// Dictionary(minimumCapacity:) avoid O(log n) reallocation copies as the +// collection grows incrementally. While this is a micro-optimization for each +// individual call, these methods execute on every `container run/create` and +// the savings compound at scale. + +/// A parsed volume specification from user input +public struct ParsedVolume { + public let name: String + public let destination: String + public let options: [String] + public let isAnonymous: Bool + + public init(name: String, destination: String, options: [String] = [], isAnonymous: Bool = false) { + self.name = name + self.destination = destination + self.options = options + self.isAnonymous = isAnonymous + } +} + +/// Union type for parsed mount specifications +public enum VolumeOrFilesystem { + case filesystem(Filesystem) + case volume(ParsedVolume) +} + +public struct Parser { + public static func memoryStringAsMiB(_ memory: String) throws -> Int64 { + let ram = try Measurement.parse(parsing: memory) + let mb = ram.converted(to: .mebibytes) + return Int64(mb.value) + } + + public static func memoryStringAsBytes(_ memory: String) throws -> UInt64 { + let ram = try Measurement.parse(parsing: memory) + let mb = ram.converted(to: .bytes) + return UInt64(mb.value) + } + + public static func user( + user: String?, uid: UInt32?, gid: UInt32?, + defaultUser: ProcessConfiguration.User = .id(uid: 0, gid: 0) + ) -> (user: ProcessConfiguration.User, groups: [UInt32]) { + var supplementalGroups: [UInt32] = [] + let user: ProcessConfiguration.User = { + if let user = user, !user.isEmpty { + return .raw(userString: user) + } + if let uid, let gid { + return .id(uid: uid, gid: gid) + } + if uid == nil, gid == nil { + // Neither uid nor gid is set. return the default user + return defaultUser + } + // One of uid / gid is left unspecified. Set the user accordingly + if let uid { + return .raw(userString: "\(uid)") + } + if let gid { + supplementalGroups.append(gid) + } + return defaultUser + }() + return (user, supplementalGroups) + } + + public static func platform(os: String, arch: String) -> ContainerizationOCI.Platform { + .init(arch: arch, os: os) + } + + public static func platform(from platform: String) throws -> ContainerizationOCI.Platform { + try .init(from: platform) + } + + public static func resources( + cpus: Int64?, + memory: String?, + defaultCPUs: Int, + defaultMemory: MemorySize, + ) throws -> ContainerConfiguration.Resources { + var resource = ContainerConfiguration.Resources() + resource.cpus = defaultCPUs + resource.memoryInBytes = Int64(defaultMemory.measurement.converted(to: .mebibytes).value).mib() + + if let cpus { + resource.cpus = Int(cpus) + } + + if let memory { + resource.memoryInBytes = try Parser.memoryStringAsMiB(memory).mib() + } + + return resource + } + + public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] { + var combined: [String] = [] + combined.append(contentsOf: Parser.env(envList: imageEnvs)) + for envFile in envFiles { + let content = try Parser.envFile(path: envFile) + combined.append(contentsOf: content) + } + combined.append(contentsOf: Parser.env(envList: envs)) + + let deduped = combined.reduce(into: [String: String](minimumCapacity: combined.count)) { map, entry in + let key = String(entry.split(separator: "=", maxSplits: 1).first ?? Substring(entry)) + map[key] = entry + } + + return deduped.map { $0.value } + } + + public static func envFile(path: String) throws -> [String] { + // This is a somewhat faithful Go->Swift port of Moby's envfile + // parsing in the cli: + // https://github.com/docker/cli/blob/f5a7a3c72eb35fc5ba9c4d65a2a0e2e1bd216bf2/pkg/kvfile/kvfile.go#L81 + + let data: Data + do { + // Use FileHandle to support named pipes (FIFOs) and process substitutions + // like --env-file <(echo "KEY=value") + let fileHandle = try FileHandle(forReadingFrom: URL(fileURLWithPath: path)) + defer { try? fileHandle.close() } + data = try fileHandle.readToEnd() ?? Data() + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to read envfile at \(path)", + cause: error + ) + } + + guard let content = String(data: data, encoding: .utf8) else { + throw ContainerizationError( + .invalidArgument, + message: "env file \(path) contains invalid utf8 bytes" + ) + } + + let whiteSpaces = " \t" + + var lines: [String] = [] + let fileLines = content.components(separatedBy: .newlines) + + for line in fileLines { + let trimmedLine = line.drop(while: { $0.isWhitespace }) + + // Skip empty lines and comments + if trimmedLine.isEmpty || trimmedLine.hasPrefix("#") { + continue + } + + let hasValue: Bool + let variable: String + let value: String + + if let equalIndex = trimmedLine.firstIndex(of: "=") { + variable = String(trimmedLine[.. [String] { + var envVar: [String] = [] + for env in envList { + var env = env + // Only inherit from host if no "=" is present (e.g., "--env VAR") + // "VAR=" should set an explicit empty value, not inherit. + if !env.contains("=") { + guard let val = ProcessInfo.processInfo.environment[env] else { + continue + } + env = "\(env)=\(val)" + } + envVar.append(env) + } + return envVar + } + + public static func labels(_ rawLabels: [String]) throws -> [String: String] { + var result: [String: String] = Dictionary(minimumCapacity: rawLabels.count) + for label in rawLabels { + if label.isEmpty { + throw ContainerizationError(.invalidArgument, message: "label cannot be an empty string") + } + let parts = label.split(separator: "=", maxSplits: 2) + switch parts.count { + case 1: + result[String(parts[0])] = "" + case 2: + result[String(parts[0])] = String(parts[1]) + default: + throw ContainerizationError(.invalidArgument, message: "invalid label format \(label)") + } + } + return result + } + + public static func process( + arguments: [String], + processFlags: Flags.Process, + managementFlags: Flags.Management, + config: ContainerizationOCI.ImageConfig? + ) throws -> ProcessConfiguration { + + let imageEnvVars = config?.env ?? [] + let envvars = try Parser.allEnv(imageEnvs: imageEnvVars, envFiles: processFlags.envFile, envs: processFlags.env) + + let workingDir: String = { + if let cwd = processFlags.cwd { + return cwd + } + if let cwd = config?.workingDir { + return cwd + } + return "/" + }() + + let processArguments: [String]? = { + var result: [String] = [] + var hasEntrypointOverride: Bool = false + // ensure the entrypoint is honored if it has been explicitly set by the user + if let entrypoint = managementFlags.entrypoint, !entrypoint.isEmpty { + result = [entrypoint] + hasEntrypointOverride = true + } else if let entrypoint = config?.entrypoint, !entrypoint.isEmpty { + result = entrypoint + } + if !arguments.isEmpty { + result.append(contentsOf: arguments) + } else { + if let cmd = config?.cmd, !hasEntrypointOverride, !cmd.isEmpty { + result.append(contentsOf: cmd) + } + } + return result.count > 0 ? result : nil + }() + + guard let commandToRun = processArguments, commandToRun.count > 0 else { + throw ContainerizationError(.invalidArgument, message: "command/entrypoint not specified for container process") + } + + let defaultUser: ProcessConfiguration.User = { + if let u = config?.user { + return .raw(userString: u) + } + return .id(uid: 0, gid: 0) + }() + + let (user, additionalGroups) = Parser.user( + user: processFlags.user, uid: processFlags.uid, + gid: processFlags.gid, defaultUser: defaultUser) + + let rlimits = try Parser.rlimits(processFlags.ulimits) + + return .init( + executable: commandToRun.first!, + arguments: [String](commandToRun.dropFirst()), + environment: envvars, + workingDirectory: workingDir, + terminal: processFlags.tty, + user: user, + supplementalGroups: additionalGroups, + rlimits: rlimits + ) + } + + // MARK: Mounts + + public static let mountTypes = [ + "virtiofs", + "bind", + "tmpfs", + ] + + public static let defaultDirectives = ["type": "virtiofs"] + + public static func tmpfsMounts(_ mounts: [String]) throws -> [Filesystem] { + let mounts = mounts.dedupe() + var result: [Filesystem] = [] + result.reserveCapacity(mounts.count) + for tmpfs in mounts { + let fs = Filesystem.tmpfs(destination: tmpfs, options: []) + try validateMount(.filesystem(fs)) + result.append(fs) + } + return result + } + + public static func mounts(_ rawMounts: [String], relativeTo basePath: URL? = nil) throws -> [VolumeOrFilesystem] { + let rawMounts = rawMounts.dedupe() + var mounts: [VolumeOrFilesystem] = [] + mounts.reserveCapacity(rawMounts.count) + for mount in rawMounts { + let m = try Parser.mount(mount, relativeTo: basePath) + try validateMount(m) + mounts.append(m) + } + return mounts + } + + public static func mount(_ mount: String, relativeTo basePath: URL? = nil) throws -> VolumeOrFilesystem { + let parts = mount.split(separator: ",") + if parts.count == 0 { + throw ContainerizationError(.invalidArgument, message: "invalid mount format: \(mount)") + } + var directives = defaultDirectives + for part in parts { + let keyVal = part.split(separator: "=", maxSplits: 2) + var key = String(keyVal[0]) + var skipValue = false + switch key { + case "type", "size", "mode": + break + case "source", "src": + key = "source" + case "destination", "dst", "target": + key = "destination" + case "readonly", "ro": + key = "ro" + skipValue = true + default: + throw ContainerizationError(.invalidArgument, message: "unknown directive \(key) when parsing mount \(mount)") + } + var value = "" + if !skipValue { + if keyVal.count != 2 { + throw ContainerizationError(.invalidArgument, message: "invalid directive format missing value \(part) in \(mount)") + } + value = String(keyVal[1]) + } + directives[key] = value + } + + var fs = Filesystem() + var isVolume = false + var volumeName = "" + for (key, val) in directives { + var val = val + let type = directives["type"] ?? "" + + switch key { + case "type": + if val == "bind" { + val = "virtiofs" + } + switch val { + case "virtiofs": + fs.type = Filesystem.FSType.virtiofs + case "tmpfs": + fs.type = Filesystem.FSType.tmpfs + case "volume": + isVolume = true + default: + throw ContainerizationError(.invalidArgument, message: "unsupported mount type \(val)") + } + + case "ro": + fs.options.append("ro") + case "size": + if type != "tmpfs" { + throw ContainerizationError(.invalidArgument, message: "unsupported option size for \(type) mount") + } + var overflow: Bool + var memory = try Parser.memoryStringAsMiB(val) + (memory, overflow) = memory.multipliedReportingOverflow(by: 1024 * 1024) + if overflow { + throw ContainerizationError(.invalidArgument, message: "overflow encountered when parsing memory string: \(val)") + } + let s = "size=\(memory)" + fs.options.append(s) + case "mode": + if type != "tmpfs" { + throw ContainerizationError(.invalidArgument, message: "unsupported option mode for \(type) mount") + } + let s = "mode=\(val)" + fs.options.append(s) + case "source": + switch type { + case "virtiofs", "bind": + // For bind mounts, resolve both absolute and relative paths + let url = basePath?.appending(path: val).standardizedFileURL ?? URL(filePath: val) + let absolutePath = url.absoluteURL.path + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { + throw ContainerizationError(.invalidArgument, message: "path '\(val)' does not exist") + } + guard isDirectory.boolValue else { + throw ContainerizationError(.invalidArgument, message: "path '\(val)' is not a directory") + } + fs.source = absolutePath + case "volume": + // For volume mounts, validate as volume name + guard VolumeStorage.isValidVolumeName(val) else { + throw ContainerizationError(.invalidArgument, message: "invalid volume name '\(val)': must match \(VolumeStorage.volumeNamePattern)") + } + + // This is a named volume + volumeName = val + fs.source = val + case "tmpfs": + throw ContainerizationError(.invalidArgument, message: "cannot specify source for tmpfs mount") + default: + throw ContainerizationError(.invalidArgument, message: "unknown mount type \(type)") + } + case "destination": + fs.destination = val + default: + throw ContainerizationError(.invalidArgument, message: "unknown mount directive \(key)") + } + } + + guard isVolume else { + return .filesystem(fs) + } + + // If it's a volume type but no source was provided, create an anonymous volume + let isAnonymous = volumeName.isEmpty + if isAnonymous { + volumeName = VolumeStorage.generateAnonymousVolumeName() + } + + return .volume( + ParsedVolume( + name: volumeName, + destination: fs.destination, + options: fs.options, + isAnonymous: isAnonymous + )) + } + + public static func volumes(_ rawVolumes: [String], relativeTo basePath: URL? = nil) throws -> [VolumeOrFilesystem] { + var mounts: [VolumeOrFilesystem] = [] + mounts.reserveCapacity(rawVolumes.count) + for volume in rawVolumes { + let m = try Parser.volume(volume, relativeTo: basePath) + try Parser.validateMount(m) + mounts.append(m) + } + return mounts + } + + public static func volume(_ volume: String, relativeTo basePath: URL? = nil) throws -> VolumeOrFilesystem { + var vol = volume + vol.trimLeft(char: ":") + + let parts = vol.split(separator: ":") + switch parts.count { + case 1: + // Anonymous volume: -v /path + // Generate a random name for the anonymous volume + let anonymousName = VolumeStorage.generateAnonymousVolumeName() + let destination = String(parts[0]) + let options: [String] = [] + + return .volume( + ParsedVolume( + name: anonymousName, + destination: destination, + options: options, + isAnonymous: true + )) + case 2, 3: + let src = String(parts[0]) + let dst = String(parts[1]) + + // Check if it's a filesystem path (absolute, or relative like ".", "..", "./foo", "../foo") + guard src.contains("/") || src == "." || src == ".." else { + // Named volume - validate name syntax only + guard VolumeStorage.isValidVolumeName(src) else { + throw ContainerizationError(.invalidArgument, message: "invalid volume name '\(src)': must match \(VolumeStorage.volumeNamePattern)") + } + + // This is a named volume + let options = parts.count == 3 ? parts[2].split(separator: ",").map { String($0) } : [] + return .volume( + ParsedVolume( + name: src, + destination: dst, + options: options + )) + } + let url = basePath?.appending(path: src).standardizedFileURL ?? URL(filePath: src) + let absolutePath = url.absoluteURL.path + + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: absolutePath, isDirectory: &isDirectory) else { + throw ContainerizationError(.invalidArgument, message: "path '\(src)' does not exist") + } + + // This is a filesystem mount + var fs = Filesystem.virtiofs( + source: URL(fileURLWithPath: absolutePath).absolutePath(), + destination: dst, + options: [] + ) + if parts.count == 3 { + fs.options = parts[2].split(separator: ",").map { String($0) } + } + return .filesystem(fs) + default: + throw ContainerizationError(.invalidArgument, message: "invalid volume format \(volume)") + } + } + + public static func validMountType(_ type: String) -> Bool { + mountTypes.contains(type) + } + + public static func validateMount(_ mount: VolumeOrFilesystem) throws { + switch mount { + case .filesystem(let fs): + if !fs.isTmpfs { + if !fs.source.isAbsolutePath() { + throw ContainerizationError( + .invalidArgument, message: "\(fs.source) is not an absolute path on the host") + } + if !FileManager.default.fileExists(atPath: fs.source) { + throw ContainerizationError(.invalidArgument, message: "file path '\(fs.source)' does not exist") + } + } + + if fs.destination.isEmpty { + throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty") + } + case .volume(let vol): + if vol.destination.isEmpty { + throw ContainerizationError(.invalidArgument, message: "volume destination cannot be empty") + } + // Volume name validation already done during parsing + } + } + + /// Parse --publish-port arguments into PublishPort objects + /// The format of each argument is `[host-ip:]host-port:container-port[/protocol]` + /// (e.g., "127.0.0.1:8080:80/tcp") + /// host-port and container-port can be ranges (e.g., "127.0.0.1:3456-4567:3456-4567/tcp` + /// + /// - Parameter rawPublishPorts: Array of port arguments + /// - Returns: Array of PublishPort objects + /// - Throws: ContainerizationError if parsing fails + public static func publishPorts(_ rawPublishPorts: [String]) throws -> [PublishPort] { + var publishPorts: [PublishPort] = [] + publishPorts.reserveCapacity(rawPublishPorts.count) + + // Process each raw port string + for socket in rawPublishPorts { + let publishPort = try Parser.publishPort(socket) + publishPorts.append(publishPort) + } + return publishPorts + } + + // Parse a single `--publish-port` argument into a `PublishPort`. + public static func publishPort(_ portText: String) throws -> PublishPort { + let publishPortRegex = #/((\[(?[^\]]*)\]|(?[^:].*)):)?(?[^:].*):(?[^:/]*)(/(?.*))?/# + guard let match = try publishPortRegex.wholeMatch(in: portText) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish value: \(portText)") + } + + let proto: PublishProtocol + let protoText = match.proto?.lowercased() ?? "tcp" + switch protoText { + case "tcp": + proto = .tcp + case "udp": + proto = .udp + default: + throw ContainerizationError(.invalidArgument, message: "invalid publish protocol: \(protoText)") + } + + let hostAddress: IPAddress + if let ipv6 = match.ipv6, !ipv6.isEmpty { + guard let address = try? IPAddress(String(ipv6)), case .v6 = address else { + throw ContainerizationError(.invalidArgument, message: "invalid publish IPv6 address: \(portText)") + } + hostAddress = address + } else if let ipv4 = match.ipv4, !ipv4.isEmpty { + guard let address = try? IPAddress(String(ipv4)), case .v4 = address else { + throw ContainerizationError(.invalidArgument, message: "invalid publish IPv4 address: \(portText)") + } + hostAddress = address + } else { + hostAddress = try IPAddress("0.0.0.0") + } + + let hostPortText = match.hostPort + let containerPortText = match.containerPort + let hostPortRangeStart: UInt16 + let hostPortRangeEnd: UInt16 + let containerPortRangeStart: UInt16 + let containerPortRangeEnd: UInt16 + + let hostPortParts = hostPortText.split(separator: "-") + switch hostPortParts.count { + case 1: + guard let start = UInt16(hostPortParts[0]) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") + } + hostPortRangeStart = start + hostPortRangeEnd = start + case 2: + guard let start = UInt16(hostPortParts[0]) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") + } + + guard let end = UInt16(hostPortParts[1]) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") + } + + hostPortRangeStart = start + hostPortRangeEnd = end + default: + throw ContainerizationError(.invalidArgument, message: "invalid publish host port: \(hostPortText)") + } + + let containerPortParts = containerPortText.split(separator: "-") + switch containerPortParts.count { + case 1: + guard let start = UInt16(containerPortParts[0]) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") + } + + containerPortRangeStart = start + containerPortRangeEnd = start + case 2: + guard let start = UInt16(containerPortParts[0]) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") + } + + guard let end = UInt16(containerPortParts[1]) else { + throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") + } + + containerPortRangeStart = start + containerPortRangeEnd = end + default: + throw ContainerizationError(.invalidArgument, message: "invalid publish container port: \(containerPortText)") + } + + guard hostPortRangeStart > 1, + hostPortRangeStart <= hostPortRangeEnd + else { + throw ContainerizationError(.invalidArgument, message: "invalid publish host port range: \(hostPortText)") + } + + guard containerPortRangeStart > 1, + containerPortRangeStart <= containerPortRangeEnd + else { + throw ContainerizationError(.invalidArgument, message: "invalid publish container port range: \(containerPortText)") + } + + let hostCount = hostPortRangeEnd - hostPortRangeStart + 1 + let containerCount = containerPortRangeEnd - containerPortRangeStart + 1 + + guard hostCount == containerCount else { + throw ContainerizationError(.invalidArgument, message: "publish host and container port counts are not equal: \(hostPortText):\(containerPortText)") + } + + return try PublishPort( + hostAddress: hostAddress, + hostPort: hostPortRangeStart, + containerPort: containerPortRangeStart, + proto: proto, + count: hostCount + ) + } + + /// Parse --publish-socket arguments into PublishSocket objects + /// The format of each argument is `host_path:container_path` + /// (e.g., "/tmp/docker.sock:/var/run/docker.sock") + /// + /// - Parameter rawPublishSockets: Array of socket arguments + /// - Returns: Array of PublishSocket objects + /// - Throws: ContainerizationError if parsing fails or a path is invalid + public static func publishSockets(_ rawPublishSockets: [String]) throws -> [PublishSocket] { + var sockets: [PublishSocket] = [] + sockets.reserveCapacity(rawPublishSockets.count) + + // Process each raw socket string + for socket in rawPublishSockets { + let parsedSocket = try Parser.publishSocket(socket) + sockets.append(parsedSocket) + } + return sockets + } + + // Parse a single `--publish-socket`` argument into a `PublishSocket`. + public static func publishSocket(_ socketText: String) throws -> PublishSocket { + // Split by colon to two parts: [host_path, container_path] + let parts = socketText.split(separator: ":") + + switch parts.count { + case 2: + // Extract host and container paths + let hostPath = String(parts[0]) + let containerPath = String(parts[1]) + + if hostPath.isEmpty { + throw ContainerizationError( + .invalidArgument, message: "host socket path cannot be empty") + } + if containerPath.isEmpty { + throw ContainerizationError( + .invalidArgument, message: "container socket path cannot be empty") + } + + let absoluteHostPath = FilePathOps.absolutePath(FilePath(hostPath)) + + if FileManager.default.fileExists(atPath: absoluteHostPath.string) { + do { + let attrs = try FileManager.default.attributesOfItem(atPath: absoluteHostPath.string) + if let fileType = attrs[.type] as? FileAttributeType, fileType == .typeSocket { + throw ContainerizationError( + .invalidArgument, + message: "host socket \(absoluteHostPath) already exists and may be in use") + } + // If it exists but is not a socket, we can remove it and create socket + try FileManager.default.removeItem(atPath: absoluteHostPath.string) + } catch let error as ContainerizationError { + throw error + } catch { + // For other file system errors, continue with creation + } + } + + let hostDir = absoluteHostPath.removingLastComponent() + if !FileManager.default.fileExists(atPath: hostDir.string) { + try FileManager.default.createDirectory( + atPath: hostDir.string, withIntermediateDirectories: true) + } + + return try PublishSocket( + containerPath: FilePath(containerPath), + hostPath: absoluteHostPath, + permissions: nil + ) + + default: + throw ContainerizationError( + .invalidArgument, + message: + "invalid publish-socket format \(socketText). Expected: host_path:container_path") + } + } + + // MARK: Networks + + /// Parsed network attachment with optional properties + public struct ParsedNetwork { + public let name: String + public let macAddress: String? + public let mtu: UInt32? + + public init(name: String, macAddress: String? = nil, mtu: UInt32? = nil) { + self.name = name + self.macAddress = macAddress + self.mtu = mtu + } + } + + /// Parse network attachment with optional properties + /// Format: network_name[,mac=XX:XX:XX:XX:XX:XX][,mtu=VALUE] + /// Example: "backend,mac=02:42:ac:11:00:02,mtu=1500" + public static func network(_ networkSpec: String) throws -> ParsedNetwork { + guard !networkSpec.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") + } + + let parts = networkSpec.split(separator: ",", omittingEmptySubsequences: false) + + guard !parts.isEmpty else { + throw ContainerizationError(.invalidArgument, message: "network specification cannot be empty") + } + + let networkName = String(parts[0]) + if networkName.isEmpty { + throw ContainerizationError(.invalidArgument, message: "network name cannot be empty") + } + + var macAddress: String? + var mtu: UInt32? + + // Parse properties if any + for part in parts.dropFirst() { + let keyVal = part.split(separator: "=", maxSplits: 2, omittingEmptySubsequences: false) + + let key: String + let value: String + + guard keyVal.count == 2 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid property format '\(part)' in network specification '\(networkSpec)'" + ) + } + key = String(keyVal[0]) + value = String(keyVal[1]) + + switch key { + case "mac": + if value.isEmpty { + throw ContainerizationError( + .invalidArgument, + message: "mac address value cannot be empty" + ) + } + macAddress = value + case "mtu": + guard let mtuValue = UInt32(value), mtuValue >= 1280, mtuValue <= 65535 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid mtu value '\(value)': must be between 1280 and 65535" + ) + } + mtu = mtuValue + default: + throw ContainerizationError( + .invalidArgument, + message: "unknown network property '\(key)'. Available properties: mac, mtu" + ) + } + } + + return ParsedNetwork(name: networkName, macAddress: macAddress, mtu: mtu) + } + + // MARK: DNS + + public static func isValidDomainName(_ name: String) -> Bool { + guard !name.isEmpty && name.count <= 255 else { + return false + } + return name.components(separatedBy: ".").allSatisfy { Self.isValidDomainNameLabel($0) } + } + + public static func isValidDomainNameLabel(_ label: String) -> Bool { + guard !label.isEmpty && label.count <= 63 else { + return false + } + let pattern = #/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$/# + return !label.ranges(of: pattern).isEmpty + } + + private static let ulimitNameToRlimit: [String: String] = [ + "core": "RLIMIT_CORE", + "cpu": "RLIMIT_CPU", + "data": "RLIMIT_DATA", + "fsize": "RLIMIT_FSIZE", + "locks": "RLIMIT_LOCKS", + "memlock": "RLIMIT_MEMLOCK", + "msgqueue": "RLIMIT_MSGQUEUE", + "nice": "RLIMIT_NICE", + "nofile": "RLIMIT_NOFILE", + "nproc": "RLIMIT_NPROC", + "rss": "RLIMIT_RSS", + "rtprio": "RLIMIT_RTPRIO", + "rttime": "RLIMIT_RTTIME", + "sigpending": "RLIMIT_SIGPENDING", + "stack": "RLIMIT_STACK", + ] + + /// Parse ulimit specifications into Rlimit objects + /// Format: =[:] + /// Examples: + /// - nofile=1024:2048 (soft=1024, hard=2048) + /// - nofile=1024 (soft=hard=1024) + /// - nofile=unlimited (soft=hard=UINT64_MAX) + /// - nofile=1024:unlimited (soft=1024, hard=UINT64_MAX) + public static func rlimits(_ rawUlimits: [String]) throws -> [ProcessConfiguration.Rlimit] { + var rlimits: [ProcessConfiguration.Rlimit] = [] + rlimits.reserveCapacity(rawUlimits.count) + var seenTypes: Set = [] + + for ulimit in rawUlimits { + let rlimit = try Parser.rlimit(ulimit) + if seenTypes.contains(rlimit.limit) { + throw ContainerizationError( + .invalidArgument, + message: "duplicate ulimit type: \(ulimit.split(separator: "=").first ?? "")" + ) + } + seenTypes.insert(rlimit.limit) + rlimits.append(rlimit) + } + + return rlimits + } + + /// Parse a single ulimit specification + public static func rlimit(_ ulimit: String) throws -> ProcessConfiguration.Rlimit { + let parts = ulimit.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { + throw ContainerizationError( + .invalidArgument, + message: "invalid ulimit format '\(ulimit)': expected =[:]" + ) + } + + let typeName = String(parts[0]).lowercased() + let valuesPart = String(parts[1]) + + guard let rlimitType = ulimitNameToRlimit[typeName] else { + let validTypes = ulimitNameToRlimit.keys.sorted().joined(separator: ", ") + throw ContainerizationError( + .invalidArgument, + message: "unsupported ulimit type '\(typeName)': valid types are \(validTypes)" + ) + } + + let valueParts = valuesPart.split(separator: ":", maxSplits: 1) + let soft: UInt64 + let hard: UInt64 + + switch valueParts.count { + case 1: + // Single value: use for both soft and hard + soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName) + hard = soft + case 2: + // Two values: soft:hard + soft = try parseRlimitValue(String(valueParts[0]), typeName: typeName) + hard = try parseRlimitValue(String(valueParts[1]), typeName: typeName) + default: + throw ContainerizationError( + .invalidArgument, + message: "invalid ulimit format '\(ulimit)': expected =[:]" + ) + } + + if soft > hard { + throw ContainerizationError( + .invalidArgument, + message: "ulimit '\(typeName)' soft limit (\(soft)) cannot exceed hard limit (\(hard))" + ) + } + + return ProcessConfiguration.Rlimit(limit: rlimitType, soft: soft, hard: hard) + } + + private static func parseRlimitValue(_ value: String, typeName: String) throws -> UInt64 { + let trimmed = value.trimmingCharacters(in: .whitespaces).lowercased() + + if trimmed == "unlimited" || trimmed == "-1" { + return UInt64.max + } + + guard let parsed = UInt64(trimmed) else { + throw ContainerizationError( + .invalidArgument, + message: "invalid ulimit value '\(value)' for '\(typeName)': must be a non-negative integer or 'unlimited'" + ) + } + + return parsed + } + + // MARK: Capabilities + + /// Parse and validate --cap-add / --cap-drop arguments. + /// Returns normalized uppercase CAP_* strings. + public static func capabilities(capAdd: [String], capDrop: [String]) throws -> (capAdd: [String], capDrop: [String]) { + var normalizedAdd: [String] = [] + normalizedAdd.reserveCapacity(capAdd.count) + for cap in capAdd { + let upper = cap.uppercased() + if upper == "ALL" { + normalizedAdd.append("ALL") + continue + } + // Validate using CapabilityName from the containerization lib + _ = try CapabilityName(rawValue: upper) + // Normalize to CAP_ prefixed form + let normalized = upper.hasPrefix("CAP_") ? upper : "CAP_\(upper)" + normalizedAdd.append(normalized) + } + + var normalizedDrop: [String] = [] + normalizedDrop.reserveCapacity(capDrop.count) + for cap in capDrop { + let upper = cap.uppercased() + if upper == "ALL" { + normalizedDrop.append("ALL") + continue + } + _ = try CapabilityName(rawValue: upper) + let normalized = upper.hasPrefix("CAP_") ? upper : "CAP_\(upper)" + normalizedDrop.append(normalized) + } + + return (normalizedAdd, normalizedDrop) + } + + // MARK: Miscellaneous + + public static func parseBool(string: String) -> Bool? { + Parsers.parseBool(string: string) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ProcessIO.swift b/Sources/Services/ContainerAPIService/Client/ProcessIO.swift new file mode 100644 index 0000000..2af8306 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ProcessIO.swift @@ -0,0 +1,386 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import ContainerizationOS +import Foundation +import Logging + +public struct ProcessIO: Sendable { + let stdin: Pipe? + let stdout: Pipe? + let stderr: Pipe? + var ioTracker: IoTracker? + + static let signalSet: [Int32] = [ + SIGTERM, + SIGINT, + SIGUSR1, + SIGUSR2, + SIGWINCH, + ] + + public struct IoTracker: Sendable { + let stream: AsyncStream + let cont: AsyncStream.Continuation + let configuredStreams: Int + } + + public let stdio: [FileHandle?] + + public let console: Terminal? + + public static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { + let current: Terminal? = try { + if !tty || !interactive { + return nil + } + let current = try Terminal(descriptor: STDIN_FILENO) + try current.setraw() + return current + }() + + var stdio = [FileHandle?](repeating: nil, count: 3) + + let stdin: Pipe? = { + if !interactive { + return nil + } + return Pipe() + }() + + if let stdin { + let pin = FileHandle.standardInput + let stdinOSFile = OSFile(fd: pin.fileDescriptor) + let pipeOSFile = OSFile(fd: stdin.fileHandleForWriting.fileDescriptor) + try stdinOSFile.makeNonBlocking() + nonisolated(unsafe) let buf = UnsafeMutableBufferPointer.allocate(capacity: Int(getpagesize())) + + pin.readabilityHandler = { _ in + Self.streamStdin( + from: stdinOSFile, + to: pipeOSFile, + buffer: buf, + ) { + pin.readabilityHandler = nil + buf.deallocate() + try? stdin.fileHandleForWriting.close() + } + } + stdio[0] = stdin.fileHandleForReading + } + + let stdout: Pipe? = { + if detach { + return nil + } + return Pipe() + }() + + var configuredStreams = 0 + let (stream, cc) = AsyncStream.makeStream() + if let stdout { + configuredStreams += 1 + + stdio[1] = stdout.fileHandleForWriting + let pout = FileHandle.standardOutput + let rout = stdout.fileHandleForReading + rout.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rout.readabilityHandler = nil + cc.yield() + return + } + do { + try pout.write(contentsOf: data) + } catch { + rout.readabilityHandler = nil + cc.yield() + } + } + } + + let stderr: Pipe? = { + if detach || tty { + return nil + } + return Pipe() + }() + if let stderr { + configuredStreams += 1 + let perr: FileHandle = .standardError + let rerr = stderr.fileHandleForReading + rerr.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rerr.readabilityHandler = nil + cc.yield() + return + } + do { + try perr.write(contentsOf: data) + } catch { + rerr.readabilityHandler = nil + cc.yield() + } + } + stdio[2] = stderr.fileHandleForWriting + } + + var ioTracker: IoTracker? = nil + if configuredStreams > 0 { + ioTracker = .init(stream: stream, cont: cc, configuredStreams: configuredStreams) + } + + return .init( + stdin: stdin, + stdout: stdout, + stderr: stderr, + ioTracker: ioTracker, + stdio: stdio, + console: current + ) + } + + public func handleProcess(process: ClientProcess, log: Logger) async throws -> Int32 { + let signals = AsyncSignalHandler.create(notify: Self.signalSet) + return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in + try await process.start() + try closeAfterStart() + + let waitAdded = group.addTaskUnlessCancelled { + let code = try await process.wait() + try await wait() + return code + } + + guard waitAdded else { + group.cancelAll() + return -1 + } + + if let current = console { + let size = try current.size + // It's supremely possible the process could've exited already. We shouldn't treat + // this as fatal. + try? await process.resize(size) + _ = group.addTaskUnlessCancelled { + let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH]) + for await _ in winchHandler.signals { + do { + try await process.resize(try current.size) + } catch { + log.error( + "failed to send terminal resize event", + metadata: [ + "error": "\(error)" + ] + ) + } + } + return nil + } + } else { + _ = group.addTaskUnlessCancelled { + for await sig in signals.signals { + do { + try await process.kill(sig) + } catch { + log.error( + "failed to send signal", + metadata: [ + "signal": "\(sig)", + "error": "\(error)", + ] + ) + } + } + return nil + } + } + + while true { + let result = try await group.next() + if result == nil { + return -1 + } + let status = result! + if let status { + group.cancelAll() + return status + } + } + return -1 + } + } + + public func closeAfterStart() throws { + try stdin?.fileHandleForReading.close() + try stdout?.fileHandleForWriting.close() + try stderr?.fileHandleForWriting.close() + } + + public func close() throws { + try console?.reset() + } + + public func wait() async throws { + guard let ioTracker = self.ioTracker else { + return + } + do { + try await Timeout.run(seconds: 3) { + var counter = ioTracker.configuredStreams + for await _ in ioTracker.stream { + counter -= 1 + if counter == 0 { + ioTracker.cont.finish() + break + } + } + } + } catch { + throw error + } + } + + static func streamStdin( + from: OSFile, + to: OSFile, + buffer: UnsafeMutableBufferPointer, + onErrorOrEOF: () -> Void, + ) { + while true { + let (bytesRead, action) = from.read(buffer) + if bytesRead > 0 { + let view = UnsafeMutableBufferPointer( + start: buffer.baseAddress, + count: bytesRead + ) + + let (bytesWritten, _) = to.write(view) + if bytesWritten != bytesRead { + onErrorOrEOF() + return + } + } + + switch action { + case .error(_), .eof, .brokenPipe: + onErrorOrEOF() + return + case .again: + return + case .success: + break + } + } + } +} + +public struct OSFile: Sendable { + private let fd: Int32 + + public enum IOAction: Equatable { + case eof + case again + case success + case brokenPipe + case error(_ errno: Int32) + } + + public init(fd: Int32) { + self.fd = fd + } + + public init(handle: FileHandle) { + self.fd = handle.fileDescriptor + } + + func makeNonBlocking() throws { + let flags = fcntl(fd, F_GETFL) + guard flags != -1 else { + throw POSIXError.fromErrno() + } + + if fcntl(fd, F_SETFL, flags | O_NONBLOCK) == -1 { + throw POSIXError.fromErrno() + } + } + + func write(_ buffer: UnsafeMutableBufferPointer) -> (wrote: Int, action: IOAction) { + if buffer.count == 0 { + return (0, .success) + } + + var bytesWrote: Int = 0 + while true { + let n = Darwin.write( + self.fd, + buffer.baseAddress!.advanced(by: bytesWrote), + buffer.count - bytesWrote + ) + if n == -1 { + if errno == EAGAIN || errno == EIO { + return (bytesWrote, .again) + } + return (bytesWrote, .error(errno)) + } + + if n == 0 { + return (bytesWrote, .brokenPipe) + } + + bytesWrote += n + if bytesWrote < buffer.count { + continue + } + return (bytesWrote, .success) + } + } + + func read(_ buffer: UnsafeMutableBufferPointer) -> (read: Int, action: IOAction) { + if buffer.count == 0 { + return (0, .success) + } + + var bytesRead: Int = 0 + while true { + let n = Darwin.read( + self.fd, + buffer.baseAddress!.advanced(by: bytesRead), + buffer.count - bytesRead + ) + if n == -1 { + if errno == EAGAIN || errno == EIO { + return (bytesRead, .again) + } + return (bytesRead, .error(errno)) + } + + if n == 0 { + return (bytesRead, .eof) + } + + bytesRead += n + if bytesRead < buffer.count { + continue + } + return (bytesRead, .success) + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ProgressUpdateClient.swift b/Sources/Services/ContainerAPIService/Client/ProgressUpdateClient.swift new file mode 100644 index 0000000..458e318 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ProgressUpdateClient.swift @@ -0,0 +1,163 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationExtras +import Foundation +import TerminalProgress + +/// A client that can be used to receive progress updates from a service. +public actor ProgressUpdateClient { + private var endpointConnection: xpc_connection_t? + private var endpoint: xpc_endpoint_t? + + /// Creates a new client for receiving progress updates from a service. + /// - Parameters: + /// - progressUpdate: The handler to invoke when progress updates are received. + /// - request: The XPC message to send the endpoint to connect to. + public init(for progressUpdate: @escaping ProgressUpdateHandler, request: XPCMessage) async { + createEndpoint(for: progressUpdate) + setEndpoint(to: request) + } + + /// Performs a connection setup for receiving progress updates. + /// - Parameter progressUpdate: The handler to invoke when progress updates are received. + private func createEndpoint(for progressUpdate: @escaping ProgressUpdateHandler) { + let endpointConnection = xpc_connection_create(nil, nil) + // Access to `reversedConnection` is protected by a lock + nonisolated(unsafe) var reversedConnection: xpc_connection_t? + let reversedConnectionLock = NSLock() + xpc_connection_set_event_handler(endpointConnection) { connectionMessage in + reversedConnectionLock.withLock { + switch xpc_get_type(connectionMessage) { + case XPC_TYPE_CONNECTION: + reversedConnection = connectionMessage + xpc_connection_set_event_handler(connectionMessage) { updateMessage in + Self.handleProgressUpdate(updateMessage, progressUpdate: progressUpdate) + } + xpc_connection_activate(connectionMessage) + case XPC_TYPE_ERROR: + if let reversedConnectionUnwrapped = reversedConnection { + xpc_connection_cancel(reversedConnectionUnwrapped) + reversedConnection = nil + } + default: + fatalError("unhandled xpc object type: \(xpc_get_type(connectionMessage))") + } + } + } + xpc_connection_activate(endpointConnection) + + self.endpointConnection = endpointConnection + self.endpoint = xpc_endpoint_create(endpointConnection) + } + + /// Performs a setup of the progress update endpoint. + /// - Parameter request: The XPC message containing the endpoint to use. + private func setEndpoint(to request: XPCMessage) { + guard let endpoint else { + return + } + request.set(key: .progressUpdateEndpoint, value: endpoint) + } + + /// Performs cleanup of the created connection. + public func finish() { + if let endpointConnection { + xpc_connection_cancel(endpointConnection) + self.endpointConnection = nil + } + } + + private static func handleProgressUpdate(_ message: xpc_object_t, progressUpdate: @escaping ProgressUpdateHandler) { + switch xpc_get_type(message) { + case XPC_TYPE_DICTIONARY: + let message = XPCMessage(object: message) + handleProgressUpdate(message, progressUpdate: progressUpdate) + case XPC_TYPE_ERROR: + break + default: + fatalError("unhandled xpc object type: \(xpc_get_type(message))") + break + } + } + + private static func handleProgressUpdate(_ message: XPCMessage, progressUpdate: @escaping ProgressUpdateHandler) { + var events = [ProgressUpdateEvent]() + + if let description = message.string(key: .progressUpdateSetDescription) { + events.append(.setDescription(description)) + } + if let subDescription = message.string(key: .progressUpdateSetSubDescription) { + events.append(.setSubDescription(subDescription)) + } + if let itemsName = message.string(key: .progressUpdateSetItemsName) { + events.append(.setItemsName(itemsName)) + } + var tasks = message.int(key: .progressUpdateAddTasks) + if tasks != 0 { + events.append(.addTasks(tasks)) + } + tasks = message.int(key: .progressUpdateSetTasks) + if tasks != 0 { + events.append(.setTasks(tasks)) + } + var totalTasks = message.int(key: .progressUpdateAddTotalTasks) + if totalTasks != 0 { + events.append(.addTotalTasks(totalTasks)) + } + totalTasks = message.int(key: .progressUpdateSetTotalTasks) + if totalTasks != 0 { + events.append(.setTotalTasks(totalTasks)) + } + var items = message.int(key: .progressUpdateAddItems) + if items != 0 { + events.append(.addItems(items)) + } + items = message.int(key: .progressUpdateSetItems) + if items != 0 { + events.append(.setItems(items)) + } + var totalItems = message.int(key: .progressUpdateAddTotalItems) + if totalItems != 0 { + events.append(.addTotalItems(totalItems)) + } + totalItems = message.int(key: .progressUpdateSetTotalItems) + if totalItems != 0 { + events.append(.setTotalItems(totalItems)) + } + var size = message.int64(key: .progressUpdateAddSize) + if size != 0 { + events.append(.addSize(size)) + } + size = message.int64(key: .progressUpdateSetSize) + if size != 0 { + events.append(.setSize(size)) + } + var totalSize = message.int64(key: .progressUpdateAddTotalSize) + if totalSize != 0 { + events.append(.addTotalSize(totalSize)) + } + totalSize = message.int64(key: .progressUpdateSetTotalSize) + if totalSize != 0 { + events.append(.setTotalSize(totalSize)) + } + + Task { + await progressUpdate(events) + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/ProgressUpdateService.swift b/Sources/Services/ContainerAPIService/Client/ProgressUpdateService.swift new file mode 100644 index 0000000..863d4de --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/ProgressUpdateService.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationExtras +import Foundation +import TerminalProgress + +/// A service that sends progress updates to the client. +public actor ProgressUpdateService { + private let endpointConnection: xpc_connection_t + + /// Creates a new instance for sending progress updates to the client. + /// - Parameter message: The XPC message that contains the endpoint to connect to. + public init?(message: XPCMessage) { + guard let progressUpdateEndpoint = message.endpoint(key: .progressUpdateEndpoint) else { + return nil + } + endpointConnection = xpc_connection_create_from_endpoint(progressUpdateEndpoint) + xpc_connection_set_event_handler(endpointConnection) { _ in } + // This connection will be closed by the client. + xpc_connection_activate(endpointConnection) + } + + /// Performs a progress update. + /// - Parameter events: The events that represent the update. + public func handler(_ events: [ProgressUpdateEvent]) async { + let object = xpc_dictionary_create(nil, nil, 0) + let replyMessage = XPCMessage(object: object) + for event in events { + switch event { + case .setDescription(let description): + replyMessage.set(key: .progressUpdateSetDescription, value: description) + case .setSubDescription(let subDescription): + replyMessage.set(key: .progressUpdateSetSubDescription, value: subDescription) + case .setItemsName(let itemsName): + replyMessage.set(key: .progressUpdateSetItemsName, value: itemsName) + case .addTasks(let tasks): + replyMessage.set(key: .progressUpdateAddTasks, value: tasks) + case .setTasks(let tasks): + replyMessage.set(key: .progressUpdateSetTasks, value: tasks) + case .addTotalTasks(let totalTasks): + replyMessage.set(key: .progressUpdateAddTotalTasks, value: totalTasks) + case .setTotalTasks(let totalTasks): + replyMessage.set(key: .progressUpdateSetTotalTasks, value: totalTasks) + case .addSize(let size): + replyMessage.set(key: .progressUpdateAddSize, value: size) + case .setSize(let size): + replyMessage.set(key: .progressUpdateSetSize, value: size) + case .addTotalSize(let totalSize): + replyMessage.set(key: .progressUpdateAddTotalSize, value: totalSize) + case .setTotalSize(let totalSize): + replyMessage.set(key: .progressUpdateSetTotalSize, value: totalSize) + case .addItems(let items): + replyMessage.set(key: .progressUpdateAddItems, value: items) + case .setItems(let items): + replyMessage.set(key: .progressUpdateSetItems, value: items) + case .addTotalItems(let totalItems): + replyMessage.set(key: .progressUpdateAddTotalItems, value: totalItems) + case .setTotalItems(let totalItems): + replyMessage.set(key: .progressUpdateSetTotalItems, value: totalItems) + case .custom(_): + // Unsupported progress update event in XPC communication. + break + } + } + xpc_connection_send_message(endpointConnection, replyMessage.underlying) + } +} diff --git a/Sources/Services/ContainerAPIService/Client/RequestScheme.swift b/Sources/Services/ContainerAPIService/Client/RequestScheme.swift new file mode 100644 index 0000000..b2ab8c8 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/RequestScheme.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras + +/// The URL scheme to be used for a HTTP request. +public enum RequestScheme: String, Sendable { + private static let defaultDomain = "test" + + case http = "http" + case https = "https" + + case auto = "auto" + + public init(_ rawValue: String) throws { + switch rawValue { + case RequestScheme.http.rawValue: + self = .http + case RequestScheme.https.rawValue: + self = .https + case RequestScheme.auto.rawValue: + self = .auto + default: + throw ContainerizationError(.invalidArgument, message: "unsupported scheme \(rawValue)") + } + } + + /// Returns the prescribed protocol to use while making a HTTP request to a webserver + /// - Parameter host: The domain or IP address of the webserver + /// - Parameter internalDnsDomain: The DNS domain used for container name resolution + /// - Returns: RequestScheme + public func schemeFor(host: String, internalDnsDomain: String?) throws -> Self { + guard host.count > 0 else { + throw ContainerizationError(.invalidArgument, message: "host cannot be empty") + } + switch self { + case .http, .https: + return self + case .auto: + return Self.isInternalHost(host: host, internalDnsDomain: internalDnsDomain) ? .http : .https + } + } + + /// Checks if the given `host` string is a private IP address + /// or a domain typically reachable only on the local system. + public static func isInternalHost(host: String, internalDnsDomain: String?) -> Bool { + // The localhost hostname is private. + if host == "localhost" { + return true + } + + // If hostname uses the provided DNS domain, treat it as private. + if let internalDnsDomain { + if host.hasSuffix(".\(internalDnsDomain)") { + return true + } + } + + // If it's any other hostname and not an IP address, it's not private access. + guard let ipv4Address = try? IPv4Address(host) else { + return false + } + + let ipv4Value = ipv4Address.value + + // 10.0.0.0/8 and 127.0.0.0/8 are private CIDRs. + if (ipv4Value & 0xff00_0000 == 0x0a00_0000) || (ipv4Value & 0xff00_0000 == 0x7f00_0000) { + return true + } + + // 192.168.0.0/16 is a private CIDR. + if ipv4Value & 0xffff_0000 == 0xc0a8_0000 { + return true + } + + // 172.16.0.0/12 is a private CIDR. + if ipv4Value & 0xfff0_0000 == 0xac10_0000 { + return true + } + + return false + } +} diff --git a/Sources/Services/ContainerAPIService/Client/SignalThreshold.swift b/Sources/Services/ContainerAPIService/Client/SignalThreshold.swift new file mode 100644 index 0000000..f1db36b --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/SignalThreshold.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOS + +// For a lot of programs, they don't install their own signal handlers for +// SIGINT/SIGTERM which poses a somewhat fun problem for containers. Because +// they're pid 1 (doesn't matter that it isn't in the "root" pid namespace) +// the default actions for SIGINT and SIGTERM now are nops. So this type gives +// us an opportunity to set a threshold for a certain number of signals received +// so we can have an escape hatch for users to escape their horrific mistake +// of cat'ing /dev/urandom by exit(1)'ing :) +public struct SignalThreshold { + private let threshold: Int + private let signals: [Int32] + private var t: Task<(), Never>? + + public init( + threshold: Int, + signals: [Int32], + ) { + self.threshold = threshold + self.signals = signals + } + + // Start kicks off the signal watching. The passed in handler will + // run only once upon passing the threshold number passed in the constructor. + mutating public func start(handler: @Sendable @escaping () -> Void) { + let signals = self.signals + let threshold = self.threshold + self.t = Task { + var received = 0 + let signalHandler = AsyncSignalHandler.create(notify: signals) + for await _ in signalHandler.signals { + received += 1 + if received == threshold { + handler() + signalHandler.cancel() + return + } + } + } + } + + public func stop() { + self.t?.cancel() + } +} diff --git a/Sources/Services/ContainerAPIService/Client/String+Extensions.swift b/Sources/Services/ContainerAPIService/Client/String+Extensions.swift new file mode 100644 index 0000000..e2adf75 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/String+Extensions.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +extension String { + public func fromISO8601DateString(to: String) -> String? { + if let date = fromISO8601Date() { + let dateformatTo = DateFormatter() + dateformatTo.dateFormat = to + return dateformatTo.string(from: date) + } + return nil + } + + public func fromISO8601Date() -> Date? { + let iso8601DateFormatter = ISO8601DateFormatter() + iso8601DateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return iso8601DateFormatter.date(from: self) + } + + public func isAbsolutePath() -> Bool { + self.starts(with: "/") + } + + /// Trim all `char` characters from the left side of the string. Stops when encountering a character that + /// doesn't match `char`. + mutating public func trimLeft(char: Character) { + if self.isEmpty { + return + } + var trimTo = 0 + for c in self { + if char != c { + break + } + trimTo += 1 + } + if trimTo != 0 { + let index = self.index(self.startIndex, offsetBy: trimTo) + self = String(self[index...]) + } + } +} diff --git a/Sources/Services/ContainerAPIService/Client/SystemHealth.swift b/Sources/Services/ContainerAPIService/Client/SystemHealth.swift new file mode 100644 index 0000000..2c6b794 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/SystemHealth.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SystemPackage + +/// Snapshot of the health of container services and resources +public struct SystemHealth: Sendable, Codable { + /// The full pathname of the application data root. + public let appRoot: URL + + /// The full pathname of the application install root. + public let installRoot: URL + + /// The full pathname of the application install root. + public let logRoot: FilePath? + + /// The release version of the container services. + public let apiServerVersion: String + + /// The Git commit ID for the container services. + public let apiServerCommit: String + + /// The build type of the API server (debug|release). + public let apiServerBuild: String + + /// The app name label returned by the server. + public let apiServerAppName: String +} diff --git a/Sources/Services/ContainerAPIService/Client/Utility.swift b/Sources/Services/ContainerAPIService/Client/Utility.swift new file mode 100644 index 0000000..ce937eb --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/Utility.swift @@ -0,0 +1,404 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import Logging +import TerminalProgress + +// MARK: - Collection capacity hints +// Dictionary(minimumCapacity:) and reserveCapacity() are used in this file to +// pre-allocate storage when the final collection size is known from the input. +// This avoids incremental reallocation overhead in hot-path parser methods. + +public struct Utility { + static let publishedPortCountLimit = 64 + + public static func createContainerID(name: String?) -> String { + guard let name else { + return UUID().uuidString.lowercased() + } + return name + } + + public static func isInfraImage(name: String, builderImage: String, initImage: String) -> Bool { + for infraImage in [builderImage, initImage] { + if name == infraImage { + return true + } + } + return false + } + + public static func trimDigest(digest: String) -> String { + var hex = digest + if let colonIndex = digest.firstIndex(of: ":") { + hex = String(digest[digest.index(after: colonIndex)...]) + } + return String(hex.prefix(12)) + } + + public static func validEntityName(_ name: String) throws { + let pattern = #"^[a-zA-Z0-9][a-zA-Z0-9_.-]+$"# + let regex = try Regex(pattern) + if try regex.firstMatch(in: name) == nil { + throw ContainerizationError(.invalidArgument, message: "invalid entity name \(name)") + } + } + + public static func validMACAddress(_ macAddress: String) throws { + let pattern = #"^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$"# + let regex = try Regex(pattern) + if try regex.firstMatch(in: macAddress) == nil { + throw ContainerizationError(.invalidArgument, message: "invalid MAC address format \(macAddress), expected format: XX:XX:XX:XX:XX:XX") + } + } + + public static func containerConfigFromFlags( + id: String, + image: String, + arguments: [String], + process: Flags.Process, + management: Flags.Management, + resource: Flags.Resource, + registry: Flags.Registry, + imageFetch: Flags.ImageFetch, + containerSystemConfig: ContainerSystemConfig, + progressUpdate: @escaping ProgressUpdateHandler, + log: Logger + ) async throws -> (ContainerConfiguration, Kernel, String?) { + let requestedPlatform = try DefaultPlatform.resolveWithDefaults( + platform: management.platform, + os: management.os, + arch: management.arch, + log: log + ) + let scheme = try RequestScheme(registry.scheme) + + await progressUpdate([ + .setDescription("Fetching image"), + .setItemsName("blobs"), + ]) + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + let img = try await ClientImage.fetch( + reference: image, + platform: requestedPlatform, + scheme: scheme, + containerSystemConfig: containerSystemConfig, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate), + maxConcurrentDownloads: imageFetch.maxConcurrentDownloads + ) + + // Unpack a fetched image before use + await progressUpdate([ + .setDescription("Unpacking image"), + .setItemsName("entries"), + ]) + let unpackTask = await taskManager.startTask() + try await img.getCreateSnapshot( + platform: requestedPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)) + + await progressUpdate([ + .setDescription("Fetching kernel"), + .setItemsName("binary"), + ]) + + let kernel = try await self.getKernel(management: management) + + // Pull and unpack the initial filesystem + await progressUpdate([ + .setDescription("Fetching init image"), + .setItemsName("blobs"), + ]) + let fetchInitTask = await taskManager.startTask() + let initImageRef = management.initImage ?? containerSystemConfig.vminit.image + let initImage = try await ClientImage.fetch( + reference: initImageRef, platform: .current, scheme: scheme, + containerSystemConfig: containerSystemConfig, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate), + maxConcurrentDownloads: imageFetch.maxConcurrentDownloads) + + await progressUpdate([ + .setDescription("Unpacking init image"), + .setItemsName("entries"), + ]) + let unpackInitTask = await taskManager.startTask() + _ = try await initImage.getCreateSnapshot( + platform: .current, + progressUpdate: ProgressTaskCoordinator.handler(for: unpackInitTask, from: progressUpdate)) + + await taskManager.finish() + + let imageConfig = try await img.config(for: requestedPlatform).config + let description = img.description + let pc = try Parser.process( + arguments: arguments, + processFlags: process, + managementFlags: management, + config: imageConfig + ) + + var config = ContainerConfiguration(id: id, image: description, process: pc) + config.platform = requestedPlatform + + config.resources = try Parser.resources( + cpus: resource.cpus, + memory: resource.memory, + defaultCPUs: containerSystemConfig.container.cpus, + defaultMemory: containerSystemConfig.container.memory + ) + + let tmpfs = try Parser.tmpfsMounts(management.tmpFs) + let volumesOrFs = try Parser.volumes(management.volumes) + let mountsOrFs = try Parser.mounts(management.mounts) + + var resolvedMounts: [Filesystem] = [] + resolvedMounts.append(contentsOf: tmpfs) + + // Resolve volumes and filesystems + for item in (volumesOrFs + mountsOrFs) { + switch item { + case .filesystem(let fs): + resolvedMounts.append(fs) + case .volume(let parsed): + let volume = try await getOrCreateVolume(parsed: parsed, log: log) + let volumeMount = Filesystem.volume( + name: parsed.name, + format: volume.format, + source: volume.source, + destination: parsed.destination, + options: parsed.options + ) + resolvedMounts.append(volumeMount) + } + } + + config.mounts = resolvedMounts + + if let shmSizeStr = management.shmSize { + let measurement = try Measurement.parse(parsing: shmSizeStr) + let bytes = measurement.converted(to: .bytes) + config.shmSize = UInt64(bytes.value) + } + + config.virtualization = management.virtualization + + // Parse network specifications with properties + let parsedNetworks = try management.networks.map { try Parser.network($0) } + if management.networks.contains(NetworkClient.noNetworkName) { + guard management.networks.count == 1 else { + throw ContainerizationError(.unsupported, message: "no other networks may be created along with network \(NetworkClient.noNetworkName)") + } + config.networks = [] + } else { + let networkClient = NetworkClient() + let builtinNetworkId = try await networkClient.builtin?.id + config.networks = try getAttachmentConfigurations( + containerId: config.id, + builtinNetworkId: builtinNetworkId, + networks: parsedNetworks, + dnsDomain: containerSystemConfig.dns.domain, + ) + for attachmentConfiguration in config.networks { + _ = try await networkClient.get(id: attachmentConfiguration.network) + } + } + + if management.dnsDisabled { + config.dns = nil + } else { + let domain = management.dns.domain ?? containerSystemConfig.dns.domain + config.dns = .init( + nameservers: management.dns.nameservers, + domain: domain, + searchDomains: management.dns.searchDomains, + options: management.dns.options + ) + } + + config.rosetta = management.rosetta || (Platform.current.architecture == "arm64" && requestedPlatform.architecture == "amd64") + + if management.rosetta && Platform.current.architecture != "arm64" { + throw ContainerizationError(.unsupported, message: "--rosetta flag requires an arm64 host") + } + + config.labels = try Parser.labels(management.labels) + + config.publishedPorts = try Parser.publishPorts(management.publishPorts) + guard config.publishedPorts.count <= publishedPortCountLimit else { + throw ContainerizationError(.invalidArgument, message: "cannot exceed more than \(publishedPortCountLimit) port publish descriptors") + } + guard !config.publishedPorts.hasOverlaps() else { + throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap") + } + + // Parse --publish-socket arguments and add to container configuration + // to enable socket forwarding from container to host. + config.publishedSockets = try Parser.publishSockets(management.publishSockets) + + config.ssh = management.ssh + config.readOnly = management.readOnly + config.useInit = management.useInit + + let caps = try Parser.capabilities(capAdd: management.capAdd, capDrop: management.capDrop) + config.capAdd = caps.capAdd + config.capDrop = caps.capDrop + config.stopSignal = imageConfig?.stopSignal + + if let runtime = management.runtime { + config.runtimeHandler = runtime + } + + return (config, kernel, management.initImage) + } + + static func getAttachmentConfigurations( + containerId: String, + builtinNetworkId: String?, + networks: [Parser.ParsedNetwork], + dnsDomain: String?, + ) throws -> [AttachmentConfiguration] { + // Validate MAC addresses if provided + for network in networks { + if let mac = network.macAddress { + try validMACAddress(mac) + } + } + + // make an FQDN for the first interface + let fqdn: String? + if !containerId.contains(".") { + // add default domain if it exists, and container ID is unqualified + if let dnsDomain { + fqdn = "\(containerId).\(dnsDomain)." + } else { + fqdn = nil + } + } else { + // use container ID directly if fully qualified + fqdn = "\(containerId)." + } + + guard networks.isEmpty else { + // Check if this is only the default network with properties (e.g., MAC address) + let isOnlyDefaultNetwork = networks.count == 1 && networks[0].name == builtinNetworkId + + // networks may only be specified for macOS 26+ (except for default network with properties) + if !isOnlyDefaultNetwork { + guard #available(macOS 26, *) else { + throw ContainerizationError(.invalidArgument, message: "non-default network configuration requires macOS 26 or newer") + } + } + + // attach the first network using the fqdn, and the rest using just the container ID + return try networks.enumerated().map { item in + let macAddress = try item.element.macAddress.map { try MACAddress($0) } + let mtu = item.element.mtu ?? 1280 + guard item.offset == 0 else { + return AttachmentConfiguration( + network: item.element.name, + options: AttachmentOptions(hostname: containerId, macAddress: macAddress, mtu: mtu) + ) + } + return AttachmentConfiguration( + network: item.element.name, + options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: macAddress, mtu: mtu) + ) + } + } + + // if no networks specified, attach to the default network + guard let builtinNetworkId else { + throw ContainerizationError(.invalidState, message: "builtin network is not present") + } + return [AttachmentConfiguration(network: builtinNetworkId, options: AttachmentOptions(hostname: fqdn ?? containerId, macAddress: nil, mtu: 1280))] + } + + private static func getKernel(management: Flags.Management) async throws -> Kernel { + // For the image itself we'll take the user input and try with it as we can do userspace + // emulation for x86, but for the kernel we need it to match the hosts architecture. + let s: SystemPlatform = .current + if let userKernel = management.kernel { + guard FileManager.default.fileExists(atPath: userKernel) else { + throw ContainerizationError(.notFound, message: "kernel file not found at path \(userKernel)") + } + let p = URL(filePath: userKernel) + return .init(path: p, platform: s) + } + return try await ClientKernel.getDefaultKernel(for: s) + } + + /// Parses key-value pairs from command line arguments. + /// + /// Supports formats like "key=value" and standalone keys (treated as "key="). + /// - Parameter pairs: Array of strings in "key=value" format + /// - Returns: Dictionary mapping keys to values + public static func parseKeyValuePairs(_ pairs: [String]) -> [String: String] { + var result: [String: String] = Dictionary(minimumCapacity: pairs.count) + for pair in pairs { + let components = pair.split(separator: "=", maxSplits: 1) + if components.count == 2 { + result[String(components[0])] = String(components[1]) + } else { + result[pair] = "" + } + } + return result + } + + /// Gets an existing volume or creates it if it doesn't exist. + /// Shows a warning for named volumes when auto-creating. + private static func getOrCreateVolume(parsed: ParsedVolume, log: Logger) async throws -> VolumeConfiguration { + let labels = parsed.isAnonymous ? [VolumeConfiguration.anonymousLabel: ""] : [:] + + let volume: VolumeConfiguration + var wasCreated = false + do { + volume = try await ClientVolume.create( + name: parsed.name, + driver: "local", + driverOpts: [:], + labels: labels + ) + wasCreated = true + } catch let error as VolumeError { + guard case .volumeAlreadyExists = error else { + throw error + } + // Volume already exists, just inspect it + volume = try await ClientVolume.inspect(parsed.name) + } catch let error as ContainerizationError { + // Handle XPC-wrapped volumeAlreadyExists error + guard error.message.contains("already exists") else { + throw error + } + volume = try await ClientVolume.inspect(parsed.name) + } + + if wasCreated && !parsed.isAnonymous { + log.warning("named volume was automatically created", metadata: ["volume": "\(parsed.name)"]) + } + + return volume + } +} diff --git a/Sources/Services/ContainerAPIService/Client/XPC+.swift b/Sources/Services/ContainerAPIService/Client/XPC+.swift new file mode 100644 index 0000000..499b82b --- /dev/null +++ b/Sources/Services/ContainerAPIService/Client/XPC+.swift @@ -0,0 +1,282 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Foundation +import ContainerXPC + +/// Keys for XPC fields. +public enum XPCKeys: String { + /// Route key. + case route + /// Container array key. + case containers + /// ID key. + case id + // ID for a process. + case processIdentifier + /// Container configuration key. + case containerConfig + /// Container options key. + case containerOptions + /// Opaque runtime-specific data. + case runtimeData + /// Vsock port number key. + case port + /// Exit code for a process + case exitCode + /// Exit timestamp for a process + case exitedAt + /// An event that occurred in a container + case containerEvent + /// Error key. + case error + /// FD to a container resource key. + case fd + /// FDs pointing to container logs key. + case logs + /// Options for stopping a container key. + case stopOptions + /// Whether to force stop a container when deleting. + case forceDelete + /// Plugins + case pluginName + case plugins + case plugin + /// Archive path to export rootfs + case archive + /// Special-case environment variables recomputed on each container start + case dynamicEnv + + /// Health check request. + case ping + case appRoot + case installRoot + case logRoot + case apiServerVersion + case apiServerCommit + case apiServerBuild + case apiServerAppName + + /// Process request keys. + case signal + case snapshot + case stdin + case stdout + case stderr + case status + case width + case height + case processConfig + + /// Update progress + case progressUpdateEndpoint + case progressUpdateSetDescription + case progressUpdateSetSubDescription + case progressUpdateSetItemsName + case progressUpdateAddTasks + case progressUpdateSetTasks + case progressUpdateAddTotalTasks + case progressUpdateSetTotalTasks + case progressUpdateAddItems + case progressUpdateSetItems + case progressUpdateAddTotalItems + case progressUpdateSetTotalItems + case progressUpdateAddSize + case progressUpdateSetSize + case progressUpdateAddTotalSize + case progressUpdateSetTotalSize + + /// Network + case networkId + case networkConfig + case networkResource + case networkResources + + /// Kernel + case kernel + case kernelTarURL + case kernelFilePath + case systemPlatform + case kernelForce + + /// Init image reference + case initImage + + /// Volume + case volume + case volumes + case volumeName + case volumeSize + case volumeDriver + case volumeDriverOpts + case volumeLabels + case volumeReadonly + case volumeContainerId + + /// Container statistics + case statistics + case containerSize + + /// Container list filters + case listFilters + + /// Disk usage + case diskUsageStats + + /// Copy parameters + case sourcePath + case destinationPath + case fileMode + case createParents +} + +public enum XPCRoute: String { + case containerList + case containerCreate + case containerBootstrap + case containerCreateProcess + case containerStartProcess + case containerWait + case containerDelete + case containerStop + case containerDial + case containerResize + case containerKill + case containerState + case containerLogs + case containerEvent + case containerStats + case containerDiskUsage + case containerCopyIn + case containerCopyOut + case containerExport + + case pluginLoad + case pluginGet + case pluginRestart + case pluginUnload + case pluginList + + case networkCreate + case networkDelete + case networkList + + case volumeCreate + case volumeDelete + case volumeList + case volumeInspect + + case volumeDiskUsage + case systemDiskUsage + + case ping + + case installKernel + case getDefaultKernel +} + +extension XPCMessage { + public init(route: XPCRoute) { + self.init(route: route.rawValue) + } + + public func data(key: XPCKeys) -> Data? { + data(key: key.rawValue) + } + + public func dataNoCopy(key: XPCKeys) -> Data? { + dataNoCopy(key: key.rawValue) + } + + public func set(key: XPCKeys, value: Data) { + set(key: key.rawValue, value: value) + } + + public func string(key: XPCKeys) -> String? { + string(key: key.rawValue) + } + + public func set(key: XPCKeys, value: String) { + set(key: key.rawValue, value: value) + } + + public func bool(key: XPCKeys) -> Bool { + bool(key: key.rawValue) + } + + public func set(key: XPCKeys, value: Bool) { + set(key: key.rawValue, value: value) + } + + public func uint64(key: XPCKeys) -> UInt64 { + uint64(key: key.rawValue) + } + + public func set(key: XPCKeys, value: UInt64) { + set(key: key.rawValue, value: value) + } + + public func int64(key: XPCKeys) -> Int64 { + int64(key: key.rawValue) + } + + public func set(key: XPCKeys, value: Int64) { + set(key: key.rawValue, value: value) + } + + public func int(key: XPCKeys) -> Int { + Int(int64(key: key.rawValue)) + } + + public func set(key: XPCKeys, value: Int) { + set(key: key.rawValue, value: Int64(value)) + } + + public func date(key: XPCKeys) -> Date { + date(key: key.rawValue) + } + + public func set(key: XPCKeys, value: Date) { + set(key: key.rawValue, value: value) + } + + public func fileHandle(key: XPCKeys) -> FileHandle? { + fileHandle(key: key.rawValue) + } + + public func set(key: XPCKeys, value: FileHandle) { + set(key: key.rawValue, value: value) + } + + public func fileHandles(key: XPCKeys) -> [FileHandle]? { + fileHandles(key: key.rawValue) + } + + public func set(key: XPCKeys, value: [FileHandle]) throws { + try set(key: key.rawValue, value: value) + } + + public func endpoint(key: XPCKeys) -> xpc_endpoint_t? { + endpoint(key: key.rawValue) + } + + public func set(key: XPCKeys, value: xpc_endpoint_t) { + set(key: key.rawValue, value: value) + } +} + +#endif diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift new file mode 100644 index 0000000..d7da46e --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersHarness.swift @@ -0,0 +1,389 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging + +public struct ContainersHarness: Sendable { + let log: Logging.Logger + let service: ContainersService + + public init(service: ContainersService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + public func list(_ message: XPCMessage) async throws -> XPCMessage { + var filters = ContainerListFilters.all + if let filterData = message.dataNoCopy(key: .listFilters) { + filters = try JSONDecoder().decode(ContainerListFilters.self, from: filterData) + } + let containers = try await service.list(filters: filters) + let data = try JSONEncoder().encode(containers) + + let reply = message.reply() + reply.set(key: .containers, value: data) + return reply + } + + @Sendable + public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let stdio = message.stdio() + + let data = message.dataNoCopy(key: .dynamicEnv) + let env = try data.map { try JSONDecoder().decode([String: String].self, from: $0) } ?? [:] + + try await service.bootstrap(id: id, stdio: stdio, dynamicEnv: env) + return message.reply() + } + + @Sendable + public func stop(_ message: XPCMessage) async throws -> XPCMessage { + let stopOptions = try message.stopOptions() + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + try await service.stop(id: id, options: stopOptions) + return message.reply() + } + + @Sendable + public func dial(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + + let port = message.uint64(key: .port) + let fh = try await service.dial(id: id, port: UInt32(port)) + let reply = message.reply() + reply.setFileHandle(fh) + + return reply + } + + @Sendable + public func wait(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + + let exitStatus = try await service.wait(id: id, processID: processID) + let reply = message.reply() + reply.set(key: .exitCode, value: Int64(exitStatus.exitCode)) + reply.set(key: .exitedAt, value: exitStatus.exitedAt) + return reply + } + + @Sendable + public func resize(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + + let width = message.uint64(key: .width) + let height = message.uint64(key: .height) + try await service.resize( + id: id, + processID: processID, + size: Terminal.Size(width: UInt16(width), height: UInt16(height)) + ) + + return message.reply() + } + + @Sendable + public func kill(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + try await service.kill( + id: id, + processID: processID, + signal: try message.signal() + ) + return message.reply() + } + + @Sendable + public func create(_ message: XPCMessage) async throws -> XPCMessage { + let data = message.dataNoCopy(key: .containerConfig) + guard let data else { + throw ContainerizationError( + .invalidArgument, + message: "container configuration cannot be empty" + ) + } + let kdata = message.dataNoCopy(key: .kernel) + guard let kdata else { + throw ContainerizationError( + .invalidArgument, + message: "kernel cannot be empty" + ) + } + let odata = message.dataNoCopy(key: .containerOptions) + var options: ContainerCreateOptions = .default + if let odata { + options = try JSONDecoder().decode(ContainerCreateOptions.self, from: odata) + } + let config = try JSONDecoder().decode(ContainerConfiguration.self, from: data) + let kernel = try JSONDecoder().decode(Kernel.self, from: kdata) + + let initImage = message.string(key: .initImage) + let runtimeData = message.dataNoCopy(key: .runtimeData) + + try await service.create(configuration: config, kernel: kernel, options: options, initImage: initImage, runtimeData: runtimeData) + return message.reply() + } + + @Sendable + public func createProcess(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + let config = try message.processConfig() + let stdio = message.stdio() + + try await service.createProcess( + id: id, + processID: processID, + config: config, + stdio: stdio + ) + + return message.reply() + } + + @Sendable + public func startProcess(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let processID = message.string(key: .processIdentifier) + guard let processID else { + throw ContainerizationError( + .invalidArgument, + message: "process ID cannot be empty" + ) + } + + try await service.startProcess( + id: id, + processID: processID, + ) + + return message.reply() + } + + @Sendable + public func delete(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + let forceDelete = message.bool(key: .forceDelete) + try await service.delete(id: id, force: forceDelete) + return message.reply() + } + + @Sendable + public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage { + guard let containerId = message.string(key: .id) else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + + let size = try await service.containerDiskUsage(id: containerId) + + let reply = message.reply() + reply.set(key: .containerSize, value: size) + return reply + } + + @Sendable + public func logs(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let fds = try await service.logs(id: id) + let reply = message.reply() + try reply.set(key: .logs, value: fds) + return reply + } + + @Sendable + public func copyIn(_ message: XPCMessage) async throws -> XPCMessage { + guard let id = message.string(key: .id) else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + guard let sourcePath = message.string(key: .sourcePath) else { + throw ContainerizationError( + .invalidArgument, + message: "source path cannot be empty" + ) + } + guard let destinationPath = message.string(key: .destinationPath) else { + throw ContainerizationError( + .invalidArgument, + message: "destination path cannot be empty" + ) + } + let mode = UInt32(message.uint64(key: .fileMode)) + let createParents = message.bool(key: .createParents) + + try await service.copyIn(id: id, source: sourcePath, destination: destinationPath, mode: mode, createParents: createParents) + return message.reply() + } + + @Sendable + public func copyOut(_ message: XPCMessage) async throws -> XPCMessage { + guard let id = message.string(key: .id) else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + guard let sourcePath = message.string(key: .sourcePath) else { + throw ContainerizationError( + .invalidArgument, + message: "source path cannot be empty" + ) + } + guard let destinationPath = message.string(key: .destinationPath) else { + throw ContainerizationError( + .invalidArgument, + message: "destination path cannot be empty" + ) + } + + let createParents = message.bool(key: .createParents) + + try await service.copyOut(id: id, source: sourcePath, destination: destinationPath, createParents: createParents) + return message.reply() + } + + @Sendable + public func stats(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let stats = try await service.stats(id: id) + let data = try JSONEncoder().encode(stats) + let reply = message.reply() + reply.set(key: .statistics, value: data) + return reply + } + + @Sendable + public func export(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .id) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "id cannot be empty" + ) + } + let archive = message.string(key: .archive) + guard let archive else { + throw ContainerizationError( + .invalidArgument, + message: "archive cannot be empty" + ) + } + let archiveUrl = URL(fileURLWithPath: archive) + + try await service.exportRootfs(id: id, archive: archiveUrl) + return message.reply() + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift new file mode 100644 index 0000000..41f33d4 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Containers/ContainersService.swift @@ -0,0 +1,1196 @@ +//===----------------------------------------------------------------------===// +// 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 CVersion +import ContainerAPIClient +import ContainerPersistence +import ContainerPlugin +import ContainerResource +import ContainerRuntimeClient +import ContainerXPC +import Containerization +import ContainerizationEXT4 +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import Logging +import SystemPackage + +public actor ContainersService { + struct ContainerState { + var snapshot: ContainerSnapshot + var client: RuntimeClient? = nil + + func getClient() throws -> RuntimeClient { + guard let client else { + var message = "no runtime client exists" + if snapshot.status == .stopped { + message += ": container is stopped" + } + throw ContainerizationError(.invalidState, message: message) + } + return client + } + } + + private static let machServicePrefix = "com.apple.container" + private static let launchdDomainString = try! ServiceManager.getDomainString() + + private let log: Logger + private let debugHelpers: Bool + private let containerRoot: URL + private let pluginLoader: PluginLoader + private let runtimePlugins: [Plugin] + private let exitMonitor: ExitMonitor + private let containerSystemConfig: ContainerSystemConfig + + private let lock: AsyncLock + private var containers: [String: ContainerState] + + // FIXME: Find a better mechanism for services running on the APIServer to work with each other + private weak var networksService: NetworksService? + + public init( + appRoot: URL, + pluginLoader: PluginLoader, + containerSystemConfig: ContainerSystemConfig, + log: Logger, + debugHelpers: Bool = false + ) throws { + let containerRoot = appRoot.appendingPathComponent("containers") + try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true) + self.exitMonitor = ExitMonitor(log: log) + self.lock = AsyncLock(log: log) + self.containerRoot = containerRoot + self.pluginLoader = pluginLoader + self.containerSystemConfig = containerSystemConfig + self.log = log + self.debugHelpers = debugHelpers + self.runtimePlugins = pluginLoader.findPlugins().filter { $0.hasType(.runtime) } + self.containers = try Self.loadAtBoot(root: containerRoot, loader: pluginLoader, log: log) + } + + public func setNetworksService(_ service: NetworksService) async { + self.networksService = service + } + + static func loadAtBoot(root: URL, loader: PluginLoader, log: Logger) throws -> [String: ContainerState] { + var directories = try FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey] + ) + directories = directories.filter { + $0.isDirectory + } + + let runtimePlugins = loader.findPlugins().filter { $0.hasType(.runtime) } + var results = [String: ContainerState]() + for dir in directories { + do { + let (config, options) = try Self.getContainerConfiguration(at: dir) + if options?.autoRemove ?? false { + log.info( + "reap auto-remove container", + metadata: [ + "id": "\(config.id)" + ]) + + let label = Self.fullLaunchdServiceLabel( + runtimeName: config.runtimeHandler, + instanceId: config.id) + + var status: Int32 = -1 + try? ServiceManager.deregister(fullServiceLabel: label, status: &status) + if status != 0 { + log.warning( + "failed to deregister service", + metadata: [ + "id": "\(config.id)", + "service": "\(label)", + "status": "\(status)", + ] + ) + } + + let bundle = ContainerResource.Bundle(path: dir) + try? bundle.delete() + continue + } + + let state = ContainerState( + snapshot: .init( + configuration: config, + status: .stopped, + networks: [], + startedDate: nil + ), + ) + results[config.id] = state + guard runtimePlugins.first(where: { $0.name == config.runtimeHandler }) != nil else { + throw ContainerizationError( + .internalError, + message: "failed to find runtime plugin \(config.runtimeHandler)" + ) + } + } catch { + try? FileManager.default.removeItem(at: dir) + log.warning( + "failed to load container", + metadata: [ + "path": "\(dir.path)", + "error": "\(error)", + ]) + } + } + return results + } + + /// List containers matching the given filters. + public func list(filters: ContainerListFilters = .all) async throws -> [ContainerSnapshot] { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)" + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)" + ] + ) + } + + let labelPatterns: [(key: String, regex: Regex)] = try filters.labels.map { key, pattern in + do { + return (key: key, regex: try Regex(pattern)) + } catch { + throw ContainerizationError( + .invalidArgument, message: "failed to compile regex '\(pattern)' for \(key)", + cause: error) + } + } + + return self.containers.values.compactMap { state -> ContainerSnapshot? in + let snapshot = state.snapshot + + if !filters.ids.isEmpty { + guard filters.ids.contains(snapshot.id) else { + return nil + } + } + + if let status = filters.status { + guard snapshot.status == status else { + return nil + } + } + + for (key, regex) in labelPatterns { + let label = snapshot.configuration.labels[key] ?? "" + + guard label.contains(regex) else { + return nil + } + } + + return snapshot + } + } + + /// Execute an operation with the current container list while maintaining atomicity + /// This prevents race conditions where containers are created during the operation + public func withContainerList( + logMetadata: Logger.Metadata? = nil, + _ operation: @Sendable @escaping ([ContainerSnapshot]) async throws -> T + ) async throws -> T { + try await lock.withLock(logMetadata: logMetadata) { context in + let snapshots = await self.containers.values.map { $0.snapshot } + return try await operation(snapshots) + } + } + + /// Calculate disk usage for containers + /// - Returns: Tuple of (total count, active count, total size, reclaimable size) + public func calculateDiskUsage() async -> (Int, Int, UInt64, UInt64) { + await lock.withLock(logMetadata: ["acquirer": "\(#function)"]) { _ in + var totalSize: UInt64 = 0 + var reclaimableSize: UInt64 = 0 + var activeCount = 0 + + for (id, state) in await self.containers { + let bundlePath = self.containerRoot.appendingPathComponent(id) + let containerSize = FileManager.default.allocatedSize(of: bundlePath) + totalSize += containerSize + + if state.snapshot.status == .running { + activeCount += 1 + } else { + // Stopped containers are reclaimable + reclaimableSize += containerSize + } + } + + return (await self.containers.count, activeCount, totalSize, reclaimableSize) + } + } + + /// Get set of image references used by containers (for disk usage calculation) + /// - Returns: Set of image references currently in use + public func getActiveImageReferences() async -> Set { + await lock.withLock(logMetadata: ["acquirer": "\(#function)"]) { _ in + var imageRefs = Set() + for (_, state) in await self.containers { + imageRefs.insert(state.snapshot.configuration.image.reference) + } + return imageRefs + } + } + + /// Create a new container from the provided id and configuration. + public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions, initImage: String? = nil, runtimeData: Data? = nil) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(configuration.id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(configuration.id)", + ] + ) + } + + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(configuration.id)"]) { context in + guard await self.containers[configuration.id] == nil else { + throw ContainerizationError( + .exists, + message: "container already exists: \(configuration.id)" + ) + } + + var allHostnames = Set() + for container in await self.containers.values { + for attachmentConfiguration in container.snapshot.configuration.networks { + allHostnames.insert(attachmentConfiguration.options.hostname) + } + } + + var conflictingHostnames = [String]() + for attachmentConfiguration in configuration.networks { + if allHostnames.contains(attachmentConfiguration.options.hostname) { + conflictingHostnames.append(attachmentConfiguration.options.hostname) + } + } + + guard conflictingHostnames.isEmpty else { + throw ContainerizationError( + .exists, + message: "hostname(s) already exist: \(conflictingHostnames)" + ) + } + + guard self.runtimePlugins.first(where: { $0.name == configuration.runtimeHandler }) != nil else { + throw ContainerizationError( + .notFound, + message: "unable to locate runtime plugin \(configuration.runtimeHandler)" + ) + } + + // Protect against a user providing a memory amount that will cause us to not be able + // to boot. We can go lower, but this is a somewhat safe threshold. Containerization + // also gives a little bit extra than the user asked for to account for guest agent overhead. + // + // NOTE: We could potentially leave this validation to the runtime service(s), as + // it's possible there could be an implementation that can get away with a lower + // amount and be perfectly safe. + let minimumMemory: UInt64 = 200.mib() + guard configuration.resources.memoryInBytes >= minimumMemory else { + throw ContainerizationError( + .invalidArgument, + message: "minimum memory amount allowed is 200 MiB (got \(configuration.resources.memoryInBytes) bytes)" + ) + } + + let path = self.containerRoot.appendingPathComponent(configuration.id) + let systemPlatform = kernel.platform + + // Fetch init image (custom or default) + self.log.debug( + "ContainersService: get init block", + metadata: [ + "id": "\(configuration.id)" + ] + ) + let initFilesystem = try await self.getInitBlock(for: systemPlatform.ociPlatform(), imageRef: initImage) + + do { + self.log.debug( + "create snapshot", + metadata: [ + "id": "\(configuration.id)", + "ref": "\(configuration.image.reference)", + ]) + let containerImage = ClientImage(description: configuration.image) + let imageFs = try await options.rootFsOverride == nil ? containerImage.getCreateSnapshot(platform: configuration.platform) : nil + + self.log.debug( + "configure runtime", + metadata: [ + "id": "\(configuration.id)", + "kernel": "\(kernel.path)", + "initfs": "\(initImage ?? self.containerSystemConfig.vminit.image)", + ]) + let runtimeConfig = RuntimeConfiguration( + path: path, + initialFilesystem: initFilesystem, + kernel: kernel, + containerConfiguration: configuration, + containerRootFilesystem: imageFs, + options: options, + runtimeData: runtimeData + ) + + try runtimeConfig.writeRuntimeConfiguration() + + let snapshot = ContainerSnapshot( + configuration: configuration, + status: .stopped, + networks: [], + startedDate: nil + ) + await self.setContainerState(configuration.id, ContainerState(snapshot: snapshot), context: context) + } catch { + throw error + } + } + } + + /// Bootstrap the init process of the container. + public func bootstrap(id: String, stdio: [FileHandle?], dynamicEnv: [String: String]) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "env": "\(dynamicEnv)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in + var state = try await self.getContainerState(id: id, context: context) + + // We've already bootstrapped this container. Ideally we should be able to + // return some sort of error code from the sandbox svc to check here, but this + // is also a very simple check and faster than doing an rpc to get the same result. + if state.client != nil { + return + } + + let path = self.containerRoot.appendingPathComponent(id) + let (config, _) = try Self.getContainerConfiguration(at: path) + + var networkBootstrapInfos = [NetworkBootstrapInfo]() + for n in config.networks { + guard let plugin = try await self.networksService?.plugin(for: n.network) else { + throw ContainerizationError(.internalError, message: "failed to get plugin for network \(n.network)") + } + networkBootstrapInfos.append(NetworkBootstrapInfo(plugin: plugin)) + } + + do { + try Self.registerService( + plugin: self.runtimePlugins.first { $0.name == config.runtimeHandler }!, + loader: self.pluginLoader, + configuration: config, + path: path, + debug: self.debugHelpers + ) + + let runtime = state.snapshot.configuration.runtimeHandler + let runtimeClient = try await RuntimeClient.create( + id: id, + runtime: runtime + ) + try await runtimeClient.bootstrap(stdio: stdio, networkBootstrapInfos: networkBootstrapInfos, dynamicEnv: dynamicEnv) + + try await self.exitMonitor.registerProcess( + id: id, + onExit: self.handleContainerExit + ) + + state.client = runtimeClient + await self.setContainerState(id, state, context: context) + } catch { + let label = Self.fullLaunchdServiceLabel( + runtimeName: config.runtimeHandler, + instanceId: id + ) + + await self.exitMonitor.stopTracking(id: id) + try? ServiceManager.deregister(fullServiceLabel: label) + throw error + } + } + } + + /// Create a new process in the container. + public func createProcess( + id: String, + processID: String, + config: ProcessConfiguration, + stdio: [FileHandle?] + ) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + "command": "\(config.arguments.isEmpty ? "" : config.arguments[0])", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + try await client.createProcess( + processID, + config: config, + stdio: stdio + ) + } + + /// Start a process in a container. This can either be a process created via + /// createProcess, or the init process of the container which requires + /// id == processID. + public func startProcess(id: String, processID: String) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + ] + ) + } + + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)", "processId": "\(processID)"]) { context in + var state = try await self.getContainerState(id: id, context: context) + + let isInit = Self.isInitProcess(id: id, processID: processID) + if state.snapshot.status == .running && isInit { + return + } + + let client = try state.getClient() + try await client.startProcess(processID) + + guard isInit else { + return + } + + do { + let log = self.log + let waitFunc: ExitMonitor.WaitHandler = { + log.info("registering container with exit monitor") + let code = try await client.wait(id) + log.info( + "container finished in exit monitor", + metadata: [ + "id": "\(id)", + "rc": "\(code)", + ]) + + return code + } + try await self.exitMonitor.track(id: id, waitingOn: waitFunc) + + let sandboxSnapshot = try await client.state() + state.snapshot.status = .running + state.snapshot.networks = sandboxSnapshot.networks + state.snapshot.startedDate = Date() + await self.setContainerState(id, state, context: context) + } catch { + await self.exitMonitor.stopTracking(id: id) + try? await client.stop(options: ContainerStopOptions.default) + throw error + } + } + } + + /// Send a signal to the container. + public func kill(id: String, processID: String, signal: String) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + "signal": "\(signal)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + ] + ) + } + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + try await client.kill(processID, signal: signal) + + // SIGKILL is guaranteed to terminate the target. When directed at the + // container's init process, follow up with the same API-server cleanup + // that `stop` performs. + if processID == id, (try? Signal(signal)) == .kill { + try await handleContainerExit(id: id) + } + } + + /// Stop all containers inside the sandbox, aborting any processes currently + /// executing inside the container, before stopping the underlying sandbox. + public func stop(id: String, options: ContainerStopOptions) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + let state = try self._getContainerState(id: id) + + // Stop should be idempotent. + let client: RuntimeClient + do { + client = try state.getClient() + } catch { + return + } + + var resolvedOptions = options + if resolvedOptions.signal == nil, let stopSignal = state.snapshot.configuration.stopSignal { + resolvedOptions.signal = stopSignal + } + + do { + try await client.stop(options: resolvedOptions) + } catch let err as ContainerizationError { + if err.code != .interrupted { + throw err + } + } + try await handleContainerExit(id: id) + } + + public func dial(id: String, port: UInt32) async throws -> FileHandle { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "port": "\(port)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "port": "\(port)", + ] + ) + } + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + return try await client.dial(port) + } + + /// Wait waits for the container's init process or exec to exit and returns the + /// exit status. + public func wait(id: String, processID: String) async throws -> ExitStatus { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + ] + ) + } + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + return try await client.wait(processID) + } + + /// Resize resizes the container's PTY if one exists. + public func resize(id: String, processID: String, size: Terminal.Size) async throws { + log.trace( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + ] + ) + defer { + log.trace( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "processId": "\(processID)", + ] + ) + } + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + try await client.resize(processID, size: size) + } + + // Get the logs for the container. + public func logs(id: String) async throws -> [FileHandle] { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + // Logs doesn't care if the container is running or not, just that + // the bundle is there, and that the files actually exist. We do + // first try and get the container state so we get a nicer error message + // (container foo not found) however. + do { + _ = try _getContainerState(id: id) + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerResource.Bundle(path: path) + return [ + try FileHandle(forReadingFrom: bundle.containerLog), + try FileHandle(forReadingFrom: bundle.bootlog), + ] + } catch { + throw ContainerizationError( + .internalError, + message: "failed to open container logs: \(error)" + ) + } + } + + /// Copy a file or directory from the host into the container. + public func copyIn(id: String, source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container \(id) is not running") + } + let client = try state.getClient() + try await client.copyIn(source: source, destination: destination, mode: mode, createParents: createParents) + } + + /// Copy a file or directory from the container to the host. + public func copyOut(id: String, source: String, destination: String, createParents: Bool = true) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .running else { + throw ContainerizationError(.invalidState, message: "container \(id) is not running") + } + let client = try state.getClient() + try await client.copyOut(source: source, destination: destination, createParents: createParents) + } + + /// Get statistics for the container. + public func stats(id: String) async throws -> ContainerStats { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + let state = try self._getContainerState(id: id) + let client = try state.getClient() + return try await client.statistics() + } + + /// Delete a container and its resources. + public func delete(id: String, force: Bool) async throws { + log.info( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + "force": "\(force)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + let state = try self._getContainerState(id: id) + switch state.snapshot.status { + case .running: + if !force { + throw ContainerizationError( + .invalidState, + message: "container \(id) is \(state.snapshot.status) and can not be deleted" + ) + } + let opts = ContainerStopOptions( + timeoutInSeconds: 5, + signal: "SIGKILL" + ) + let client = try state.getClient() + try await client.stop(options: opts) + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in + self.log.info( + "ContainersService: attempt cleanup", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + try await self.cleanUp(id: id, context: context) + self.log.info( + "ContainersService: successful cleanup", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + case .stopping: + throw ContainerizationError( + .invalidState, + message: "container \(id) is \(state.snapshot.status) and can not be deleted" + ) + default: + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { context in + try await self.cleanUp(id: id, context: context) + } + } + } + + public func containerDiskUsage(id: String) async throws -> UInt64 { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + let containerPath = self.containerRoot.appendingPathComponent(id).path + + return FileManager.default.allocatedSize(of: URL(fileURLWithPath: containerPath)) + } + + public func exportRootfs(id: String, archive: URL) async throws { + self.log.debug("\(#function)") + + let state = try self._getContainerState(id: id) + guard state.snapshot.status == .stopped else { + throw ContainerizationError(.invalidState, message: "container is not stopped") + } + + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerResource.Bundle(path: path) + let rootfs = bundle.containerRootfsBlock + try EXT4.EXT4Reader(blockDevice: FilePath(rootfs)).export(archive: FilePath(archive)) + } + + private func handleContainerExit(id: String, code: ExitStatus? = nil) async throws { + try await self.lock.withLock(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { [self] context in + try await handleContainerExit(id: id, code: code, context: context) + } + } + + private func handleContainerExit(id: String, code: ExitStatus?, context: AsyncLock.Context) async throws { + if let code { + self.log.info( + "handling container exit", + metadata: [ + "id": "\(id)", + "rc": "\(code)", + ]) + } + + var state: ContainerState + do { + state = try self.getContainerState(id: id, context: context) + if state.snapshot.status == .stopped { + return + } + } catch { + // Was auto removed by the background thread, nothing for us to do. + return + } + + await self.exitMonitor.stopTracking(id: id) + + // Shutdown and deregister the runtime service + self.log.info("shutting down runtime service", metadata: ["id": "\(id)"]) + + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerResource.Bundle(path: path) + let config = try bundle.configuration + let label = Self.fullLaunchdServiceLabel( + runtimeName: config.runtimeHandler, + instanceId: id + ) + + // Try to shutdown the client gracefully, but if the runtime service + // is already dead (e.g., killed externally), we should still continue + // with state cleanup. + if let client = state.client { + do { + try await client.shutdown() + } catch { + self.log.error( + "failed to shutdown runtime service", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) + } + } + + // Deregister the service, launchd will terminate the process. + // This may also fail if the service was already deregistered or + // the process was killed externally. + do { + try ServiceManager.deregister(fullServiceLabel: label) + self.log.info("deregistered runtime service", metadata: ["id": "\(id)"]) + } catch { + self.log.error( + "failed to deregister runtime service", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) + } + + state.snapshot.status = .stopped + state.snapshot.networks = [] + state.client = nil + await self.setContainerState(id, state, context: context) + + let options = try getContainerCreationOptions(id: id) + if options.autoRemove { + try await self.cleanUp(id: id, context: context) + } + } + + private static func fullLaunchdServiceLabel(runtimeName: String, instanceId: String) -> String { + "\(Self.launchdDomainString)/\(Self.machServicePrefix).\(runtimeName).\(instanceId)" + } + + private func _cleanUp(id: String) async throws { + log.debug( + "ContainersService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "ContainersService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + // Did the exit container handler win? + if self.containers[id] == nil { + return + } + + // To be pedantic. This is only needed if something in the "launch + // the init process" lifecycle fails before actually fork+exec'ing + // the OCI runtime. + await self.exitMonitor.stopTracking(id: id) + let path = self.containerRoot.appendingPathComponent(id) + + // Try to get config for service deregistration + // Don't fail if bundle is incomplete + var config: ContainerConfiguration? + let bundle = ContainerResource.Bundle(path: path) + do { + config = try bundle.configuration + } catch { + self.log.warning( + "failed to read bundle configuration during cleanup for container", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) + } + + // Only try to deregister service if we have a valid config + // TODO: Change this so we don't have to reread the config + // possibly store the container ID to service label mapping + if let config = config { + let label = Self.fullLaunchdServiceLabel( + runtimeName: config.runtimeHandler, + instanceId: id + ) + try? ServiceManager.deregister(fullServiceLabel: label) + } + + // Always try to delete the bundle directory, even if it's incomplete + do { + try bundle.delete() + } catch { + self.log.warning( + "failed to delete bundle for container", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) + } + + self.containers.removeValue(forKey: id) + } + + private func cleanUp(id: String, context: AsyncLock.Context) async throws { + try await self._cleanUp(id: id) + } + + private func getContainerCreationOptions(id: String) throws -> ContainerCreateOptions { + let path = self.containerRoot.appendingPathComponent(id) + let bundle = ContainerResource.Bundle(path: path) + let options: ContainerCreateOptions = try bundle.load(filename: "options.json") + return options + } + + private func getInitBlock(for platform: Platform, imageRef: String? = nil) async throws -> Filesystem { + let ref = imageRef ?? containerSystemConfig.vminit.image + let initImage = try await ClientImage.fetch(reference: ref, platform: platform, containerSystemConfig: containerSystemConfig) + var fs = try await initImage.getCreateSnapshot(platform: platform) + fs.options = ["ro"] + return fs + } + + private static func registerService( + plugin: Plugin, + loader: PluginLoader, + configuration: ContainerConfiguration, + path: URL, + debug: Bool + ) throws { + let args = [ + "start", + "--root", path.path, + "--uuid", configuration.id, + debug ? "--debug" : nil, + ].compactMap { $0 } + try loader.registerWithLaunchd( + plugin: plugin, + pluginStateRoot: path, + args: args, + instanceId: configuration.id + ) + } + + private func setContainerState(_ id: String, _ state: ContainerState, context: AsyncLock.Context) async { + self.containers[id] = state + } + + private func getContainerState(id: String, context: AsyncLock.Context) throws -> ContainerState { + try self._getContainerState(id: id) + } + + private func _getContainerState(id: String) throws -> ContainerState { + let state = self.containers[id] + guard let state else { + throw ContainerizationError( + .notFound, + message: "container with ID \(id) not found" + ) + } + return state + } + + private static func isInitProcess(id: String, processID: String) -> Bool { + id == processID + } + + /// Get container configuration, either from existing bundle or from RuntimeConfiguration + private static func getContainerConfiguration(at path: URL) throws -> (ContainerConfiguration, ContainerCreateOptions?) { + let bundle = ContainerResource.Bundle(path: path) + do { + let config = try bundle.configuration + let options: ContainerCreateOptions? = try? bundle.load(filename: "options.json") + return (config, options) + } catch { + // Bundle doesn't exist or incomplete, try runtime configuration + // This handles containers that were created but not started yet + let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: path) + guard let config = runtimeConfig.containerConfiguration else { + throw ContainerizationError(.internalError, message: "runtime configuration missing container configuration") + } + return (config, runtimeConfig.options) + } + } +} + +extension XPCMessage { + func signal() throws -> String { + guard let signal = self.string(key: .signal) else { + throw ContainerizationError(.invalidArgument, message: "missing signal in xpc message") + } + return signal + } + + func stopOptions() throws -> ContainerStopOptions { + guard let data = self.dataNoCopy(key: .stopOptions) else { + throw ContainerizationError(.invalidArgument, message: "empty StopOptions") + } + return try JSONDecoder().decode(ContainerStopOptions.self, from: data) + } + + func setState(_ state: SandboxSnapshot) throws { + let data = try JSONEncoder().encode(state) + self.set(key: .snapshot, value: data) + } + + func stdio() -> [FileHandle?] { + var handles = [FileHandle?](repeating: nil, count: 3) + if let stdin = self.fileHandle(key: .stdin) { + handles[0] = stdin + } + if let stdout = self.fileHandle(key: .stdout) { + handles[1] = stdout + } + if let stderr = self.fileHandle(key: .stderr) { + handles[2] = stderr + } + return handles + } + + func setFileHandle(_ handle: FileHandle) { + self.set(key: .fd, value: handle) + } + + func processConfig() throws -> ProcessConfiguration { + guard let data = self.dataNoCopy(key: .processConfig) else { + throw ContainerizationError(.invalidArgument, message: "empty process configuration") + } + return try JSONDecoder().decode(ProcessConfiguration.self, from: data) + } +} diff --git a/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageHarness.swift b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageHarness.swift new file mode 100644 index 0000000..b111e57 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageHarness.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import Foundation +import Logging + +/// XPC harness for disk usage operations +public struct DiskUsageHarness: Sendable { + let log: Logger + let service: DiskUsageService + + public init(service: DiskUsageService, log: Logger) { + self.log = log + self.service = service + } + + @Sendable + public func get(_ message: XPCMessage) async throws -> XPCMessage { + do { + let stats = try await service.calculateDiskUsage() + let data = try JSONEncoder().encode(stats) + + let reply = message.reply() + reply.set(key: .diskUsageStats, value: data) + return reply + } catch { + log.error("failed to get disk usage", metadata: ["error": "\(error)"]) + throw ContainerizationError( + .internalError, + message: "failed to get disk usage", + cause: error + ) + } + } +} diff --git a/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageService.swift b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageService.swift new file mode 100644 index 0000000..39d43b8 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/DiskUsage/DiskUsageService.swift @@ -0,0 +1,81 @@ +//===----------------------------------------------------------------------===// +// 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 Logging + +/// Service for calculating disk usage across all resource types +public actor DiskUsageService { + private let containersService: ContainersService + private let volumesService: VolumesService + private let log: Logger + + public init( + containersService: ContainersService, + volumesService: VolumesService, + log: Logger + ) { + self.containersService = containersService + self.volumesService = volumesService + self.log = log + } + + /// Calculate disk usage for all resource types + public func calculateDiskUsage() async throws -> DiskUsageStats { + log.debug("calculating disk usage for all resources") + + // Get active image references first (needed for image calculation) + let activeImageRefs = await containersService.getActiveImageReferences() + + // Query all services concurrently + async let imageStats = ClientImage.calculateDiskUsage(activeReferences: activeImageRefs) + async let containerStats = containersService.calculateDiskUsage() + async let volumeStats = volumesService.calculateDiskUsage() + + let (imageData, containerData, volumeData) = try await (imageStats, containerStats, volumeStats) + + let stats = DiskUsageStats( + images: ResourceUsage( + total: imageData.totalCount, + active: imageData.activeCount, + sizeInBytes: imageData.totalSize, + reclaimable: imageData.reclaimableSize + ), + containers: ResourceUsage( + total: containerData.0, + active: containerData.1, + sizeInBytes: containerData.2, + reclaimable: containerData.3 + ), + volumes: ResourceUsage( + total: volumeData.0, + active: volumeData.1, + sizeInBytes: volumeData.2, + reclaimable: volumeData.3 + ) + ) + + log.debug( + "disk usage calculation complete", + metadata: [ + "images_total": "\(imageData.totalCount)", + "containers_total": "\(containerData.0)", + "volumes_total": "\(volumeData.0)", + ]) + + return stats + } +} diff --git a/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift new file mode 100644 index 0000000..563482d --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/HealthCheck/HealthCheckHarness.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// 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 CVersion +import ContainerAPIClient +import ContainerVersion +import ContainerXPC +import Containerization +import Foundation +import Logging +import SystemPackage + +public actor HealthCheckHarness { + private let appRoot: URL + private let installRoot: URL + private let logRoot: FilePath? + private let log: Logger + + public init(appRoot: URL, installRoot: URL, logRoot: FilePath?, log: Logger) { + self.appRoot = appRoot + self.installRoot = installRoot + self.logRoot = logRoot + self.log = log + } + + @Sendable + public func ping(_ message: XPCMessage) async -> XPCMessage { + let reply = message.reply() + reply.set(key: .appRoot, value: appRoot.absoluteString) + reply.set(key: .installRoot, value: installRoot.absoluteString) + if let logRoot { + reply.set(key: .logRoot, value: logRoot.string) + } + reply.set(key: .apiServerVersion, value: ReleaseVersion.singleLine(appName: "container-apiserver")) + reply.set(key: .apiServerCommit, value: get_git_commit().map { String(cString: $0) } ?? "unspecified") + // Extra optional fields for richer client display + reply.set(key: .apiServerBuild, value: ReleaseVersion.buildType()) + reply.set(key: .apiServerAppName, value: "container-apiserver") + return reply + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift new file mode 100644 index 0000000..d129057 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelHarness.swift @@ -0,0 +1,97 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization +import ContainerizationError +import Foundation +import Logging + +public struct KernelHarness: Sendable { + private let log: Logging.Logger + private let service: KernelService + + public init(service: KernelService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + public func install(_ message: XPCMessage) async throws -> XPCMessage { + let kernelFilePath = try message.kernelFilePath() + let platform = try message.platform() + let force = try message.kernelForce() + + guard let kernelTarUrl = try message.kernelTarURL() else { + // We have been given a path to a kernel binary on disk + guard let kernelFile = URL(string: kernelFilePath) else { + throw ContainerizationError(.invalidArgument, message: "invalid kernel file path: \(kernelFilePath)") + } + try await self.service.installKernel(kernelFile: kernelFile, platform: platform, force: force) + return message.reply() + } + + let progressUpdateService = ProgressUpdateService(message: message) + try await self.service.installKernelFrom( + tar: kernelTarUrl, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progressUpdateService?.handler, force: force) + return message.reply() + } + + @Sendable + public func getDefaultKernel(_ message: XPCMessage) async throws -> XPCMessage { + guard let platformData = message.dataNoCopy(key: .systemPlatform) else { + throw ContainerizationError(.invalidArgument, message: "missing SystemPlatform") + } + let platform = try JSONDecoder().decode(SystemPlatform.self, from: platformData) + let kernel = try await self.service.getDefaultKernel(platform: platform) + let reply = message.reply() + let data = try JSONEncoder().encode(kernel) + reply.set(key: .kernel, value: data) + return reply + } +} + +extension XPCMessage { + fileprivate func platform() throws -> SystemPlatform { + guard let platformData = self.dataNoCopy(key: .systemPlatform) else { + throw ContainerizationError(.invalidArgument, message: "missing SystemPlatform in XPC Message") + } + let platform = try JSONDecoder().decode(SystemPlatform.self, from: platformData) + return platform + } + + fileprivate func kernelFilePath() throws -> String { + guard let kernelFilePath = self.string(key: .kernelFilePath) else { + throw ContainerizationError(.invalidArgument, message: "missing kernel file path in XPC Message") + } + return kernelFilePath + } + + fileprivate func kernelTarURL() throws -> URL? { + guard let kernelTarURLString = self.string(key: .kernelTarURL) else { + return nil + } + guard let k = URL(string: kernelTarURLString) else { + throw ContainerizationError(.invalidArgument, message: "cannot parse URL from \(kernelTarURLString)") + } + return k + } + + fileprivate func kernelForce() throws -> Bool { + self.bool(key: .kernelForce) + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift new file mode 100644 index 0000000..84c55a5 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Kernel/KernelService.swift @@ -0,0 +1,219 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationArchive +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging +import TerminalProgress + +public actor KernelService { + private static let defaultKernelNamePrefix: String = "default.kernel-" + + private let log: Logger + private let kernelDirectory: URL + + public init(log: Logger, appRoot: URL) throws { + self.log = log + self.kernelDirectory = appRoot.appending(path: "kernels") + try FileManager.default.createDirectory(at: self.kernelDirectory, withIntermediateDirectories: true) + } + + /// Copies a kernel binary from a local path on disk into the managed kernels directory + /// as the default kernel for the provided platform. + public func installKernel(kernelFile url: URL, platform: SystemPlatform = .linuxArm, force: Bool) throws { + log.debug( + "KernelService: enter", + metadata: [ + "func": "\(#function)", + "kernelFile": "\(url)", + "platform": "\(String(describing: platform))", + ] + ) + defer { + log.debug( + "KernelService: exit", + metadata: [ + "func": "\(#function)", + "kernelFile": "\(url)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let kFile = url.resolvingSymlinksInPath() + let destPath = self.kernelDirectory.appendingPathComponent(kFile.lastPathComponent) + if force { + do { + try FileManager.default.removeItem(at: destPath) + } catch let error as NSError { + guard error.code == NSFileNoSuchFileError else { + throw error + } + } + } + try FileManager.default.copyItem(at: kFile, to: destPath) + try Task.checkCancellation() + do { + try self.setDefaultKernel(name: kFile.lastPathComponent, platform: platform) + } catch { + try? FileManager.default.removeItem(at: destPath) + throw error + } + } + + /// Copies a kernel binary from inside of tar file into the managed kernels directory + /// as the default kernel for the provided platform. + /// The parameter `tar` maybe a location to a local file on disk, or a remote URL. + public func installKernelFrom(tar: URL, kernelFilePath: String, platform: SystemPlatform, progressUpdate: ProgressUpdateHandler?, force: Bool) async throws { + log.debug( + "KernelService: enter", + metadata: [ + "func": "\(#function)", + "tar": "\(tar)", + "kernelFilePath": "\(kernelFilePath)", + "platform": "\(String(describing: platform))", + ] + ) + defer { + log.debug( + "KernelService: exit", + metadata: [ + "func": "\(#function)", + "tar": "\(tar)", + "kernelFilePath": "\(kernelFilePath)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let tempDir = FileManager.default.uniqueTemporaryDirectory() + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + await progressUpdate?([ + .setDescription("Downloading kernel") + ]) + let taskManager = ProgressTaskCoordinator() + let downloadTask = await taskManager.startTask() + var tarFile = tar + if !FileManager.default.fileExists(atPath: tar.absoluteString) { + self.log.debug("KernelService: start download", metadata: ["tar": "\(tar)"]) + tarFile = tempDir.appendingPathComponent(tar.lastPathComponent) + var downloadProgressUpdate: ProgressUpdateHandler? + if let progressUpdate { + downloadProgressUpdate = ProgressTaskCoordinator.handler(for: downloadTask, from: progressUpdate) + } + try await ContainerAPIClient.FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) + } + await taskManager.finish() + + await progressUpdate?([ + .setDescription("Unpacking kernel") + ]) + let kernelFile = try self.extractFile(tarFile: tarFile, at: kernelFilePath, to: tempDir) + try self.installKernel(kernelFile: kernelFile, platform: platform, force: force) + + if !FileManager.default.fileExists(atPath: tar.absoluteString) { + try FileManager.default.removeItem(at: tarFile) + } + } + + private func setDefaultKernel(name: String, platform: SystemPlatform) throws { + log.debug( + "KernelService: enter", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + "platform": "\(String(describing: platform))", + ] + ) + defer { + log.debug( + "KernelService: exit", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let kernelPath = self.kernelDirectory.appendingPathComponent(name) + guard FileManager.default.fileExists(atPath: kernelPath.path) else { + throw ContainerizationError(.notFound, message: "kernel not found at \(kernelPath)") + } + let name = "\(Self.defaultKernelNamePrefix)\(platform.architecture)" + let defaultKernelPath = self.kernelDirectory.appendingPathComponent(name) + try? FileManager.default.removeItem(at: defaultKernelPath) + try FileManager.default.createSymbolicLink(at: defaultKernelPath, withDestinationURL: kernelPath) + } + + public func getDefaultKernel(platform: SystemPlatform = .linuxArm) async throws -> Kernel { + log.debug( + "KernelService: enter", + metadata: [ + "func": "\(#function)", + "platform": "\(String(describing: platform))", + ] + ) + defer { + log.debug( + "KernelService: exit", + metadata: [ + "func": "\(#function)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let name = "\(Self.defaultKernelNamePrefix)\(platform.architecture)" + let defaultKernelPath = self.kernelDirectory.appendingPathComponent(name).resolvingSymlinksInPath() + guard FileManager.default.fileExists(atPath: defaultKernelPath.path) else { + throw ContainerizationError(.notFound, message: "default kernel not found at \(defaultKernelPath)") + } + return Kernel(path: defaultKernelPath, platform: platform) + } + + private func extractFile(tarFile: URL, at: String, to directory: URL) throws -> URL { + var target = at + var archiveReader = try ArchiveReader(file: tarFile) + var (entry, data) = try archiveReader.extractFile(path: target) + + // if the target file is a symlink, get the data for the actual file + if entry.fileType == .symbolicLink, let symlinkRelative = entry.symlinkTarget { + // the previous extractFile changes the underlying file pointer, so we need to reopen the file + // to ensure we traverse all the files in the archive + archiveReader = try ArchiveReader(file: tarFile) + let symlinkTarget = URL(filePath: target).deletingLastPathComponent().appending(path: symlinkRelative) + + // standardize so that we remove any and all ../ and ./ in the path since symlink targets + // are relative paths to the target file from the symlink's parent dir itself + target = symlinkTarget.standardized.relativePath + let (_, targetData) = try archiveReader.extractFile(path: target) + data = targetData + } + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true, attributes: nil) + let fileName = URL(filePath: target).lastPathComponent + let fileURL = directory.appendingPathComponent(fileName) + try data.write(to: fileURL, options: .atomic) + return fileURL + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksHarness.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksHarness.swift new file mode 100644 index 0000000..ec525be --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksHarness.swift @@ -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 ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging + +public struct NetworksHarness: Sendable { + let log: Logging.Logger + let service: NetworksService + + public init(service: NetworksService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + public func list(_ message: XPCMessage) async throws -> XPCMessage { + let resources = try await service.list() + + let reply = message.reply() + reply.set(key: .networkResources, value: try JSONEncoder().encode(resources)) + + return reply + } + + @Sendable + public func create(_ message: XPCMessage) async throws -> XPCMessage { + let data = message.dataNoCopy(key: .networkConfig) + guard let data else { + throw ContainerizationError(.invalidArgument, message: "network configuration cannot be empty") + } + + let config = try JSONDecoder().decode(NetworkConfiguration.self, from: data) + let resource = try await service.create(configuration: config) + + let reply = message.reply() + reply.set(key: .networkResource, value: try JSONEncoder().encode(resource)) + + return reply + } + + @Sendable + public func delete(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .networkId) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + try await service.delete(id: id) + + return message.reply() + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift new file mode 100644 index 0000000..7fe35fa --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Networks/NetworksService.swift @@ -0,0 +1,426 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkClient +import ContainerPersistence +import ContainerPlugin +import ContainerResource +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Logging +import SystemPackage + +public actor NetworksService { + struct NetworkEntry { + var configuration: NetworkConfiguration + var status: NetworkStatus + var client: ContainerNetworkClient.NetworkClient + } + + private let pluginLoader: PluginLoader + private let resourceRoot: FilePath + private let containersService: ContainersService + private let log: Logger + private let debugHelpers: Bool + + private let store: FilesystemEntityStore + private let networkPlugins: [Plugin] + private var busyNetworks = Set() + + private let stateLock = AsyncLock() + private var serviceStates = [String: NetworkEntry]() + + public init( + pluginLoader: PluginLoader, + resourceRoot: FilePath, + containersService: ContainersService, + defaultNetworkConfiguration: NetworkConfiguration, + log: Logger, + debugHelpers: Bool = false, + ) async throws { + self.pluginLoader = pluginLoader + self.resourceRoot = resourceRoot + self.containersService = containersService + self.log = log + self.debugHelpers = debugHelpers + + try FileManager.default.createDirectory(atPath: resourceRoot.string, withIntermediateDirectories: true) + self.store = try FilesystemEntityStore( + path: resourceRoot, + type: "network", + log: log + ) + + let networkPlugins = + pluginLoader + .findPlugins() + .filter { $0.hasType(.network) } + guard !networkPlugins.isEmpty else { + throw ContainerizationError(.internalError, message: "cannot find any plugins with type network") + } + self.networkPlugins = networkPlugins + + let configurations = try await store.list() + for configuration in configurations { + var effectiveConfiguration = configuration + + // If there's a default network in the store, always update it with the + // computed default network configuration from the apiserver to ensure we + // have the correct default values configured. + if effectiveConfiguration.id == NetworkClient.defaultNetworkName { + effectiveConfiguration = defaultNetworkConfiguration + try await store.update(effectiveConfiguration) + } + + // Start up the network. + // This call will normally take ~20-100ms to complete after service + // registration, but on a fresh system (e.g. CI runner), it may take + // 5 seconds or considerably more from the registration of this first + // network service to its execution. + do { + try await registerService(configuration: effectiveConfiguration) + let client = try Self.getClient(configuration: effectiveConfiguration) + let networkStatus = try await client.status() + serviceStates[effectiveConfiguration.id] = NetworkEntry( + configuration: effectiveConfiguration, + status: networkStatus, + client: client + ) + } catch { + log.error( + "failed to start network", + metadata: [ + "id": "\(effectiveConfiguration.id)", + "error": "\(error)", + ]) + } + } + } + + /// List all networks registered with the service. + public func list() async throws -> [NetworkResource] { + log.debug("NetworksService: enter", metadata: ["func": "\(#function)"]) + defer { log.debug("NetworksService: exit", metadata: ["func": "\(#function)"]) } + + return serviceStates.values + .map { NetworkResource(configuration: $0.configuration, status: $0.status) } + .sorted { $0.id < $1.id } + } + + /// Create a new network from the provided configuration. + public func create(configuration: NetworkConfiguration) async throws -> NetworkResource { + log.debug( + "NetworksService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(configuration.id)", + ] + ) + defer { + log.debug( + "NetworksService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(configuration.id)", + ] + ) + } + + //Ensure that the network is not named "none" + if configuration.id == NetworkClient.noNetworkName { + throw ContainerizationError(.unsupported, message: "network \(configuration.id) is not a valid name") + } + + // Ensure nobody is manipulating the network already. + guard !busyNetworks.contains(configuration.id) else { + throw ContainerizationError(.exists, message: "network \(configuration.id) has a pending operation") + } + + busyNetworks.insert(configuration.id) + defer { busyNetworks.remove(configuration.id) } + + // Ensure the network doesn't already exist. + return try await self.stateLock.withLock { _ in + guard await self.serviceStates[configuration.id] == nil else { + throw ContainerizationError(.exists, message: "network \(configuration.id) already exists") + } + + // Create and start the network. + try await self.registerService(configuration: configuration) + let client = try Self.getClient(configuration: configuration) + + // Ensure the network is running + let networkStatus = try await client.status() + + let finalConfiguration = try NetworkConfiguration( + name: configuration.name, + mode: configuration.mode, + ipv4Subnet: configuration.ipv4Subnet, + ipv6Subnet: configuration.ipv6Subnet, + labels: configuration.labels, + plugin: configuration.plugin, + options: configuration.options + ) + + let entry = NetworkEntry(configuration: finalConfiguration, status: networkStatus, client: client) + await self.setServiceState(key: finalConfiguration.id, value: entry) + + // Persist the configuration data. + do { + try await self.store.create(finalConfiguration) + return NetworkResource(configuration: finalConfiguration, status: networkStatus) + } catch { + await self.removeServiceState(key: finalConfiguration.id) + do { + try await self.deregisterService(configuration: finalConfiguration) + } catch { + self.log.error( + "failed to deregister network service after failed creation", + metadata: [ + "id": "\(finalConfiguration.id)", + "error": "\(error.localizedDescription)", + ]) + } + throw error + } + } + } + + /// Delete a network. + public func delete(id: String) async throws { + log.debug( + "NetworksService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + log.debug( + "NetworksService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + // check actor busy state + guard !busyNetworks.contains(id) else { + throw ContainerizationError(.exists, message: "network \(id) has a pending operation") + } + + // make actor state busy for this network + busyNetworks.insert(id) + defer { busyNetworks.remove(id) } + + log.info( + "deleting network", + metadata: [ + "id": "\(id)" + ] + ) + + try await stateLock.withLock { _ in + guard let serviceState = await self.serviceStates[id] else { + throw ContainerizationError(.notFound, message: "no network for id \(id)") + } + + // basic sanity checks on network itself + if serviceState.configuration.labels.isBuiltin { + throw ContainerizationError(.invalidArgument, message: "cannot delete builtin network: \(id)") + } + + // prevent container operations while we atomically check and delete + try await self.containersService.withContainerList(logMetadata: ["acquirer": "\(#function)", "id": "\(id)"]) { containers in + // find all containers that refer to the network + var referringContainers = Set() + for container in containers { + for attachmentConfiguration in container.configuration.networks { + if attachmentConfiguration.network == id { + referringContainers.insert(container.configuration.id) + break + } + } + } + + // bail if any referring containers + guard referringContainers.isEmpty else { + throw ContainerizationError( + .invalidState, + message: "cannot delete subnet \(id) with referring containers: \(referringContainers.joined(separator: ", "))" + ) + } + + // start network deletion, this is the last place we'll want to throw + do { + try await self.deregisterService(configuration: serviceState.configuration) + } catch { + self.log.error( + "failed to deregister network service", + metadata: [ + "id": "\(id)", + "error": "\(error.localizedDescription)", + ]) + } + + // deletion is underway, do not throw anything now + do { + try await self.store.delete(id) + } catch { + self.log.error( + "failed to delete network from configuration store", + metadata: [ + "id": "\(id)", + "error": "\(error.localizedDescription)", + ]) + } + } + + // having deleted successfully, remove the runtime state + await self.removeServiceState(key: id) + } + } + + /// Perform a hostname lookup on all networks. + /// + /// - Parameter hostname: A canonical DNS hostname with a trailing dot (e.g. `"example.com."`). + public func lookup(hostname: String) async throws -> Attachment? { + try await self.stateLock.withLock { _ in + for state in await self.serviceStates.values { + guard let allocation = try await state.client.lookup(hostname: hostname) else { + continue + } + return allocation + } + return nil + } + } + + public func plugin(for id: String) throws -> String { + guard let serviceState = serviceStates[id] else { + throw ContainerizationError(.notFound, message: "no network for id \(id)") + } + return serviceState.configuration.plugin + } + + private static func getClient(configuration: NetworkConfiguration) throws -> ContainerNetworkClient.NetworkClient { + NetworkClient(id: configuration.id, plugin: configuration.plugin) + } + + private func registerService(configuration: NetworkConfiguration) async throws { + guard configuration.mode == .nat || configuration.mode == .hostOnly else { + throw ContainerizationError(.invalidArgument, message: "unsupported network mode \(configuration.mode.rawValue)") + } + + guard let networkPlugin = self.networkPlugins.first(where: { $0.name == configuration.plugin }) else { + throw ContainerizationError( + .notFound, + message: "unable to locate network plugin \(configuration.plugin)" + ) + } + + guard let serviceIdentifier = networkPlugin.getMachService(instanceId: configuration.id, type: .network) else { + throw ContainerizationError(.invalidArgument, message: "unsupported network mode \(configuration.mode.rawValue)") + } + var args = [ + "start", + "--id", + configuration.id, + "--service-identifier", + serviceIdentifier, + "--mode", + configuration.mode.rawValue, + ] + if debugHelpers { + args.append("--debug") + } + + if let ipv4Subnet = configuration.ipv4Subnet { + var existingCidrs: [CIDRv4] = [] + for serviceState in serviceStates.values { + existingCidrs.append(serviceState.status.ipv4Subnet) + } + let overlap = existingCidrs.first { + $0.contains(ipv4Subnet.lower) + || $0.contains(ipv4Subnet.upper) + || ipv4Subnet.contains($0.lower) + || ipv4Subnet.contains($0.upper) + } + if let overlap { + throw ContainerizationError(.exists, message: "IPv4 subnet \(ipv4Subnet) overlaps an existing network with subnet \(overlap)") + } + + args += ["--subnet", ipv4Subnet.description] + } + + if let ipv6Subnet = configuration.ipv6Subnet { + var existingCidrs: [CIDRv6] = [] + for serviceState in serviceStates.values { + if let otherIPv6Subnet = serviceState.status.ipv6Subnet { + existingCidrs.append(otherIPv6Subnet) + } + } + let overlap = existingCidrs.first { + $0.contains(ipv6Subnet.lower) + || $0.contains(ipv6Subnet.upper) + || ipv6Subnet.contains($0.lower) + || ipv6Subnet.contains($0.upper) + } + if let overlap { + throw ContainerizationError(.exists, message: "IPv6 subnet \(ipv6Subnet) overlaps an existing network with subnet \(overlap)") + } + + args += ["--subnet-v6", ipv6Subnet.description] + } + + if let variant = configuration.options["variant"] { + args += ["--variant", variant] + } + + let entityPath = try store.entityPath(configuration.id) + try pluginLoader.registerWithLaunchd( + plugin: networkPlugin, + pluginStateRoot: URL(filePath: entityPath.string), + args: args, + instanceId: configuration.id + ) + } + + private func deregisterService(configuration: NetworkConfiguration) async throws { + guard let networkPlugin = self.networkPlugins.first(where: { $0.name == configuration.plugin }) else { + throw ContainerizationError( + .notFound, + message: "unable to locate network plugin \(configuration.plugin)" + ) + } + try self.pluginLoader.deregisterWithLaunchd(plugin: networkPlugin, instanceId: configuration.id) + } +} + +extension NetworksService { + private func removeServiceState(key: String) { + self.serviceStates.removeValue(forKey: key) + } + + private func setServiceState(key: String, value: NetworkEntry) { + self.serviceStates[key] = value + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift new file mode 100644 index 0000000..a811d56 --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsHarness.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import Foundation +import Logging + +public struct PluginsHarness: Sendable { + private let log: Logging.Logger + private let service: PluginsService + + public init(service: PluginsService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + public func load(_ message: XPCMessage) async throws -> XPCMessage { + let name = message.string(key: .pluginName) + guard let name else { + throw ContainerizationError(.invalidArgument, message: "no plugin name found") + } + + try await service.load(name: name) + let reply = message.reply() + return reply + } + + @Sendable + public func get(_ message: XPCMessage) async throws -> XPCMessage { + let name = message.string(key: .pluginName) + guard let name else { + throw ContainerizationError(.invalidArgument, message: "no plugin name found") + } + + let plugin = try await service.get(name: name) + let data = try JSONEncoder().encode(plugin) + + let reply = message.reply() + reply.set(key: .plugin, value: data) + return reply + } + + @Sendable + public func restart(_ message: XPCMessage) async throws -> XPCMessage { + let name = message.string(key: .pluginName) + guard let name else { + throw ContainerizationError(.invalidArgument, message: "no plugin name found") + } + + try await service.restart(name: name) + let reply = message.reply() + return reply + } + + @Sendable + public func unload(_ message: XPCMessage) async throws -> XPCMessage { + let name = message.string(key: .pluginName) + guard let name else { + throw ContainerizationError(.invalidArgument, message: "no plugin name found") + } + + try await service.unload(name: name) + let reply = message.reply() + return reply + } + + @Sendable + public func list(_ message: XPCMessage) async throws -> XPCMessage { + let plugins = try await service.list() + + let data = try JSONEncoder().encode(plugins) + + let reply = message.reply() + reply.set(key: .plugins, value: data) + return reply + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Plugin/PluginsService.swift b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsService.swift new file mode 100644 index 0000000..c3b676d --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Plugin/PluginsService.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import Foundation +import Logging + +public actor PluginsService { + private let log: Logger + private var loaded: [String: Plugin] + private let pluginLoader: PluginLoader + + public init(pluginLoader: PluginLoader, log: Logger) { + self.log = log + self.loaded = [:] + self.pluginLoader = pluginLoader + } + + /// Load the specified plugins, or all plugins with services defined + /// if none are explicitly specified. + public func loadAll( + _ plugins: [Plugin]? = nil, + debug: Bool = false + ) throws { + let registerPlugins = plugins ?? pluginLoader.findPlugins() + for plugin in registerPlugins { + try pluginLoader.registerWithLaunchd(plugin: plugin, debug: debug) + loaded[plugin.name] = plugin + } + } + + /// Stop the specified plugins, or all plugins with services defined + /// if none are explicitly specified. + public func stopAll(_ plugins: [Plugin]? = nil) throws { + let deregisterPlugins = plugins ?? pluginLoader.findPlugins() + for plugin in deregisterPlugins { + try pluginLoader.deregisterWithLaunchd(plugin: plugin) + self.loaded.removeValue(forKey: plugin.name) + } + } + + // MARK: XPC API surface. + + /// Load a single plugin, doing nothing if the plugin is already loaded. + public func load(name: String, debug: Bool = false) throws { + guard self.loaded[name] == nil else { + return + } + guard let plugin = pluginLoader.findPlugin(name: name) else { + throw Error.pluginNotFound(name) + } + try pluginLoader.registerWithLaunchd(plugin: plugin, debug: debug) + self.loaded[plugin.name] = plugin + } + + /// Get information for a loaded plugin. + public func get(name: String) throws -> Plugin { + guard let plugin = loaded[name] else { + throw Error.pluginNotLoaded(name) + } + return plugin + } + + /// Restart a loaded plugin. + public func restart(name: String) throws { + guard let plugin = self.loaded[name] else { + throw Error.pluginNotLoaded(name) + } + try ServiceManager.kickstart(fullServiceLabel: plugin.getLaunchdLabel()) + } + + /// Unload a loaded plugin. + public func unload(name: String) throws { + guard let plugin = self.loaded[name] else { + throw Error.pluginNotLoaded(name) + } + try pluginLoader.deregisterWithLaunchd(plugin: plugin) + self.loaded.removeValue(forKey: plugin.name) + } + + /// List all loaded plugins. + public func list() throws -> [Plugin] { + self.loaded.map { $0.value } + } + + public enum Error: Swift.Error, CustomStringConvertible { + case pluginNotFound(String) + case pluginNotLoaded(String) + + public var description: String { + switch self { + case .pluginNotFound(let name): + return "plugin not found: \(name)" + case .pluginNotLoaded(let name): + return "plugin not loaded: \(name)" + } + } + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Volumes/VolumesHarness.swift b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesHarness.swift new file mode 100644 index 0000000..a0b2c0e --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesHarness.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import Foundation +import Logging + +public struct VolumesHarness: Sendable { + let log: Logging.Logger + let service: VolumesService + + public init(service: VolumesService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + public func list(_ message: XPCMessage) async throws -> XPCMessage { + let volumes = try await service.list() + let data = try JSONEncoder().encode(volumes) + + let reply = message.reply() + reply.set(key: .volumes, value: data) + return reply + } + + @Sendable + public func create(_ message: XPCMessage) async throws -> XPCMessage { + guard let name = message.string(key: .volumeName) else { + throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") + } + + let driver = message.string(key: .volumeDriver) ?? "local" + + let driverOpts: [String: String] + if let driverOptsData = message.dataNoCopy(key: .volumeDriverOpts) { + driverOpts = try JSONDecoder().decode([String: String].self, from: driverOptsData) + } else { + driverOpts = [:] + } + + let labels: [String: String] + if let labelsData = message.dataNoCopy(key: .volumeLabels) { + labels = try JSONDecoder().decode([String: String].self, from: labelsData) + } else { + labels = [:] + } + + let volume = try await service.create(name: name, driver: driver, driverOpts: driverOpts, labels: labels) + let responseData = try JSONEncoder().encode(volume) + + let reply = message.reply() + reply.set(key: .volume, value: responseData) + return reply + } + + @Sendable + public func delete(_ message: XPCMessage) async throws -> XPCMessage { + guard let name = message.string(key: .volumeName) else { + throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") + } + + try await service.delete(name: name) + return message.reply() + } + + @Sendable + public func inspect(_ message: XPCMessage) async throws -> XPCMessage { + guard let name = message.string(key: .volumeName) else { + throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") + } + + let volume = try await service.inspect(name) + let data = try JSONEncoder().encode(volume) + + let reply = message.reply() + reply.set(key: .volume, value: data) + return reply + } + + @Sendable + public func diskUsage(_ message: XPCMessage) async throws -> XPCMessage { + guard let name = message.string(key: .volumeName) else { + throw ContainerizationError(.invalidArgument, message: "volume name cannot be empty") + } + let size = try await service.volumeDiskUsage(name: name) + + let reply = message.reply() + reply.set(key: .volumeSize, value: size) + return reply + } +} diff --git a/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift new file mode 100644 index 0000000..3edd96e --- /dev/null +++ b/Sources/Services/ContainerAPIService/Server/Volumes/VolumesService.swift @@ -0,0 +1,406 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import Containerization +import ContainerizationEXT4 +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Logging +import Synchronization +import SystemPackage + +public actor VolumesService { + private let resourceRoot: FilePath + private let store: ContainerPersistence.FilesystemEntityStore + private let log: Logger + private let lock = AsyncLock() + private let containersService: ContainersService + + // Storage constants + private static let entityFile = "entity.json" + private static let blockFile = "volume.img" + + public init(resourceRoot: FilePath, containersService: ContainersService, log: Logger) async throws { + try FileManager.default.createDirectory(atPath: resourceRoot.string, withIntermediateDirectories: true) + self.resourceRoot = resourceRoot + self.store = try FilesystemEntityStore(path: resourceRoot, type: "volumes", log: log) + self.containersService = containersService + self.log = log + + // Migrate configs stored with the old `createdAt` key to `creationDate`. + // Deprecated: As of 1.0.0. Use ``creationDate`` instead of ``createdAt``. + // Note: Will be removed in a later release. + let configurations = try await store.list() + for configuration in configurations { + do { + try await store.update(configuration) + } catch { + log.error( + "failed to migrate volume configuration", + metadata: [ + "name": "\(configuration.name)", + "error": "\(error)", + ]) + } + } + } + + public func create( + name: String, + driver: String = "local", + driverOpts: [String: String] = [:], + labels: [String: String] = [:] + ) async throws -> VolumeConfiguration { + log.debug( + "VolumesService: enter", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + defer { + log.debug( + "VolumesService: exit", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + } + + return try await lock.withLock { _ in + try await self._create(name: name, driver: driver, driverOpts: driverOpts, labels: labels) + } + } + + public func delete(name: String) async throws { + log.debug( + "VolumesService: enter", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + defer { + log.debug( + "VolumesService: exit", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + } + + try await lock.withLock { _ in + try await self._delete(name: name) + } + } + + public func list() async throws -> [VolumeConfiguration] { + log.debug( + "VolumesService: enter", + metadata: [ + "func": "\(#function)" + ] + ) + defer { + log.debug( + "VolumesService: exit", + metadata: [ + "func": "\(#function)" + ] + ) + } + + return try await store.list() + } + + public func inspect(_ name: String) async throws -> VolumeConfiguration { + log.debug( + "VolumesService: enter", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + defer { + log.debug( + "VolumesService: exit", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + } + + return try await lock.withLock { _ in + try await self._inspect(name) + } + } + + /// Calculate disk usage for a single volume + public func volumeDiskUsage(name: String) async throws -> UInt64 { + log.debug( + "VolumesService: enter", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + defer { + log.debug( + "VolumesService: exit", + metadata: [ + "func": "\(#function)", + "name": "\(name)", + ] + ) + } + + let volumePath = self.volumePath(for: name) + return FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath)) + } + + /// Calculate disk usage for volumes + /// - Returns: Tuple of (total count, active count, total size, reclaimable size) + public func calculateDiskUsage() async throws -> (Int, Int, UInt64, UInt64) { + log.debug( + "VolumesService: enter", + metadata: [ + "func": "\(#function)" + ] + ) + defer { + log.debug( + "VolumesService: exit", + metadata: [ + "func": "\(#function)" + ] + ) + } + + return try await lock.withLock { _ in + let allVolumes = try await self.store.list() + + // Atomically get active volumes with container list + return try await self.containersService.withContainerList(logMetadata: ["acquirer": "\(#function)"]) { containers in + var inUseSet = Set() + + // Find all mounted volumes + for container in containers { + for mount in container.configuration.mounts { + if mount.isVolume, let volumeName = mount.volumeName { + inUseSet.insert(volumeName) + } + } + } + + var totalSize: UInt64 = 0 + var reclaimableSize: UInt64 = 0 + + // Calculate sizes + for volume in allVolumes { + let volumePath = self.volumePath(for: volume.name) + let volumeSize = FileManager.default.allocatedSize(of: URL(fileURLWithPath: volumePath)) + totalSize += volumeSize + + if !inUseSet.contains(volume.name) { + reclaimableSize += volumeSize + } + } + + return (allVolumes.count, inUseSet.count, totalSize, reclaimableSize) + } + } + } + + private func parseSize(_ sizeString: String) throws -> UInt64 { + let measurement = try Measurement.parse(parsing: sizeString) + let bytes = measurement.converted(to: .bytes).value + + // Validate minimum size + let minSize: UInt64 = 1.mib() // 1mib minimum + + let sizeInBytes = UInt64(bytes) + + guard sizeInBytes >= minSize else { + throw VolumeError.storageError("volume size too small: minimum 1MiB") + } + + return sizeInBytes + } + + // FIXME: These don't guarantee that name doesn't have component separators. + private nonisolated func volumePath(for name: String) -> String { + resourceRoot.appending(name).string + } + + private nonisolated func entityPath(for name: String) -> String { + "\(volumePath(for: name))/\(Self.entityFile)" + } + + private nonisolated func blockPath(for name: String) -> String { + "\(volumePath(for: name))/\(Self.blockFile)" + } + + private func createVolumeDirectory(for name: String) throws { + let volumePath = volumePath(for: name) + let fm = FileManager.default + try fm.createDirectory(atPath: volumePath, withIntermediateDirectories: true, attributes: nil) + } + + static func parseJournalConfig(_ value: String) throws -> EXT4.JournalConfig { + let parts = value.split(separator: ":", maxSplits: 1) + guard let modeSubstring = parts.first else { + throw VolumeError.storageError("invalid journal configuration: expected 'mode' or 'mode:size'") + } + let modeString = String(modeSubstring) + let mode: EXT4.JournalConfig.JournalMode + switch modeString { + case "writeback": mode = .writeback + case "ordered": mode = .ordered + case "journal": mode = .journal + default: + throw VolumeError.storageError("invalid journal mode '\(modeString)': must be writeback, ordered, or journal") + } + let size: UInt64? = + try parts.count > 1 + ? UInt64(Measurement.parse(parsing: String(parts[1])).converted(to: .bytes).value) + : nil + return EXT4.JournalConfig(size: size, defaultMode: mode) + } + + private func createVolumeImage(for name: String, sizeInBytes: UInt64 = VolumeStorage.defaultVolumeSizeBytes, journal: EXT4.JournalConfig? = nil) throws { + let blockPath = blockPath(for: name) + + // Use the containerization library's EXT4 formatter + let formatter = try EXT4.Formatter( + FilePath(blockPath), + blockSize: 4096, + minDiskSize: sizeInBytes, + journal: journal + ) + + try formatter.close() + } + + private nonisolated func removeVolumeDirectory(for name: String) throws { + let volumePath = volumePath(for: name) + let fm = FileManager.default + + if fm.fileExists(atPath: volumePath) { + try fm.removeItem(atPath: volumePath) + } + } + + private func _create( + name: String, + driver: String, + driverOpts: [String: String], + labels: [String: String] + ) async throws -> VolumeConfiguration { + guard VolumeStorage.isValidVolumeName(name) else { + throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") + } + + // Check if volume already exists by trying to list and finding it + let existingVolumes = try await store.list() + if existingVolumes.contains(where: { $0.name == name }) { + throw VolumeError.volumeAlreadyExists(name) + } + + try createVolumeDirectory(for: name) + + // Parse size from driver options (default 512GB) + let sizeInBytes: UInt64 + if let sizeString = driverOpts["size"] { + sizeInBytes = try parseSize(sizeString) + } else { + sizeInBytes = VolumeStorage.defaultVolumeSizeBytes + } + + let journalConfig = try driverOpts["journal"].map { try Self.parseJournalConfig($0) } + + try createVolumeImage(for: name, sizeInBytes: sizeInBytes, journal: journalConfig) + + let volume = VolumeConfiguration( + name: name, + driver: driver, + format: "ext4", + source: blockPath(for: name), + labels: labels, + options: driverOpts, + sizeInBytes: sizeInBytes + ) + + try await store.create(volume) + + log.info( + "created volume", + metadata: [ + "name": "\(name)", + "driver": "\(driver)", + "isAnonymous": "\(volume.isAnonymous)", + ]) + return volume + } + + private func _delete(name: String) async throws { + guard VolumeStorage.isValidVolumeName(name) else { + throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") + } + + // Check if volume exists by trying to list and finding it + let existingVolumes = try await store.list() + guard existingVolumes.contains(where: { $0.name == name }) else { + throw VolumeError.volumeNotFound(name) + } + + // Check if volume is in use by any container atomically + try await containersService.withContainerList(logMetadata: ["acquirer": "\(#function)", "name": "\(name)"]) { containers in + for container in containers { + for mount in container.configuration.mounts { + if mount.isVolume && mount.volumeName == name { + throw VolumeError.volumeInUse(name) + } + } + } + + try await self.store.delete(name) + try self.removeVolumeDirectory(for: name) + } + + log.info("deleted volume", metadata: ["name": "\(name)"]) + } + + private func _inspect(_ name: String) async throws -> VolumeConfiguration { + guard VolumeStorage.isValidVolumeName(name) else { + throw VolumeError.invalidVolumeName("invalid volume name '\(name)': must match \(VolumeStorage.volumeNamePattern)") + } + + let volumes = try await store.list() + guard let volume = volumes.first(where: { $0.name == name }) else { + throw VolumeError.volumeNotFound(name) + } + + return volume + } + +} diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift new file mode 100644 index 0000000..c087f99 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift @@ -0,0 +1,103 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Foundation +import ContainerXPC + +/// Keys for XPC fields. +public enum ImagesServiceXPCKeys: String { + case fd + /// FDs pointing to container logs key. + case logs + /// Path to a file on disk key. + case filePath + + /// Images + case imageReference + case imageNewReference + case imageDescription + case imageDescriptions + case filesystem + case ociPlatform + case insecureFlag + case garbageCollect + case maxConcurrentDownloads + case forceLoad + case rejectedMembers + + /// ContentStore + case digest + case digests + case directory + case contentPath + case imageSize + case ingestSessionId + + /// Disk Usage + case activeImageReferences + case totalCount + case activeCount + case reclaimableSize +} + +extension XPCMessage { + public func set(key: ImagesServiceXPCKeys, value: String) { + self.set(key: key.rawValue, value: value) + } + + public func set(key: ImagesServiceXPCKeys, value: UInt64) { + self.set(key: key.rawValue, value: value) + } + + public func set(key: ImagesServiceXPCKeys, value: Data) { + self.set(key: key.rawValue, value: value) + } + + public func set(key: ImagesServiceXPCKeys, value: Bool) { + self.set(key: key.rawValue, value: value) + } + + public func set(key: ImagesServiceXPCKeys, value: Int64) { + self.set(key: key.rawValue, value: value) + } + + public func string(key: ImagesServiceXPCKeys) -> String? { + self.string(key: key.rawValue) + } + + public func data(key: ImagesServiceXPCKeys) -> Data? { + self.data(key: key.rawValue) + } + + public func dataNoCopy(key: ImagesServiceXPCKeys) -> Data? { + self.dataNoCopy(key: key.rawValue) + } + + public func uint64(key: ImagesServiceXPCKeys) -> UInt64 { + self.uint64(key: key.rawValue) + } + + public func int64(key: ImagesServiceXPCKeys) -> Int64 { + self.int64(key: key.rawValue) + } + + public func bool(key: ImagesServiceXPCKeys) -> Bool { + self.bool(key: key.rawValue) + } +} + +#endif diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift new file mode 100644 index 0000000..a381f8e --- /dev/null +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Foundation +import ContainerXPC + +public enum ImagesServiceXPCRoute: String { + case imageList + case imagePull + case imagePush + case imageTag + case imageBuild + case imageDelete + case imageSave + case imageLoad + case imageCleanupOrphanedBlobs + case imageDiskUsage + + case contentGet + case contentDelete + case contentClean + case contentSize + case contentIngestStart + case contentIngestComplete + case contentIngestCancel + + case imageUnpack + case snapshotDelete + case snapshotGet +} + +extension XPCMessage { + public init(route: ImagesServiceXPCRoute) { + self.init(route: route.rawValue) + } +} + +#endif diff --git a/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift b/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift new file mode 100644 index 0000000..d25d33a --- /dev/null +++ b/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift @@ -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. +//===----------------------------------------------------------------------===// + +#if os(macOS) +import Crypto +import ContainerizationError +import Foundation +import ContainerizationOCI +import ContainerXPC + +public struct RemoteContentStoreClient: ContentStore { + private static let serviceIdentifier = "com.apple.container.core.container-core-images" + private static let encoder = JSONEncoder() + + private static func newClient() -> XPCClient { + XPCClient(service: serviceIdentifier) + } + + public init() {} + + private func _get(digest: String) async throws -> URL? { + let client = Self.newClient() + let request = XPCMessage(route: .contentGet) + request.set(key: .digest, value: digest) + do { + let response = try await client.send(request) + guard let path = response.string(key: .contentPath) else { + return nil + } + return URL(filePath: path) + } catch let error as ContainerizationError { + if error.code == .notFound { + return nil + } + throw error + } + } + + public func get(digest: String) async throws -> Content? { + guard let url = try await self._get(digest: digest) else { + return nil + } + return try LocalContent(path: url) + } + + public func get(digest: String) async throws -> T? { + guard let content: Content = try await self.get(digest: digest) else { + return nil + } + return try content.decode() + } + + public func delete(keeping: [String]) async throws -> ([String], UInt64) { + let client = Self.newClient() + let request = XPCMessage(route: .contentClean) + + let d = try Self.encoder.encode(keeping) + request.set(key: .digests, value: d) + let response = try await client.send(request) + + guard let data = response.dataNoCopy(key: .digests) else { + throw ContainerizationError.init(.internalError, message: "failed to delete digests") + } + + let decoder = JSONDecoder() + let deleted = try decoder.decode([String].self, from: data) + let size = response.uint64(key: .imageSize) + return (deleted, size) + } + + @discardableResult + public func delete(digests: [String]) async throws -> ([String], UInt64) { + let client = Self.newClient() + let request = XPCMessage(route: .contentDelete) + + let d = try Self.encoder.encode(digests) + request.set(key: .digests, value: d) + let response = try await client.send(request) + + guard let data = response.dataNoCopy(key: .digests) else { + throw ContainerizationError.init(.internalError, message: "failed to delete digests") + } + + let decoder = JSONDecoder() + let deleted = try decoder.decode([String].self, from: data) + let size = response.uint64(key: .imageSize) + return (deleted, size) + } + + @discardableResult + public func ingest(_ body: @Sendable @escaping (URL) async throws -> Void) async throws -> [String] { + let (id, tempPath) = try await self.newIngestSession() + try await body(tempPath) + return try await self.completeIngestSession(id) + } + + public func newIngestSession() async throws -> (id: String, ingestDir: URL) { + let client = Self.newClient() + let request = XPCMessage(route: .contentIngestStart) + let response = try await client.send(request) + guard let id = response.string(key: .ingestSessionId) else { + throw ContainerizationError.init(.internalError, message: "failed create new ingest session") + } + guard let dir = response.string(key: .directory) else { + throw ContainerizationError.init(.internalError, message: "failed create new ingest session") + } + return (id, URL(filePath: dir)) + } + + @discardableResult + public func completeIngestSession(_ id: String) async throws -> [String] { + let client = Self.newClient() + let request = XPCMessage(route: .contentIngestComplete) + + request.set(key: .ingestSessionId, value: id) + + let response = try await client.send(request) + guard let data = response.dataNoCopy(key: .digests) else { + throw ContainerizationError.init(.internalError, message: "failed to delete digests") + } + + let decoder = JSONDecoder() + let ingested = try decoder.decode([String].self, from: data) + return ingested + } + + public func cancelIngestSession(_ id: String) async throws { + let client = Self.newClient() + let request = XPCMessage(route: .contentIngestCancel) + request.set(key: .ingestSessionId, value: id) + try await client.send(request) + } + + public func totalAllocatedSize() async throws -> UInt64 { + let client = Self.newClient() + let request = XPCMessage(route: .contentSize) + let response = try await client.send(request) + return response.uint64(key: .imageSize) + } +} + +#endif diff --git a/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift new file mode 100644 index 0000000..ab6b121 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerImagesServiceClient +import ContainerXPC +import Containerization +import ContainerizationError +import Foundation +import Logging + +public struct ContentServiceHarness: Sendable { + private let log: Logging.Logger + private let service: ContentStoreService + + public init(service: ContentStoreService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + public func get(_ message: XPCMessage) async throws -> XPCMessage { + let d = message.string(key: .digest) + guard let d else { + throw ContainerizationError(.invalidArgument, message: "missing digest") + } + guard let path = try await service.get(digest: d) else { + let err = ContainerizationError(.notFound, message: "digest \(d) not found") + let reply = message.reply() + reply.set(error: err) + return reply + } + let reply = message.reply() + reply.set(key: .contentPath, value: path.path(percentEncoded: false)) + return reply + } + + @Sendable + public func delete(_ message: XPCMessage) async throws -> XPCMessage { + let data = message.dataNoCopy(key: .digests) + guard let data else { + throw ContainerizationError(.invalidArgument, message: "missing digest") + } + let digests = try JSONDecoder().decode([String].self, from: data) + let (deleted, size) = try await self.service.delete(digests: digests) + let d = try JSONEncoder().encode(deleted) + let reply = message.reply() + reply.set(key: .digests, value: d) + reply.set(key: .imageSize, value: size) + return reply + } + + @Sendable + public func clean(_ message: XPCMessage) async throws -> XPCMessage { + let data = message.dataNoCopy(key: .digests) + guard let data else { + throw ContainerizationError(.invalidArgument, message: "missing digest") + } + let digests = try JSONDecoder().decode([String].self, from: data) + let (deleted, size) = try await self.service.delete(keeping: digests) + let d = try JSONEncoder().encode(deleted) + let reply = message.reply() + reply.set(key: .digests, value: d) + reply.set(key: .imageSize, value: size) + return reply + } + + @Sendable + public func newIngestSession(_ message: XPCMessage) async throws -> XPCMessage { + let session = try await self.service.newIngestSession() + let id = session.id + let dir = session.ingestDir + let reply = message.reply() + reply.set(key: .directory, value: dir.path(percentEncoded: false)) + reply.set(key: .ingestSessionId, value: id) + return reply + } + + @Sendable + public func cancelIngestSession(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .ingestSessionId) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "missing ingest session id") + } + try await self.service.cancelIngestSession(id) + let reply = message.reply() + return reply + } + + @Sendable + public func completeIngestSession(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: .ingestSessionId) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "missing ingest session id") + } + let ingested = try await self.service.completeIngestSession(id) + let d = try JSONEncoder().encode(ingested) + let reply = message.reply() + reply.set(key: .digests, value: d) + return reply + } + + @Sendable + public func totalSize(_ message: XPCMessage) async throws -> XPCMessage { + let size = try await self.service.totalAllocatedSize() + let reply = message.reply() + reply.set(key: .imageSize, value: size) + return reply + } +} diff --git a/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift b/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift new file mode 100644 index 0000000..90d2d90 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift @@ -0,0 +1,164 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerImagesServiceClient +import Containerization +import ContainerizationOCI +import Foundation +import Logging + +public actor ContentStoreService { + private let log: Logger + private let contentStore: LocalContentStore + private let root: URL + + public init(root: URL, log: Logger) throws { + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + self.root = root.appendingPathComponent("content") + self.contentStore = try LocalContentStore(path: self.root) + self.log = log + } + + public func get(digest: String) async throws -> URL? { + self.log.trace( + "ContentStoreService: enter", + metadata: [ + "func": "\(#function)", + "digest": "\(digest)", + ] + ) + defer { + self.log.trace( + "ContentStoreService: exit", + metadata: [ + "func": "\(#function)", + "digest": "\(digest)", + ] + ) + } + + return try await self.contentStore.get(digest: digest)?.path + } + + @discardableResult + public func delete(digests: [String]) async throws -> ([String], UInt64) { + self.log.trace( + "ContentStoreService: enter", + metadata: [ + "func": "\(#function)", + "digests": "\(digests)", + ] + ) + defer { + self.log.trace( + "ContentStoreService: exit", + metadata: [ + "func": "\(#function)", + "digests": "\(digests)", + ] + ) + } + + return try await self.contentStore.delete(digests: digests) + } + + @discardableResult + public func delete(keeping: [String]) async throws -> ([String], UInt64) { + self.log.debug( + "ContentStoreService: enter", + metadata: [ + "func": "\(#function)", + "keeping": "\(keeping)", + ] + ) + defer { + self.log.debug( + "ContentStoreService: exit", + metadata: [ + "func": "\(#function)", + "keeping": "\(keeping)", + ] + ) + } + + return try await self.contentStore.delete(keeping: keeping) + } + + public func newIngestSession() async throws -> (id: String, ingestDir: URL) { + self.log.debug( + "ContentStoreService: enter", + metadata: [ + "func": "\(#function)" + ] + ) + defer { + self.log.debug( + "ContentStoreService: exit", + metadata: [ + "func": "\(#function)" + ] + ) + } + return try await self.contentStore.newIngestSession() + } + + public func completeIngestSession(_ id: String) async throws -> [String] { + self.log.debug( + "ContentStoreService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + self.log.debug( + "ContentStoreService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + return try await self.contentStore.completeIngestSession(id) + } + + public func cancelIngestSession(_ id: String) async throws { + self.log.debug( + "ContentStoreService: enter", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + defer { + self.log.debug( + "ContentStoreService: exit", + metadata: [ + "func": "\(#function)", + "id": "\(id)", + ] + ) + } + + return try await self.contentStore.cancelIngestSession(id) + } + + /// Total bytes allocated on disk for the content store. + public func totalAllocatedSize() async throws -> UInt64 { + try await self.contentStore.totalAllocatedSize() + } +} diff --git a/Sources/Services/ContainerImagesService/Server/ImagesService.swift b/Sources/Services/ContainerImagesService/Server/ImagesService.swift new file mode 100644 index 0000000..21a3e51 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ImagesService.swift @@ -0,0 +1,475 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerImagesServiceClient +import ContainerResource +import Containerization +import ContainerizationArchive +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import Logging +import TerminalProgress + +public actor ImagesService { + private let log: Logger + private let contentStore: ContentStore + private let imageStore: ImageStore + private let snapshotStore: SnapshotStore + + public init( + contentStore: ContentStore, + imageStore: ImageStore, + snapshotStore: SnapshotStore, + log: Logger + ) throws { + self.contentStore = contentStore + self.imageStore = imageStore + self.snapshotStore = snapshotStore + self.log = log + } + + private func _list() async throws -> [Containerization.Image] { + try await imageStore.list() + } + + private func _get(_ reference: String) async throws -> Containerization.Image { + try await imageStore.get(reference: reference) + } + + private func _get(_ description: ImageDescription) async throws -> Containerization.Image { + let exists = try await self._get(description.reference) + guard exists.descriptor == description.descriptor else { + throw ContainerizationError(.invalidState, message: "descriptor mismatch: expected \(description.descriptor), got \(exists.descriptor)") + } + return exists + } + + public func list() async throws -> [ImageDescription] { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)" + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)" + ] + ) + } + + return try await imageStore.list().map { $0.description.fromCZ } + } + + public func pull(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?, maxConcurrentDownloads: Int = 3) async throws + -> ImageDescription + { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "ref": "\(reference)", + "platform": "\(String(describing: platform))", + "insecure": "\(insecure)", + "maxConcurrentDownloads": "\(maxConcurrentDownloads)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "ref": "\(reference)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let img = try await Self.withAuthentication(ref: reference) { auth in + try await self.imageStore.pull( + reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate), + maxConcurrentDownloads: maxConcurrentDownloads) + } + guard let img else { + throw ContainerizationError(.internalError, message: "failed to pull image \(reference)") + } + return img.description.fromCZ + } + + public func push(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?) async throws { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "ref": "\(reference)", + "platform": "\(String(describing: platform))", + "insecure": "\(insecure)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "ref": "\(reference)", + "platform": "\(String(describing: platform))", + ] + ) + } + + try await Self.withAuthentication(ref: reference) { auth in + try await self.imageStore.push( + reference: reference, platform: platform, insecure: insecure, auth: auth, progress: ContainerizationProgressAdapter.handler(from: progressUpdate)) + } + } + + public func tag(old: String, new: String) async throws -> ImageDescription { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "old": "\(old)", + "new": "\(new)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "old": "\(old)", + "new": "\(new)", + ] + ) + } + + let img = try await self.imageStore.tag(existing: old, new: new) + return img.description.fromCZ + } + + public func delete(reference: String, garbageCollect: Bool) async throws { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "ref": "\(reference)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "ref": "\(reference)", + ] + ) + } + + try await self.imageStore.delete(reference: reference, performCleanup: garbageCollect) + } + + public func save(references: [String], out: URL, platform: Platform?) async throws { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "references": "\(references)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "references": "\(references)", + ] + ) + } + + let tempDir = FileManager.default.uniqueTemporaryDirectory() + defer { + try? FileManager.default.removeItem(at: tempDir) + } + try await self.imageStore.save(references: references, out: tempDir, platform: platform) + let writer = try ArchiveWriter(format: .pax, filter: .none, file: out) + try writer.archiveDirectory(tempDir) + try writer.finishEncoding() + } + + public func load(from tarFile: URL, force: Bool) async throws -> ([ImageDescription], [String]) { + let archivePathname = tarFile.absolutePath() + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "archivePath": "\(archivePathname)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "archivePath": "\(archivePathname)", + ] + ) + } + + let reader = try ArchiveReader(file: tarFile) + let tempDir = FileManager.default.uniqueTemporaryDirectory() + defer { + try? FileManager.default.removeItem(at: tempDir) + } + let rejectedMembers = try reader.extractContents(to: tempDir) + guard rejectedMembers.isEmpty || force else { + throw ContainerizationError(.invalidArgument, message: "cannot load tar image with rejected paths: \(rejectedMembers)") + } + + let loaded = try await self.imageStore.load(from: tempDir) + var images: [ImageDescription] = [] + for image in loaded { + images.append(image.description.fromCZ) + } + return (images, rejectedMembers) + } + + public func cleanUpOrphanedBlobs() async throws -> ([String], UInt64) { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)" + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)" + ] + ) + } + + let images = try await self._list() + let freedSnapshotBytes = try await self.snapshotStore.clean(keepingSnapshotsFor: images) + let (deleted, freedContentBytes) = try await self.imageStore.cleanUpOrphanedBlobs() + return (deleted, freedContentBytes + freedSnapshotBytes) + } + + /// Calculate disk usage for images + /// - Parameter activeReferences: Set of image references currently in use by containers + public func calculateDiskUsage(activeReferences: Set) async throws -> (totalCount: Int, activeCount: Int, totalSize: UInt64, reclaimableSize: UInt64) { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "references": "\(activeReferences)", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "references": "\(activeReferences)", + ] + ) + } + + let images = try await self._list() + var activeCount = 0 + var activeContentSizes: [String: UInt64] = [:] + var activeSnapshotSizes: [String: UInt64] = [:] + var processedDigests = Set() + + for image in images { + guard activeReferences.contains(image.reference) else { continue } + activeCount += 1 + let imageDigest = image.digest.trimmingDigestPrefix + guard processedDigests.insert(imageDigest).inserted else { continue } + + for digest in try await image.referencedDigests() where activeContentSizes[digest] == nil { + guard let content: Content = try await self.contentStore.get(digest: digest) else { continue } + activeContentSizes[digest] = try self.contentDiskSize(content) + } + for (digest, size) in try await self.snapshotStore.getSnapshotSizes(for: image) { + activeSnapshotSizes[digest] = size + } + } + + let snapshotDiskSize = await self.snapshotStore.totalAllocatedSize() + let contentDiskTotal = try await self.contentStore.totalAllocatedSize() + let totalOnDisk = contentDiskTotal + snapshotDiskSize + let activeSize = activeContentSizes.values.reduce(0, +) + activeSnapshotSizes.values.reduce(0, +) + let reclaimable = totalOnDisk > activeSize ? totalOnDisk - activeSize : 0 + + return (images.count, activeCount, totalOnDisk, reclaimable) + } + + private func contentDiskSize(_ content: Content) throws -> UInt64 { + let values = try? content.path.resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + if let allocatedSize = values?.totalFileAllocatedSize { + return UInt64(allocatedSize) + } + return try content.size() + } +} + +// MARK: Image Snapshot Methods + +extension ImagesService { + public func unpack(description: ImageDescription, platform: Platform?, progressUpdate: ProgressUpdateHandler?) async throws { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "description": "\(description)", + "platform": "\(String(describing: platform))", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "description": "\(description)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let img = try await self._get(description) + try await self.snapshotStore.unpack(image: img, platform: platform, progressUpdate: progressUpdate) + } + + public func deleteImageSnapshot(description: ImageDescription, platform: Platform?) async throws { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "description": "\(description)", + "platform": "\(String(describing: platform))", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "description": "\(description)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let img = try await self._get(description) + try await self.snapshotStore.delete(for: img, platform: platform) + } + + public func getImageSnapshot(description: ImageDescription, platform: Platform) async throws -> Filesystem { + self.log.debug( + "ImagesService: enter", + metadata: [ + "func": "\(#function)", + "description": "\(description)", + "platform": "\(String(describing: platform))", + ] + ) + defer { + self.log.debug( + "ImagesService: exit", + metadata: [ + "func": "\(#function)", + "description": "\(description)", + "platform": "\(String(describing: platform))", + ] + ) + } + + let img = try await self._get(description) + return try await self.snapshotStore.get(for: img, platform: platform) + } +} + +// MARK: Static Methods + +extension ImagesService { + private static func withAuthentication( + ref: String, _ body: @Sendable @escaping (_ auth: Authentication?) async throws -> T? + ) async throws -> T? { + var authentication: Authentication? + let ref = try Reference.parse(ref) + guard let host = ref.resolvedDomain else { + throw ContainerizationError(.invalidArgument, message: "no host specified in image reference: \(ref)") + } + authentication = Self.authenticationFromEnv(host: host) + if let authentication { + return try await body(authentication) + } + let keychain = KeychainHelper(securityDomain: Constants.keychainID) + do { + authentication = try keychain.lookup(hostname: host) + } catch let err as KeychainHelper.Error { + guard case .keyNotFound = err else { + throw ContainerizationError(.internalError, message: "error querying keychain for \(host)", cause: err) + } + } + do { + return try await body(authentication) + } catch let err as RegistryClient.Error { + guard case .invalidStatus(_, let status, _) = err else { + throw err + } + guard status == .unauthorized || status == .forbidden else { + throw err + } + guard authentication != nil else { + throw ContainerizationError(.internalError, message: "\(String(describing: err)), no credentials found for host \(host)") + } + throw err + } + } + + private static func authenticationFromEnv(host: String) -> Authentication? { + let env = ProcessInfo.processInfo.environment + guard env["CONTAINER_REGISTRY_HOST"] == host else { + return nil + } + guard let user = env["CONTAINER_REGISTRY_USER"], let password = env["CONTAINER_REGISTRY_TOKEN"] else { + return nil + } + return BasicAuthentication(username: user, password: password) + } +} + +extension ImageDescription { + public var toCZ: Containerization.Image.Description { + .init(reference: self.reference, descriptor: self.descriptor) + } +} + +extension Containerization.Image.Description { + public var fromCZ: ImageDescription { + .init( + reference: self.reference, + descriptor: self.descriptor + ) + } +} diff --git a/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift new file mode 100644 index 0000000..e88b236 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -0,0 +1,285 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerImagesServiceClient +import ContainerResource +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation +import Logging + +public struct ImagesServiceHarness: Sendable { + let log: Logging.Logger + let service: ImagesService + + public init(service: ImagesService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + public func pull(_ message: XPCMessage) async throws -> XPCMessage { + let ref = message.string(key: .imageReference) + guard let ref else { + throw ContainerizationError( + .invalidArgument, + message: "missing image reference" + ) + } + let platformData = message.dataNoCopy(key: .ociPlatform) + var platform: Platform? = nil + if let platformData { + platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) + } + let insecure = message.bool(key: .insecureFlag) + let maxConcurrentDownloads = message.int64(key: .maxConcurrentDownloads) + + let progressUpdateService = ProgressUpdateService(message: message) + let imageDescription = try await service.pull( + reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler, maxConcurrentDownloads: Int(maxConcurrentDownloads)) + + let imageData = try JSONEncoder().encode(imageDescription) + let reply = message.reply() + reply.set(key: .imageDescription, value: imageData) + return reply + } + + @Sendable + public func push(_ message: XPCMessage) async throws -> XPCMessage { + let ref = message.string(key: .imageReference) + guard let ref else { + throw ContainerizationError( + .invalidArgument, + message: "missing image reference" + ) + } + let platformData = message.dataNoCopy(key: .ociPlatform) + var platform: Platform? = nil + if let platformData { + platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) + } + let insecure = message.bool(key: .insecureFlag) + + let progressUpdateService = ProgressUpdateService(message: message) + try await service.push(reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler) + + let reply = message.reply() + return reply + } + + @Sendable + public func tag(_ message: XPCMessage) async throws -> XPCMessage { + let old = message.string(key: .imageReference) + guard let old else { + throw ContainerizationError( + .invalidArgument, + message: "missing image reference" + ) + } + let new = message.string(key: .imageNewReference) + guard let new else { + throw ContainerizationError( + .invalidArgument, + message: "missing new image reference" + ) + } + let newDescription = try await service.tag(old: old, new: new) + let descData = try JSONEncoder().encode(newDescription) + let reply = message.reply() + reply.set(key: .imageDescription, value: descData) + return reply + } + + @Sendable + public func list(_ message: XPCMessage) async throws -> XPCMessage { + let images = try await service.list() + let imageData = try JSONEncoder().encode(images) + let reply = message.reply() + reply.set(key: .imageDescriptions, value: imageData) + return reply + } + + @Sendable + public func delete(_ message: XPCMessage) async throws -> XPCMessage { + let ref = message.string(key: .imageReference) + guard let ref else { + throw ContainerizationError( + .invalidArgument, + message: "missing image reference" + ) + } + let garbageCollect = message.bool(key: .garbageCollect) + try await self.service.delete(reference: ref, garbageCollect: garbageCollect) + let reply = message.reply() + return reply + } + + @Sendable + public func save(_ message: XPCMessage) async throws -> XPCMessage { + let data = message.dataNoCopy(key: .imageDescriptions) + guard let data else { + throw ContainerizationError( + .invalidArgument, + message: "missing image description" + ) + } + let imageDescriptions = try JSONDecoder().decode([ImageDescription].self, from: data) + let references = imageDescriptions.map { $0.reference } + + let platformData = message.dataNoCopy(key: .ociPlatform) + var platform: Platform? = nil + if let platformData { + platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) + } + let out = message.string(key: .filePath) + guard let out else { + throw ContainerizationError( + .invalidArgument, + message: "missing output file path" + ) + } + try await service.save(references: references, out: URL(filePath: out), platform: platform) + let reply = message.reply() + return reply + } + + @Sendable + public func load(_ message: XPCMessage) async throws -> XPCMessage { + let input = message.string(key: .filePath) + let force = message.bool(key: .forceLoad) + guard let input else { + throw ContainerizationError( + .invalidArgument, + message: "missing input file path" + ) + } + let (images, rejectedMembers) = try await service.load( + from: URL(filePath: input), + force: force + ) + let reply = message.reply() + let imagesData = try JSONEncoder().encode(images) + reply.set(key: .imageDescriptions, value: imagesData) + let rejectedData = try JSONEncoder().encode(rejectedMembers) + reply.set(key: .rejectedMembers, value: rejectedData) + return reply + } + + @Sendable + public func cleanUpOrphanedBlobs(_ message: XPCMessage) async throws -> XPCMessage { + let (deleted, size) = try await service.cleanUpOrphanedBlobs() + let reply = message.reply() + let data = try JSONEncoder().encode(deleted) + reply.set(key: .digests, value: data) + reply.set(key: .imageSize, value: size) + return reply + } + + @Sendable + public func calculateDiskUsage(_ message: XPCMessage) async throws -> XPCMessage { + // Decode active image references from the message + let activeRefsData = message.dataNoCopy(key: .activeImageReferences) + let activeRefs: Set + if let activeRefsData { + activeRefs = try JSONDecoder().decode(Set.self, from: activeRefsData) + } else { + activeRefs = Set() + } + + let (total, active, size, reclaimable) = try await service.calculateDiskUsage(activeReferences: activeRefs) + + let reply = message.reply() + reply.set(key: .totalCount, value: Int64(total)) + reply.set(key: .activeCount, value: Int64(active)) + reply.set(key: .imageSize, value: size) + reply.set(key: .reclaimableSize, value: reclaimable) + return reply + } +} + +// MARK: Image Snapshot Methods + +extension ImagesServiceHarness { + @Sendable + public func unpack(_ message: XPCMessage) async throws -> XPCMessage { + let descriptionData = message.dataNoCopy(key: .imageDescription) + guard let descriptionData else { + throw ContainerizationError( + .invalidArgument, + message: "missing Image description" + ) + } + let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData) + var platform: Platform? + if let platformData = message.dataNoCopy(key: .ociPlatform) { + platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) + } + + let progressUpdateService = ProgressUpdateService(message: message) + try await self.service.unpack(description: description, platform: platform, progressUpdate: progressUpdateService?.handler) + + let reply = message.reply() + return reply + } + + @Sendable + public func deleteSnapshot(_ message: XPCMessage) async throws -> XPCMessage { + let descriptionData = message.dataNoCopy(key: .imageDescription) + guard let descriptionData else { + throw ContainerizationError( + .invalidArgument, + message: "missing image description" + ) + } + let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData) + let platformData = message.dataNoCopy(key: .ociPlatform) + var platform: Platform? + if let platformData { + platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) + } + try await self.service.deleteImageSnapshot(description: description, platform: platform) + let reply = message.reply() + return reply + } + + @Sendable + public func getSnapshot(_ message: XPCMessage) async throws -> XPCMessage { + let descriptionData = message.dataNoCopy(key: .imageDescription) + guard let descriptionData else { + throw ContainerizationError( + .invalidArgument, + message: "missing image description" + ) + } + let description = try JSONDecoder().decode(ImageDescription.self, from: descriptionData) + let platformData = message.dataNoCopy(key: .ociPlatform) + guard let platformData else { + throw ContainerizationError( + .invalidArgument, + message: "missing OCI platform" + ) + } + let platform = try JSONDecoder().decode(ContainerizationOCI.Platform.self, from: platformData) + let fs = try await self.service.getImageSnapshot(description: description, platform: platform) + let fsData = try JSONEncoder().encode(fs) + let reply = message.reply() + reply.set(key: .filesystem, value: fsData) + return reply + } +} diff --git a/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift new file mode 100644 index 0000000..643713f --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -0,0 +1,254 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress + +public actor SnapshotStore { + private static let snapshotFileName = "snapshot" + private static let snapshotInfoFileName = "snapshot-info" + private static let ingestDirName = "ingest" + + /// Return the Unpacker to use for a given image. + /// If the given platform for the image cannot be unpacked return `nil`. + public typealias UnpackStrategy = @Sendable (Containerization.Image, Platform) async throws -> Unpacker? + + public static func defaultUnpackStrategy(initImage: String) -> UnpackStrategy { + { image, platform in + guard platform.os == "linux" else { + return nil + } + var minBlockSize = 512.gib() + if image.reference == initImage { + minBlockSize = 512.mib() + } + return EXT4Unpacker(blockSizeInBytes: minBlockSize) + } + } + + let path: URL + let fm = FileManager.default + let ingestDir: URL + let unpackStrategy: UnpackStrategy + let log: Logger? + + public init(path: URL, unpackStrategy: @escaping UnpackStrategy, log: Logger?) throws { + let root = path.appendingPathComponent("snapshots") + self.path = root + self.ingestDir = self.path.appendingPathComponent(Self.ingestDirName) + self.unpackStrategy = unpackStrategy + self.log = log + try self.fm.createDirectory(at: root, withIntermediateDirectories: true) + try self.fm.createDirectory(at: self.ingestDir, withIntermediateDirectories: true) + } + + public func unpack(image: Containerization.Image, platform: Platform? = nil, progressUpdate: ProgressUpdateHandler?) async throws { + var toUnpack: [Descriptor] = [] + if let platform { + let desc = try await image.descriptor(for: platform) + toUnpack = [desc] + } else { + toUnpack = try await image.unpackableDescriptors() + } + + let taskManager = ProgressTaskCoordinator() + var taskUpdateProgress: ProgressUpdateHandler? + + for desc in toUnpack { + try Task.checkCancellation() + let snapshotDir = self.snapshotDir(desc) + guard !self.fm.fileExists(atPath: snapshotDir.absolutePath()) else { + // We have already unpacked this image + platform. Skip + continue + } + guard let platform = desc.platform else { + throw ContainerizationError(.internalError, message: "missing platform for descriptor \(desc.digest)") + } + guard let unpacker = try await self.unpackStrategy(image, platform) else { + self.log?.warning("no unpacker configured, skipping unpack for \(image.reference) for platform \(platform.description)") + continue + } + let currentSubTask = await taskManager.startTask() + if let progressUpdate { + let _taskUpdateProgress = ProgressTaskCoordinator.handler(for: currentSubTask, from: progressUpdate) + await _taskUpdateProgress([ + .setSubDescription("for platform \(platform.description)") + ]) + taskUpdateProgress = _taskUpdateProgress + } + + let tempDir = try self.tempUnpackDir() + + let tempSnapshotPath = tempDir.appendingPathComponent(Self.snapshotFileName, isDirectory: false) + let infoPath = tempDir.appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false) + do { + let progress = ContainerizationProgressAdapter.handler(from: taskUpdateProgress) + let mount = try await unpacker.unpack(image, for: platform, at: tempSnapshotPath, progress: progress) + let fs = Filesystem.block( + format: mount.type, + source: self.snapshotPath(desc).absolutePath(), + destination: mount.destination, + options: mount.options + ) + let snapshotInfo = try JSONEncoder().encode(fs) + self.fm.createFile(atPath: infoPath.absolutePath(), contents: snapshotInfo) + } catch { + try? self.fm.removeItem(at: tempDir) + throw error + } + do { + try fm.moveItem(at: tempDir, to: snapshotDir) + } catch let err as NSError { + guard err.code == NSFileWriteFileExistsError else { + throw err + } + try? self.fm.removeItem(at: tempDir) + } + } + await taskManager.finish() + } + + public func delete(for image: Containerization.Image, platform: Platform? = nil) async throws { + var toDelete: [Descriptor] = [] + if let platform { + let desc = try await image.descriptor(for: platform) + toDelete.append(desc) + } else { + toDelete = try await image.unpackableDescriptors() + } + for desc in toDelete { + let p = self.snapshotDir(desc) + guard self.fm.fileExists(atPath: p.absolutePath()) else { + continue + } + try self.fm.removeItem(at: p) + } + } + + public func get(for image: Containerization.Image, platform: Platform) async throws -> Filesystem { + let desc = try await image.descriptor(for: platform) + let infoPath = snapshotInfoPath(desc) + let fsPath = snapshotPath(desc) + + guard self.fm.fileExists(atPath: infoPath.absolutePath()), + self.fm.fileExists(atPath: fsPath.absolutePath()) + else { + throw ContainerizationError(.notFound, message: "image snapshot for \(image.reference) with platform \(platform.description)") + } + let decoder = JSONDecoder() + let data = try Data(contentsOf: infoPath) + let fs = try decoder.decode(Filesystem.self, from: data) + return fs + } + + public func clean(keepingSnapshotsFor images: [Containerization.Image] = []) async throws -> UInt64 { + var toKeep: [String] = [Self.ingestDirName] + for image in images { + for manifest in try await image.index().manifests { + guard let platform = manifest.platform else { + continue + } + let desc = try await image.descriptor(for: platform) + toKeep.append(desc.digest.trimmingDigestPrefix) + } + } + let all = try self.fm.contentsOfDirectory(at: self.path, includingPropertiesForKeys: [.totalFileAllocatedSizeKey]).map { + $0.lastPathComponent + } + let delete = Set(all).subtracting(Set(toKeep)) + var deletedBytes: UInt64 = 0 + for dir in delete { + let unpackedPath = self.path.appending(path: dir, directoryHint: .isDirectory) + guard self.fm.fileExists(atPath: unpackedPath.absolutePath()) else { + continue + } + deletedBytes += self.fm.allocatedSize(of: unpackedPath) + try self.fm.removeItem(at: unpackedPath) + } + return deletedBytes + } + + private func snapshotDir(_ desc: Descriptor) -> URL { + let p = self.path.appendingPathComponent(desc.digest.trimmingDigestPrefix, isDirectory: true) + return p + } + + private func snapshotPath(_ desc: Descriptor) -> URL { + let p = self.snapshotDir(desc) + .appendingPathComponent(Self.snapshotFileName, isDirectory: false) + return p + } + + private func snapshotInfoPath(_ desc: Descriptor) -> URL { + let p = self.snapshotDir(desc) + .appendingPathComponent(Self.snapshotInfoFileName, isDirectory: false) + return p + } + + private func tempUnpackDir() throws -> URL { + let uniqueDirectoryURL = ingestDir.appendingPathComponent(UUID().uuidString, isDirectory: true) + try self.fm.createDirectory(at: uniqueDirectoryURL, withIntermediateDirectories: true, attributes: nil) + return uniqueDirectoryURL + } + + /// Get the disk size for a specific snapshot descriptor + public func getSnapshotSize(descriptor: Descriptor) -> UInt64 { + let snapshotPath = self.snapshotDir(descriptor) + guard self.fm.fileExists(atPath: snapshotPath.path) else { + return 0 + } + return self.fm.allocatedSize(of: snapshotPath) + } + + /// Returns (trimmed digest, size) pairs for every unpackable snapshot owned by the image. + public func getSnapshotSizes(for image: Containerization.Image) async throws -> [(digest: String, size: UInt64)] { + var results: [(digest: String, size: UInt64)] = [] + for descriptor in try await image.unpackableDescriptors() { + let size = self.getSnapshotSize(descriptor: descriptor) + guard size > 0 else { continue } + results.append((descriptor.digest.trimmingDigestPrefix, size)) + } + return results + } + + /// Total allocated bytes across all snapshot storage (including orphans). + public func totalAllocatedSize() -> UInt64 { + self.fm.allocatedSize(of: self.path) + } +} + +extension Containerization.Image { + fileprivate func unpackableDescriptors() async throws -> [Descriptor] { + let index = try await self.index() + return index.manifests.filter { desc in + guard desc.platform != nil else { + return false + } + if let referenceType = desc.annotations?["vnd.docker.reference.type"], referenceType == "attestation-manifest" { + return false + } + return true + } + } +} diff --git a/Sources/Services/MachineAPIService/Client/Flags.swift b/Sources/Services/MachineAPIService/Client/Flags.swift new file mode 100644 index 0000000..12e4649 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/Flags.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// 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 Flags { + public struct MachineManagement: ParsableArguments { + public init() {} + + @Option(name: .shortAndLong, help: "Set arch if image can target multiple architectures") + public var arch: String = Arch.hostArchitecture().rawValue + + @Option(name: .long, help: "Set OS if image can target multiple operating systems") + public var os = "linux" + + @Option(name: .long, help: "Platform for the image if it's multi-platform. This takes precedence over --os and --arch") + public var platform: String? + } +} diff --git a/Sources/Services/MachineAPIService/Client/MachineBundle.swift b/Sources/Services/MachineAPIService/Client/MachineBundle.swift new file mode 100644 index 0000000..93cf227 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineBundle.swift @@ -0,0 +1,259 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerizationError +import Foundation +import SystemPackage + +public struct MachineBundle: Sendable { + private static let rootfsBlockFile = FilePath.Component("rootfs.ext4") + private static let rootfsFile = FilePath.Component("rootfs.json") + private static let configFile = FilePath.Component("config.json") + private static let userSetupFile = FilePath.Component("create-user.sh") + private static let bootLogFile = FilePath.Component("vminitd.log") + private static let stdioLogFile = FilePath.Component("stdio.log") + + public static let sbinDirectory = FilePath.Component("sbin.machine") + public static let initFile = FilePath.Component("init") + public static let initializedFile = FilePath.Component("machine.initialized") + public static let bootConfigFile = FilePath.Component("boot-config.json") + + /// The path to the bundle + public let path: FilePath + + public init(path: FilePath) { + self.path = path + } + + private var machineRootfsBlock: FilePath { + self.path.appending(Self.rootfsBlockFile) + } + + private var machineRootfsConfig: FilePath { + self.path.appending(Self.rootfsFile) + } + + public var bootLog: FilePath { + self.path.appending(Self.bootLogFile) + } + + public var stdioLog: FilePath { + self.path.appending(Self.stdioLogFile) + } + + public var initialized: Bool { + let hasOne = try? String(contentsOf: URL(filePath: self.path.appending(Self.initializedFile).string), encoding: .utf8).hasPrefix("1") + return hasOne ?? false + } + + public var machineRootfs: Filesystem { + get throws { + let data = try Data(contentsOf: URL(filePath: machineRootfsConfig.string)) + let fs = try JSONDecoder().decode(Filesystem.self, from: data) + return fs + } + } + + private var persistedConfig: PersistedMachineConfig { + get throws { + let configPath = self.path.appending(Self.configFile) + let data = try Data(contentsOf: URL(filePath: configPath.string)) + if let wrapper = try? JSONDecoder().decode(PersistedMachineConfig.self, from: data) { + return wrapper + } + let config = try JSONDecoder().decode(MachineConfiguration.self, from: data) + return PersistedMachineConfig(configuration: config, createdDate: nil) + } + } + + public var configuration: MachineConfiguration { + get throws { + try persistedConfig.configuration + } + } + + public var createdDate: Date? { + get throws { + try persistedConfig.createdDate + } + } + + public var diskSize: UInt64? { + let values = try? URL(filePath: machineRootfsBlock.string).resourceValues(forKeys: [.totalFileAllocatedSizeKey]) + guard let allocated = values?.totalFileAllocatedSize else { return nil } + return UInt64(allocated) + } + + public var bootConfig: MachineConfig { + get throws { + try load(filename: Self.bootConfigFile) + } + } +} + +/// Metadata from an OCI artifact or in-image file that describes how a container machine +/// should be configured (shell, user creation script, etc.). +public struct MachineResources: Sendable, Codable, Equatable { + /// The media type for container machine configuration artifacts. + public static let configMediaType = "application/vnd.apple.container.machine.config.v1+json" + + /// The media type for container machine user setup scripts. + public static let setupScriptMediaType = "application/vnd.apple.container.machine.setup.v1+sh" + + public var schemaVersion: Int + public var shell: String? + public var setupScript: String? + + public init(schemaVersion: Int = 1, shell: String? = nil, setupScript: String? = nil) { + self.schemaVersion = schemaVersion + self.shell = shell + self.setupScript = setupScript + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.schemaVersion = try container.decodeIfPresent(Int.self, forKey: .schemaVersion) ?? 1 + self.shell = try container.decodeIfPresent(String.self, forKey: .shell) + self.setupScript = try container.decodeIfPresent(String.self, forKey: .setupScript) + } +} + +extension MachineBundle { + public static func create( + path: FilePath, + machineConfiguration: MachineConfiguration, + resourceRoot: FilePath, + resources: MachineResources?, + bootConfig: MachineConfig, + ) throws -> MachineBundle { + let fm = FileManager.default + + try fm.createDirectory(atPath: path.string, withIntermediateDirectories: true) + let bundle = MachineBundle(path: path) + + let persisted = PersistedMachineConfig(configuration: machineConfiguration, createdDate: Date()) + try bundle.write(filename: Self.configFile, value: persisted) + try bundle.write(filename: Self.bootConfigFile, value: bootConfig) + + let sbin = path.appending(sbinDirectory) + let initPath = sbin.appending(initFile) + let setupScriptPath = sbin.appending(userSetupFile) + let initializedPath = path.appending(initializedFile) + + try fm.createDirectory(atPath: sbin.string, withIntermediateDirectories: true) + try fm.copyItem(atPath: resourceRoot.appending(initFile).string, toPath: initPath.string) + + if let setupScript = resources?.setupScript { + try setupScript.write(toFile: setupScriptPath.string, atomically: true, encoding: .utf8) + try fm.setAttributes([.posixPermissions: 0o755], ofItemAtPath: setupScriptPath.string) + } else { + try fm.copyItem(atPath: resourceRoot.appending(userSetupFile).string, toPath: setupScriptPath.string) + } + + guard fm.createFile(atPath: initializedPath.string, contents: "".data(using: .utf8)) else { + throw ContainerizationError(.internalError, message: "failed to create \(initializedPath.string)") + } + + return bundle + } + + public static func sync(path: FilePath, resourceRoot: FilePath) throws { + let fm = FileManager.default + + try fm.createDirectory(atPath: path.string, withIntermediateDirectories: true) + + let sbin = path.appending(sbinDirectory) + let initPath = sbin.appending(initFile) + let setupScriptPath = sbin.appending(userSetupFile) + let initializedPath = path.appending(initializedFile) + + try fm.createDirectory(atPath: sbin.string, withIntermediateDirectories: true) + + if !fm.fileExists(atPath: setupScriptPath.string) { + try fm.copyItem(atPath: resourceRoot.appending(userSetupFile).string, toPath: setupScriptPath.string) + } + + if fm.fileExists(atPath: initPath.string) { + try fm.removeItem(atPath: initPath.string) + } + try fm.copyItem(atPath: resourceRoot.appending(initFile).string, toPath: initPath.string) + + if !fm.fileExists(atPath: initializedPath.string) { + guard fm.createFile(atPath: initializedPath.string, contents: "".data(using: .utf8)) else { + throw ContainerizationError(.internalError, message: "failed to create \(initializedPath.string)") + } + } + } +} + +extension MachineBundle { + /// Set the value of the configuration for the Bundle. + public func set(configuration: MachineConfiguration) throws { + let existing = try? self.persistedConfig + let persisted = PersistedMachineConfig(configuration: configuration, createdDate: existing?.createdDate) + try write(filename: Self.configFile, value: persisted) + } + + /// Set the boot-time configuration for the bundle. + public func set(bootConfig: MachineConfig) throws { + try write(filename: Self.bootConfigFile, value: bootConfig) + } + + /// Return the full filepath for a named resource in the Bundle. + public func filePath(for name: FilePath.Component) -> FilePath { + path.appending(name) + } + + public func setMachineRootFs(cloning fs: Filesystem, readonly: Bool = false) throws { + var mutableFs = fs + if readonly && !mutableFs.options.contains("ro") { + mutableFs.options.append("ro") + } + let cloned = try mutableFs.clone(to: self.machineRootfsBlock.string) + let fsData = try JSONEncoder().encode(cloned) + try fsData.write(to: URL(filePath: self.machineRootfsConfig.string), options: .atomic) + } + + /// Delete the bundle and all of the resources contained inside. + public func delete() throws { + try FileManager.default.removeItem(atPath: self.path.string) + } + + public func write(filename: FilePath.Component, value: Encodable) throws { + try Self.write(self.path.appending(filename), value: value) + } + + private static func write(_ path: FilePath, value: Encodable) throws { + let data = try JSONEncoder().encode(value) + try data.write(to: URL(filePath: path.string), options: .atomic) + } + + public func load(filename: FilePath.Component) throws -> T where T: Decodable { + try load(path: self.path.appending(filename)) + } + + private func load(path: FilePath) throws -> T where T: Decodable { + let data = try Data(contentsOf: URL(filePath: path.string)) + return try JSONDecoder().decode(T.self, from: data) + } +} + +struct PersistedMachineConfig: Codable, Sendable { + var configuration: MachineConfiguration + var createdDate: Date? +} diff --git a/Sources/Services/MachineAPIService/Client/MachineClient.swift b/Sources/Services/MachineAPIService/Client/MachineClient.swift new file mode 100644 index 0000000..7f3167f --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineClient.swift @@ -0,0 +1,399 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationOCI +import Foundation +import TerminalProgress + +/// A client for interacting with the container machine API server. +public struct MachineClient: Sendable { + public static let serviceIdentifier = "com.apple.container.core.machine-apiserver" + + public static func machineConfigFromFlags( + id: String, + image: String, + management: Flags.MachineManagement, + registry: Flags.Registry, + imageFetch: Flags.ImageFetch, + containerSystemConfig: ContainerSystemConfig, + progressUpdate: @escaping ProgressUpdateHandler + ) async throws -> (MachineConfiguration, MachineResources?) { + var requestedPlatform = Parser.platform(os: management.os, arch: management.arch) + // Prefer --platform + if let platform = management.platform { + requestedPlatform = try Parser.platform(from: platform) + } + let scheme = try RequestScheme(registry.scheme) + + await progressUpdate([ + .setDescription("Fetching image"), + .setItemsName("blobs"), + ]) + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + let img = try await ClientImage.fetch( + reference: image, + platform: requestedPlatform, + scheme: scheme, + containerSystemConfig: containerSystemConfig, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate), + maxConcurrentDownloads: imageFetch.maxConcurrentDownloads + ) + + // Unpack a fetched image before use + await progressUpdate([ + .setDescription("Unpacking image"), + .setItemsName("entries"), + ]) + let unpackTask = await taskManager.startTask() + try await img.getCreateSnapshot( + platform: requestedPlatform, + progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progressUpdate)) + + let userSetup = UserSetup( + username: NSUserName(), + uid: getuid(), + gid: getgid()) + + let config = try MachineConfiguration( + id: id, + image: img.description, + platform: requestedPlatform, + userSetup: userSetup) + + let resources = try? await Self.fetchMachineArtifact( + reference: img.reference, platform: requestedPlatform, scheme: scheme) + + return (config, resources) + } + + private let xpcClient: XPCClient + + public init() { + self.xpcClient = XPCClient(service: Self.serviceIdentifier) + } + + @discardableResult + private func xpcSend( + message: XPCMessage, + timeout: Duration? = .seconds(10) + ) async throws -> XPCMessage { + try await xpcClient.send(message, responseTimeout: timeout) + } + + /// List container machines + public func list() async throws -> [MachineSnapshot] { + do { + let request = XPCMessage(route: MachineRoutes.listMachine.rawValue) + + let response = try await xpcSend( + message: request, + timeout: .seconds(10) + ) + let data = response.dataNoCopy(key: MachineKeys.machines.rawValue) + guard let data else { + return [] + } + return try JSONDecoder().decode([MachineSnapshot].self, from: data) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to list container machines", + cause: error + ) + } + } + + /// Create a new container machine with the given configuration + public func create( + configuration: MachineConfiguration, + resources: MachineResources?, + bootConfig: MachineConfig, + ) async throws { + do { + let request = XPCMessage(route: MachineRoutes.createMachine.rawValue) + + let config = try JSONEncoder().encode(configuration) + request.set(key: MachineKeys.machineConfig.rawValue, value: config) + + if let resources { + let data = try JSONEncoder().encode(resources) + request.set(key: MachineKeys.machineResources.rawValue, value: data) + } + + let bootData = try JSONEncoder().encode(bootConfig) + request.set(key: MachineKeys.bootConfig.rawValue, value: bootData) + + let _ = try await xpcSend(message: request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create container machine", + cause: error + ) + } + } + + /// Delete the container machine along with any resources. + public func delete(id: String) async throws { + do { + let request = XPCMessage(route: MachineRoutes.deleteMachine.rawValue) + request.set(key: MachineKeys.id.rawValue, value: id) + + let _ = try await xpcSend(message: request, timeout: .seconds(15)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to delete container machine", + cause: error + ) + } + } + + /// Get the default container machine. + public func getDefault() async throws -> String? { + do { + let request = XPCMessage(route: MachineRoutes.getDefault.rawValue) + + let response = try await xpcSend(message: request) + let id = response.string(key: MachineKeys.id.rawValue) + guard let id else { + return nil + } + + return id + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get the default container machine", + cause: error + ) + } + } + + /// Set a default container machine. + public func setDefault(id: String) async throws { + do { + let request = XPCMessage(route: MachineRoutes.setDefault.rawValue) + request.set(key: MachineKeys.id.rawValue, value: id) + + let _ = try await xpcSend(message: request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to set a default container machine", + cause: error + ) + } + } + + /// Boot a container machine. + public func boot(id: String?, dynamicEnv: [String: String] = [:]) async throws -> MachineSnapshot { + do { + let request = XPCMessage(route: MachineRoutes.bootMachine.rawValue) + if let id { + request.set(key: MachineKeys.id.rawValue, value: id) + } + + let dynamicEnvData = try JSONEncoder().encode(dynamicEnv) + request.set(key: MachineKeys.dynamicEnv.rawValue, value: dynamicEnvData) + + let response = try await xpcSend(message: request) + guard let data = response.dataNoCopy(key: MachineKeys.snapshot.rawValue) else { + throw ContainerizationError( + .internalError, + message: "missing snapshot in response" + ) + } + return try JSONDecoder().decode(MachineSnapshot.self, from: data) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to boot container machine", + cause: error + ) + } + } + + /// Stop a running container machine. + public func stop(id: String) async throws { + do { + let request = XPCMessage(route: MachineRoutes.stopMachine.rawValue) + request.set(key: MachineKeys.id.rawValue, value: id) + + let _ = try await xpcSend(message: request, timeout: .seconds(30)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to stop container machine", + cause: error + ) + } + } + + /// Set boot-time config for a container machine. + public func setConfig(id: String, bootConfig: MachineConfig) async throws { + do { + let request = XPCMessage(route: MachineRoutes.setConfig.rawValue) + request.set(key: MachineKeys.id.rawValue, value: id) + let data = try JSONEncoder().encode(bootConfig) + request.set(key: MachineKeys.bootConfig.rawValue, value: data) + let _ = try await xpcSend(message: request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to set container machine config", + cause: error + ) + } + } + + /// Inspect a container machine and return its snapshot. + public func inspect(id: String) async throws -> MachineSnapshot { + do { + let request = XPCMessage(route: MachineRoutes.inspectMachine.rawValue) + request.set(key: MachineKeys.id.rawValue, value: id) + + let response = try await xpcSend(message: request) + guard let data = response.dataNoCopy(key: MachineKeys.snapshot.rawValue) else { + throw ContainerizationError( + .internalError, + message: "missing snapshot in response" + ) + } + return try JSONDecoder().decode(MachineSnapshot.self, from: data) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to inspect container machine", + cause: error + ) + } + } + + /// Get the log file handles for a container machine. + public func logs(id: String) async throws -> [FileHandle] { + do { + let request = XPCMessage(route: MachineRoutes.logsMachine.rawValue) + request.set(key: MachineKeys.id.rawValue, value: id) + + let response = try await xpcSend(message: request) + let fds = response.fileHandles(key: MachineKeys.logs.rawValue) + guard let fds else { + throw ContainerizationError( + .internalError, + message: "no log fds returned" + ) + } + return fds + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get logs for container machine \(id)", + cause: error + ) + } + } +} + +// MARK: Container machine artifact fetching + +extension MachineClient { + /// Fetch machine metadata from an OCI artifact attached to an image via the referrers API. + /// + /// Returns `nil` if no artifact is found or the registry doesn't support referrers. + static func fetchMachineArtifact( + reference: String, + platform: Platform, + scheme: RequestScheme + ) async throws -> MachineResources? { + let ref = try Reference.parse(reference) + guard let domain = ref.resolvedDomain else { + return nil + } + + let insecure = try scheme.schemeFor(host: ref.resolvedDomain ?? "", internalDnsDomain: nil) == .http + + // Look up credentials from keychain + let keychain = KeychainHelper(securityDomain: Constants.keychainID) + let auth = try? keychain.lookup(hostname: domain) + + let client = try RegistryClient(reference: reference, insecure: insecure, auth: auth) + let name = ref.path + + // Resolve the image reference to get the manifest digest. + // We need the platform-specific manifest digest, not the index digest. + let tag = ref.digest ?? ref.tag ?? "latest" + let topDescriptor = try await client.resolve(name: name, tag: tag) + + // If the top-level is an index, find the platform-specific manifest + let manifestDigest: String + switch topDescriptor.mediaType { + case MediaTypes.index, MediaTypes.dockerManifest: + let index: Index = try await client.fetch(name: name, descriptor: topDescriptor) + guard let platformDesc = index.manifests.first(where: { $0.platform == platform }) else { + return nil + } + manifestDigest = platformDesc.digest + case MediaTypes.imageManifest: + manifestDigest = topDescriptor.digest + default: + return nil + } + + // Query referrers API for container machine config artifacts + let referrersIndex = try await client.referrers( + name: name, + digest: manifestDigest, + artifactType: MachineResources.configMediaType + ) + + guard let artifactDesc = referrersIndex.manifests.first else { + return nil + } + + // Fetch the artifact manifest + let artifactManifest: Manifest = try await client.fetch(name: name, descriptor: artifactDesc) + + // Extract metadata JSON and setup script from artifact layers + var resources: MachineResources? + var setupScript: String? + + for layer in artifactManifest.layers { + if layer.mediaType == MachineResources.configMediaType { + let data = try await client.fetchData(name: name, descriptor: layer) + resources = try JSONDecoder().decode(MachineResources.self, from: data) + } else if layer.mediaType == MachineResources.setupScriptMediaType { + let data = try await client.fetchData(name: name, descriptor: layer) + let script = String(decoding: data, as: UTF8.self) + if !script.isEmpty { + setupScript = script + } + } + } + + if var resources, let setupScript { + resources.setupScript = setupScript + } + + return resources + } +} diff --git a/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift new file mode 100644 index 0000000..ea375eb --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineConfiguration.swift @@ -0,0 +1,128 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation + +/// User configuration created during first boot provisioning. +/// Stores the mapping between host user and container machine user. +public struct UserSetup: Sendable, Codable, Equatable { + public var username: String + public var uid: UInt32 + public var gid: UInt32 + + public var home: String { + "/home/\(username)" + } + + public var user: ProcessConfiguration.User { + .id(uid: uid, gid: gid) + } + + public init(username: String, uid: UInt32, gid: UInt32) { + self.username = username + self.uid = uid + self.gid = gid + } +} + +public struct MachineConfiguration: Sendable, Codable { + public static let containerUUIDLength = 6 + + public static let defaultDNSDomain = "machine" + + /// Identifier for the container machine. + public var id: String + /// Image used to create the container machine. + public var image: ImageDescription + /// Platform for the container machine + public var platform: ContainerizationOCI.Platform + /// User setup from first boot. Nil means provisioning has not run yet. + public var userSetup: UserSetup + + public var user: ProcessConfiguration.User { + userSetup.user + } + + public var home: String { + userSetup.home + } + + public var processEnvironment: [String] { + [ + "PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin", + + "CONTAINER_MACHINE_ID=\(id)", + "CONTAINER_USER=\(userSetup.username)", + "CONTAINER_HOME=\(userSetup.home)", + "CONTAINER_UID=\(userSetup.uid)", + "CONTAINER_GID=\(userSetup.gid)", + ] + } + + public var dnsName: String { + "\(id.lowercased()).\(Self.defaultDNSDomain)" + } + + public var dnsHostname: String { + "\(dnsName)." + } + + public init( + id: String, + image: ImageDescription, + platform: ContainerizationOCI.Platform, + userSetup: UserSetup + ) throws { + self.id = id + self.image = image + self.platform = platform + self.userSetup = userSetup + + try self.validate() + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + self.id = try container.decode(String.self, forKey: .id) + self.image = try container.decode(ImageDescription.self, forKey: .image) + self.platform = try container.decode(ContainerizationOCI.Platform.self, forKey: .platform) + // DEPRECATED 0.11.0.0 - `decodeIfPresent` used for down-revision compatibility, remove in 0.13.0.0 + self.userSetup = try container.decodeIfPresent(UserSetup.self, forKey: .userSetup) ?? UserSetup(username: NSUserName(), uid: getuid(), gid: getgid()) + + try self.validate() + } + + private func validate() throws { + let maxNameLength = LinuxContainer.maxIDLength - Self.containerUUIDLength - 1 + guard self.id.count <= maxNameLength else { + throw ContainerizationError(.invalidArgument, message: "machine name cannot be longer than \(maxNameLength)") + } + + let pattern = #"^[a-z0-9]([a-z0-9-]*[a-z0-9])?$"# + let regex = try Regex(pattern) + guard try regex.firstMatch(in: id.lowercased()) != nil else { + throw ContainerizationError( + .invalidArgument, + message: "machine name '\(id)' must start and end with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens" + ) + } + } +} diff --git a/Sources/Services/MachineAPIService/Client/MachineKeys.swift b/Sources/Services/MachineAPIService/Client/MachineKeys.swift new file mode 100644 index 0000000..f699b07 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineKeys.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum MachineKeys: String { + /// Container machine ID. + case id + /// Container machine configuration. + case machineConfig + /// Container machine resources. + case machineResources + /// List of container machine snapshots. + case machines + /// Single container machine snapshot. + case snapshot + /// Boot-time configuration. + case bootConfig + /// File handles to logs + case logs + /// Special-case environment variables recomputed on container machine start + case dynamicEnv +} diff --git a/Sources/Services/MachineAPIService/Client/MachineRoutes.swift b/Sources/Services/MachineAPIService/Client/MachineRoutes.swift new file mode 100644 index 0000000..631997c --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineRoutes.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum MachineRoutes: String { + /// Create a container machine. + case createMachine + /// Delete a container machine. + case deleteMachine + /// List container machines. + case listMachine + /// Get the default container machine. + case getDefault + /// Set the default container machine. + case setDefault + /// Boot a container machine. + case bootMachine + /// Stop a container machine. + case stopMachine + /// Inspect a container machine. + case inspectMachine + /// Set boot-time config for a container machine. + case setConfig + /// Fetch logs of a container machine. + case logsMachine +} diff --git a/Sources/Services/MachineAPIService/Client/MachineSnapshot.swift b/Sources/Services/MachineAPIService/Client/MachineSnapshot.swift new file mode 100644 index 0000000..fd79cc8 --- /dev/null +++ b/Sources/Services/MachineAPIService/Client/MachineSnapshot.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerizationOCI +import Foundation + +public struct MachineSnapshot: Codable, Sendable { + public var configuration: MachineConfiguration + public var status: RuntimeStatus + public var bootConfig: MachineConfig + public var startedDate: Date? + public var createdDate: Date? + public var containerId: String? + public var ipAddress: String? + public var diskSize: UInt64? + + public var initialized: Bool + + public var id: String { configuration.id } + public var platform: ContainerizationOCI.Platform { configuration.platform } + + enum CodingKeys: String, CodingKey { + case configuration + case status + case startedDate + case createdDate + case containerId + case bootConfig + case ipAddress + case diskSize + case initialized + } + + public init( + configuration: MachineConfiguration, + status: RuntimeStatus, + bootConfig: MachineConfig, + startedDate: Date? = nil, + createdDate: Date? = nil, + containerId: String? = nil, + ipAddress: String? = nil, + diskSize: UInt64? = nil, + initialized: Bool = false, + ) { + self.configuration = configuration + self.status = status + self.bootConfig = bootConfig + self.startedDate = startedDate + self.createdDate = createdDate + self.containerId = containerId + self.ipAddress = ipAddress + self.diskSize = diskSize + self.initialized = initialized + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + configuration = try container.decode(MachineConfiguration.self, forKey: .configuration) + status = try container.decode(RuntimeStatus.self, forKey: .status) + bootConfig = try container.decode(MachineConfig.self, forKey: .bootConfig) + startedDate = try container.decodeIfPresent(Date.self, forKey: .startedDate) + createdDate = try container.decodeIfPresent(Date.self, forKey: .createdDate) + containerId = try container.decodeIfPresent(String.self, forKey: .containerId) + ipAddress = try container.decodeIfPresent(String.self, forKey: .ipAddress) + diskSize = try container.decodeIfPresent(UInt64.self, forKey: .diskSize) + initialized = try container.decodeIfPresent(Bool.self, forKey: .initialized) ?? false + } +} diff --git a/Sources/Services/MachineAPIService/Server/MachinesHarness.swift b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift new file mode 100644 index 0000000..9642139 --- /dev/null +++ b/Sources/Services/MachineAPIService/Server/MachinesHarness.swift @@ -0,0 +1,169 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerXPC +import ContainerizationError +import Foundation +import MachineAPIClient + +public struct MachinesHarness: Sendable { + let service: MachinesService + + public init(service: MachinesService) { + self.service = service + } + + @Sendable + public func create(_ message: XPCMessage) async throws -> XPCMessage { + let machineConfig = message.dataNoCopy(key: MachineKeys.machineConfig.rawValue) + guard let machineConfig else { + throw ContainerizationError( + .invalidArgument, + message: "container machine configuration cannot be empty" + ) + } + + let machineResources = message.dataNoCopy(key: MachineKeys.machineResources.rawValue) + var resources: MachineResources? = nil + if let machineResources { + resources = try JSONDecoder().decode(MachineResources.self, from: machineResources) + } + + let bootConfigData = message.dataNoCopy(key: MachineKeys.bootConfig.rawValue) + guard let bootConfigData else { + throw ContainerizationError(.invalidArgument, message: "bootConfig cannot be empty") + } + let bootConfig = try JSONDecoder().decode(MachineConfig.self, from: bootConfigData) + + let config = try JSONDecoder().decode(MachineConfiguration.self, from: machineConfig) + + try await service.create(configuration: config, resources: resources, bootConfig: bootConfig) + return message.reply() + } + + @Sendable + public func delete(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: MachineKeys.id.rawValue) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + try await service.delete(id: id) + return message.reply() + } + + @Sendable + public func list(_ message: XPCMessage) async throws -> XPCMessage { + let machines = try await service.list() + let data = try JSONEncoder().encode(machines) + + let reply = message.reply() + reply.set(key: MachineKeys.machines.rawValue, value: data) + return reply + } + + @Sendable + public func getDefault(_ message: XPCMessage) async throws -> XPCMessage { + let id = try await service.getDefault() + + let reply = message.reply() + if let id { + reply.set(key: MachineKeys.id.rawValue, value: id) + } + return reply + } + + @Sendable + public func setDefault(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: MachineKeys.id.rawValue) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + try await service.setDefault(id: id) + + return message.reply() + } + + @Sendable + public func boot(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: MachineKeys.id.rawValue) + + var dynamicEnv: [String: String] = [:] + if let dynamicEnvData = message.dataNoCopy(key: MachineKeys.dynamicEnv.rawValue) { + dynamicEnv = try JSONDecoder().decode([String: String].self, from: dynamicEnvData) + } + + let snapshot = try await service.boot(id: id, dynamicEnv: dynamicEnv) + let data = try JSONEncoder().encode(snapshot) + + let reply = message.reply() + reply.set(key: MachineKeys.snapshot.rawValue, value: data) + return reply + } + + @Sendable + public func stop(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: MachineKeys.id.rawValue) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + try await service.stop(id: id) + return message.reply() + } + + @Sendable + public func inspect(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: MachineKeys.id.rawValue) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + let snapshot = try await service.inspect(id: id) + let data = try JSONEncoder().encode(snapshot) + + let reply = message.reply() + reply.set(key: MachineKeys.snapshot.rawValue, value: data) + return reply + } + + @Sendable + public func setConfig(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: MachineKeys.id.rawValue) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + let bootConfigData = message.dataNoCopy(key: MachineKeys.bootConfig.rawValue) + guard let bootConfigData else { + throw ContainerizationError(.invalidArgument, message: "boot config cannot be empty") + } + let bootConfig = try JSONDecoder().decode(MachineConfig.self, from: bootConfigData) + try await service.setConfig(id: id, bootConfig: bootConfig) + return message.reply() + } + + @Sendable + public func logs(_ message: XPCMessage) async throws -> XPCMessage { + let id = message.string(key: MachineKeys.id.rawValue) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "id cannot be empty") + } + + let fds = try await service.logs(id: id) + let reply = message.reply() + try reply.set(key: MachineKeys.logs.rawValue, value: fds) + return reply + } +} diff --git a/Sources/Services/MachineAPIService/Server/MachinesService.swift b/Sources/Services/MachineAPIService/Server/MachinesService.swift new file mode 100644 index 0000000..f641d35 --- /dev/null +++ b/Sources/Services/MachineAPIService/Server/MachinesService.swift @@ -0,0 +1,709 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerResource +import ContainerRuntimeClient +import Containerization +import ContainerizationEXT4 +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Darwin +import Foundation +import Logging +import MachineAPIClient +import SystemPackage + +// systemd poweroff signal (SIGRTMIN+4 on Linux, where SIGRTMIN=34 under glibc) +private let SIGRTMIN4: Int32 = 38 + +public actor MachinesService { + private static let machinesDir = FilePath.Component("machines") + private static let stateFile = FilePath.Component("state.json") + + private struct MachineState { + var snapshot: MachineSnapshot + + var id: String { snapshot.configuration.id } + + var logger: Task? + } + + private var serviceState: ServiceState + private let client: ContainerClient + + private let resourceRoot: FilePath + private let machineRoot: FilePath + private let lock = AsyncLock() + private var machines: [String: MachineState] + private let exitMonitor: ExitMonitor + private let log: Logger + + private var `default`: MachineState? { + guard let id = serviceState.defaultMachine else { + return nil + } + // If a default is set but doesn't exist, treat as if no default is set + // This can happen if the default container machine was deleted + return self.machines[id] + } + + public init(appRoot: FilePath, resourceRoot: FilePath, log: Logger) throws { + self.resourceRoot = resourceRoot + + let machineRoot = appRoot.appending(Self.machinesDir) + try FileManager.default.createDirectory(atPath: machineRoot.string, withIntermediateDirectories: true) + self.machineRoot = machineRoot + self.serviceState = try ServiceState.from(appRoot.appending(Self.stateFile)) + + self.log = log + self.machines = try Self.loadAtBoot(root: machineRoot, resourceRoot: resourceRoot, log: log) + self.client = ContainerClient() + self.exitMonitor = ExitMonitor(log: log) + } + + static private func loadAtBoot(root: FilePath, resourceRoot: FilePath, log: Logger) throws -> [String: MachineState] { + let entries = try FileManager.default.contentsOfDirectory(atPath: root.string) + + var results = [String: MachineState]() + for entry in entries { + guard let component = FilePath.Component(entry) else { + continue + } + let dir = root.appending(component) + var isDirectory: ObjCBool = false + guard FileManager.default.fileExists(atPath: dir.string, isDirectory: &isDirectory), isDirectory.boolValue else { + continue + } + do { + try MachineBundle.sync(path: dir, resourceRoot: resourceRoot) + } catch { + log.error("failed to sync resources for machine bundle", metadata: ["path": "\(dir.string)", "error": "\(error)"]) + continue + } + + do { + let bundle = MachineBundle(path: dir) + let config = try bundle.configuration + let bootConfig = try bundle.bootConfig + + let state = MachineState( + snapshot: .init( + configuration: config, + status: .stopped, + bootConfig: bootConfig, + createdDate: try? bundle.createdDate, + containerId: nil, + initialized: bundle.initialized + ) + ) + + results[config.id] = state + } catch { + log.warning("failed to load machine bundle", metadata: ["path": "\(dir.string)", "error": "\(error)"]) + } + } + + return results + } + + static private func pipeFile(from: FileHandle, to: FileHandle) async throws { + try to.seekToEnd() + + let stream = AsyncStream { cont in + from.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + from.readabilityHandler = nil + cont.finish() + return + } + + cont.yield(data) + } + } + + for await data in stream { + try to.write(contentsOf: data) + } + } + + public func list() async throws -> [MachineSnapshot] { + self.log.debug("\(#function)") + var snapshots: [MachineSnapshot] = [] + for state in self.machines.values { + var snapshot = state.snapshot + let path = try self.bundlePath(id: snapshot.id) + let bundle = MachineBundle(path: path) + snapshot.diskSize = bundle.diskSize + snapshots.append(snapshot) + } + let runningIds = snapshots.compactMap { $0.status == .running ? $0.containerId : nil } + if !runningIds.isEmpty { + var containers: [ContainerSnapshot]? + do { + containers = try await self.client.list(filters: ContainerListFilters(ids: runningIds)) + } catch { + self.log.warning("failed to fetch container addresses: \(error)") + } + let addressMap = (containers ?? []).reduce(into: [String: String]()) { result, c in + if let addr = c.networks.first?.ipv4Address.address.description { + result[c.id] = addr + } + } + for i in snapshots.indices where snapshots[i].status == .running { + if let cid = snapshots[i].containerId { + snapshots[i].ipAddress = addressMap[cid] + } + } + } + return snapshots + } + + public func create(configuration: MachineConfiguration, resources: MachineResources?, bootConfig: MachineConfig) async throws { + self.log.debug("\(#function)") + + try await self.lock.withLock { context in + guard await self.machines[configuration.id] == nil else { + throw ContainerizationError( + .exists, + message: "container machine already exists: \(configuration.id)" + ) + } + + let path = try self.bundlePath(id: configuration.id) + let bundle = try MachineBundle.create( + path: path, + machineConfiguration: configuration, + resourceRoot: self.resourceRoot, + resources: resources, + bootConfig: bootConfig, + ) + + do { + let machineImage = ClientImage(description: configuration.image) + let imageFs = try await machineImage.getCreateSnapshot(platform: configuration.platform) + try bundle.setMachineRootFs(cloning: imageFs) + + let state = MachineState( + snapshot: .init( + configuration: configuration, + status: .stopped, + bootConfig: bootConfig, + createdDate: Date(), + containerId: nil, + ) + ) + await self.setMachineState(configuration.id, state, context: context) + + if await self.default == nil { + try await self._setDefault(id: configuration.id) + } + } catch { + do { + try bundle.delete() + } catch { + self.log.error("failed to delete bundle for container machine \(configuration.id)") + } + + throw error + } + } + } + + public func delete(id: String) async throws { + self.log.debug("\(#function)") + + try await self.lock.withLock { context in + let state = try await self._getMachineState(id: id) + + switch state.snapshot.status { + case .running: + throw ContainerizationError( + .invalidState, + message: "container machine \(id) is \(state.snapshot.status)") + default: + break + } + + if let defaultMachine = await self.default, defaultMachine.id == id { + try await self._setDefault(id: nil) + } + + try await self._cleanUp(id: id) + } + } + + public func getDefault() async throws -> String? { + self.log.debug("\(#function)") + + return self.default?.id + } + + public func setDefault(id: String) async throws { + self.log.debug("\(#function)") + + try await self.lock.withLock { context in + let state = try await self._getMachineState(id: id) + try await self._setDefault(id: state.id) + } + } + + public func setConfig(id: String, bootConfig: MachineConfig) async throws { + self.log.debug("\(#function)") + try await self.lock.withLock { context in + var state = try await self._getMachineState(id: id) + let path = try self.bundlePath(id: id) + let bundle = MachineBundle(path: path) + try bundle.set(bootConfig: bootConfig) + + state.snapshot.bootConfig = bootConfig + await self.setMachineState(id, state, context: context) + } + } + + private func _getMachineState(id: String) throws -> MachineState { + let state = self.machines[id] + guard let state else { + throw ContainerizationError( + .notFound, + message: "container machine with ID \(id) not found") + } + return state + } + + private func setMachineState(_ id: String, _ state: MachineState, context: AsyncLock.Context) async { + self.machines[id] = state + } + + private nonisolated func bundlePath(id: String) throws -> FilePath { + guard let component = FilePath.Component(id) else { + throw ContainerizationError( + .invalidArgument, + message: "container machine ID \(id) is not a valid path component" + ) + } + return self.machineRoot.appending(component) + } + + private func _setDefault(id: String?) throws { + try serviceState.setDefault(id: id) + } + + private func _cleanUp(id: String) throws { + self.log.debug("\(#function)") + + if self.machines[id] == nil { + return + } + + let path = try self.bundlePath(id: id) + let bundle = MachineBundle(path: path) + try bundle.delete() + self.machines.removeValue(forKey: id) + } + + private func cleanUp(id: String, context: AsyncLock.Context) async throws { + try self._cleanUp(id: id) + } + + private nonisolated func systemPlatform(from ociPlatform: ContainerizationOCI.Platform) -> SystemPlatform { + ociPlatform.architecture == "amd64" ? .linuxAmd : .linuxArm + } + + public func boot(id: String?, dynamicEnv: [String: String] = [:]) async throws -> MachineSnapshot { + self.log.debug("\(#function)") + + guard let id = id ?? self.default?.id else { + throw ContainerizationError( + .invalidArgument, + message: "no container machine specified and no default set" + ) + } + + return try await self.lock.withLock { context in + var state = try await self._getMachineState(id: id) + + switch state.snapshot.status { + case .running: + return state.snapshot + case .stopped: + break + default: + throw ContainerizationError(.invalidState, message: "container machine \(id) is \(state.snapshot.status)") + } + + let cid = "\(id)-\(UUID().uuidString.prefix(MachineConfiguration.containerUUIDLength).lowercased())" + guard try await self.client.list(filters: .init(ids: [cid])).isEmpty else { + throw ContainerizationError(.internalError, message: "container \(cid) already exists") + } + + let path = try self.bundlePath(id: id) + let bundle = MachineBundle(path: path) + let rootfs = try bundle.machineRootfs + + let bootConfig = state.snapshot.bootConfig + var config = try await state.snapshot.configuration.toContainerConfig( + cid: cid, + sbin: path.appending(MachineBundle.sbinDirectory), + initializedFile: path.appending(MachineBundle.initializedFile), + homeMountOption: bootConfig.homeMount, + virtualization: bootConfig.virtualization, + ) + + config.resources.cpus = bootConfig.cpus + config.resources.cpuOverhead = 0 + config.resources.memoryInBytes = bootConfig.memory.toUInt64(unit: .bytes) + + let kernel: Kernel + if let kernelPath = bootConfig.kernelPath { + let validated = try MachineConfig.validateKernelPath(kernelPath.string) + kernel = Kernel( + path: URL(fileURLWithPath: validated.string), + platform: self.systemPlatform(from: state.snapshot.configuration.platform) + ) + } else { + kernel = try await ClientKernel.getDefaultKernel(for: .current) + } + + var fhs: [FileHandle] = [] + do { + try await self.client.create( + configuration: config, + options: ContainerCreateOptions(autoRemove: true, rootFsOverride: rootfs), + kernel: kernel + ) + + let process = try await self.client.bootstrap( + id: cid, stdio: [nil, nil, nil], dynamicEnv: dynamicEnv) + try await process.start() + + try fhs.append(contentsOf: await self.client.logs(id: cid)) + + try bundle.createLogFiles() + let stdioLog = try FileHandle(forWritingTo: URL(filePath: bundle.stdioLog.string)) + let bootLog = try FileHandle(forWritingTo: URL(filePath: bundle.bootLog.string)) + + state.logger = Task { [log = self.log, id = state.id, fhs] in + defer { + try? fhs[0].close() + try? fhs[1].close() + + try? stdioLog.close() + try? bootLog.close() + } + + await withTaskGroup(of: Result.self) { group in + for (from, to) in zip(fhs, [stdioLog, bootLog]) { + group.addTask { + do { + try await Self.pipeFile(from: from, to: to) + return .success(()) + } catch { + return .failure(error) + } + } + } + + for await result in group { + switch result { + case .success(): + continue + case .failure(let error): + log.error( + "log pipe failed", + metadata: [ + "id": "\(id)", + "error": "\(error)", + ]) + } + } + } + } + + try await self.exitMonitor.registerProcess( + id: id, + onExit: self.handleMachineExit + ) + + state.snapshot.status = .running + state.snapshot.startedDate = Date() + state.snapshot.containerId = cid + state.snapshot.initialized = bundle.initialized + await self.setMachineState(id, state, context: context) + + // Monitor container exit in the background so we can update container machine state + // when the backing container stops (e.g., VM crash, kill, etc.) + try await self.exitMonitor.track(id: id) { + self.log.info("registering container machine with exit monitor") + let code = try await process.wait() + self.log.info( + "container machine exited in exit monitor", + metadata: ["id": "\(id)", "rc": "\(code)"] + ) + + return ExitStatus(exitCode: code) + } + + return state.snapshot + } catch { + await self.exitMonitor.stopTracking(id: id) + + state.logger?.cancel() + await state.logger?.value + state.logger = nil + + fhs.forEach { try? $0.close() } + try? await self.client.delete(id: cid, force: true) + + state.snapshot.status = .stopped + state.snapshot.startedDate = nil + state.snapshot.containerId = nil + state.snapshot.ipAddress = nil + await self.setMachineState(id, state, context: context) + + throw error + } + } + } + + public func stop(id: String) async throws { + self.log.debug("\(#function)") + + try await self.lock.withLock { context in + let state = try await self._getMachineState(id: id) + + switch state.snapshot.status { + case .stopped: + return + case .running: + break + default: + throw ContainerizationError( + .invalidState, + message: "container machine \(id) is \(state.snapshot.status)" + ) + } + + guard let cid = state.snapshot.containerId else { + throw ContainerizationError( + .internalError, + message: "no container ID for running container machine" + ) + } + + try await self.client.stop(id: cid, opts: ContainerStopOptions(timeoutInSeconds: 10, signal: nil)) + await self.handleMachineExit(id: id, code: nil, context: context) + } + } + + private func handleMachineExit(id: String, code: ExitStatus? = nil) async { + await self.lock.withLock { [self] context in + await handleMachineExit(id: id, code: code, context: context) + } + } + + private func handleMachineExit(id: String, code: ExitStatus?, context: AsyncLock.Context) async { + self.log.info("container exited for container machine \(id)") + guard var state = self.machines[id] else { + return + } + state.snapshot.status = .stopped + state.snapshot.startedDate = nil + state.snapshot.containerId = nil + state.snapshot.ipAddress = nil + + state.logger?.cancel() + await state.logger?.value + state.logger = nil + + await self.exitMonitor.stopTracking(id: id) + await self.setMachineState(id, state, context: context) + } + + public func inspect(id: String) async throws -> MachineSnapshot { + self.log.debug("\(#function)") + var snapshot = try self._getMachineState(id: id).snapshot + let path = try self.bundlePath(id: id) + let bundle = MachineBundle(path: path) + snapshot.initialized = bundle.initialized + snapshot.diskSize = bundle.diskSize + if snapshot.status == .running, let cid = snapshot.containerId { + do { + let container = try await self.client.get(id: cid) + snapshot.ipAddress = container.networks.first?.ipv4Address.address.description + } catch { + self.log.warning("failed to fetch container address for \(cid): \(error)") + } + } + return snapshot + } + + // Get the logs for the container machine + public func logs(id: String) async throws -> [FileHandle] { + self.log.debug("\(#function)") + + do { + _ = try _getMachineState(id: id) + let path = try self.bundlePath(id: id) + let bundle = MachineBundle(path: path) + return [ + try FileHandle(forReadingFrom: URL(filePath: bundle.stdioLog.string)), + try FileHandle(forReadingFrom: URL(filePath: bundle.bootLog.string)), + ] + } catch { + throw ContainerizationError( + .internalError, + message: "failed to open container machine logs: \(error)") + } + } +} + +extension MachinesService { + fileprivate struct ServiceState: Codable, Sendable { + private var path: FilePath? + + public var defaultMachine: String? + + enum CodingKeys: String, CodingKey { + case defaultMachine + } + + public static func from(_ path: FilePath) throws -> ServiceState { + var state: ServiceState + + let url = URL(filePath: path.string) + do { + let data = try Data(contentsOf: url) + state = try JSONDecoder().decode(Self.self, from: data) + } catch { + state = ServiceState(defaultMachine: nil) + try JSONEncoder().encode(state).write(to: url) + } + + state.path = path + return state + } + + public mutating func setDefault(id: String?) throws { + guard let path else { + throw ContainerizationError( + .internalError, + message: "service state path is not set" + ) + } + + self.defaultMachine = id + let data = try JSONEncoder().encode(self) + try data.write(to: URL(filePath: path.string), options: .atomic) + } + } +} + +extension MachineBundle { + func createLogFiles() throws { + let bootLogFd = Darwin.open(self.bootLog.string, O_CREAT | O_RDONLY, 0o644) + guard bootLogFd > 0 else { + throw POSIXError(.init(rawValue: errno)!) + } + + close(bootLogFd) + + let stdioLogFd = Darwin.open(self.stdioLog.string, O_CREAT | O_RDONLY, 0o644) + guard stdioLogFd > 0 else { + throw POSIXError(.init(rawValue: errno)!) + } + close(stdioLogFd) + } +} + +extension MachineConfiguration { + fileprivate func toContainerConfig( + cid: String, + sbin: FilePath, + initializedFile: FilePath, + homeMountOption: MachineConfig.HomeMountOption, + virtualization: Bool, + ) async throws -> ContainerConfiguration { + var config = ContainerConfiguration( + id: cid, + image: image, + process: ProcessConfiguration( + executable: "/\(MachineBundle.sbinDirectory)/\(MachineBundle.initFile)", + arguments: [], + environment: processEnvironment, + workingDirectory: "/", + terminal: true, + user: .id(uid: 0, gid: 0) + ) + ) + + let home = FileManager.default.homeDirectoryForCurrentUser.path + config.mounts = [ + .virtiofs( + source: sbin.string, + destination: "/\(MachineBundle.sbinDirectory)", + options: ["ro"]), + .virtiofs( + source: initializedFile.string, + destination: "/etc/.\(MachineBundle.initializedFile)", + options: ["rw"]), + ] + if homeMountOption != .none { + config.mounts.append( + .virtiofs( + source: home, + destination: home, + options: [homeMountOption.rawValue] + ) + ) + } + + config.platform = platform + config.labels = [ + ResourceLabelKeys.plugin: "machine" + ] + let domain = Self.defaultDNSDomain + config.dns = ContainerConfiguration.DNSConfiguration( + nameservers: [], + domain: domain, + searchDomains: [domain], + ) + 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: dnsHostname) + ) + ] + + config.capAdd = ["ALL"] + config.ssh = true + config.virtualization = virtualization + + config.rosetta = platform.architecture == "amd64" && Arch.hostArchitecture() == .arm64 + + // Default to nil if image is not found, which defaults to send SIGTERM on stop + let imageConfig = try? await ClientImage(description: image).config(for: platform).config + config.stopSignal = imageConfig?.stopSignal + + return config + } +} diff --git a/Sources/Services/Network/Client/NetworkClient.swift b/Sources/Services/Network/Client/NetworkClient.swift new file mode 100644 index 0000000..97598a7 --- /dev/null +++ b/Sources/Services/Network/Client/NetworkClient.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Foundation + +/// A client for interacting with a single network. +public struct NetworkClient: Sendable { + static let label = "com.apple.container.network" + + public static func machServiceLabel(id: String, plugin: String) -> String { + "\(Self.label).\(plugin).\(id)" + } + + private var machServiceLabel: String { + Self.machServiceLabel(id: id, plugin: plugin) + } + + let id: String + let plugin: String + + /// Create a client for a network. + public init(id: String, plugin: String) { + self.id = id + self.plugin = plugin + } +} + +// Runtime Methods +extension NetworkClient { + /// Open a persistent connection to the network helper. + /// + /// The returned session should be reused for `allocate(on:)` calls. The + /// network helper automatically releases all allocations made over this + /// session when it closes. + public func connect() -> XPCClientSession { + createClient().openSession() + } + + public func status() async throws -> NetworkStatus { + let request = XPCMessage(route: NetworkRoutes.status.rawValue) + let client = createClient() + + let response = try await client.send(request) + let status = try response.status() + return status + } + + /// Allocate a network attachment over an existing session. + /// + /// Use `connect()` to obtain a session, then pass it here. The session + /// must remain open for the lifetime of the allocation; closing it + /// releases the allocation on the network helper automatically. + public func allocate( + hostname: String, + macAddress: MACAddress? = nil, + on session: XPCClientSession + ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) + request.set(key: NetworkKeys.hostname.rawValue, value: hostname) + if let macAddress = macAddress { + request.set(key: NetworkKeys.macAddress.rawValue, value: macAddress.description) + } + let response = try await session.send(request) + let attachment = try response.attachment() + let additionalData = response.additionalData() + return (attachment, additionalData) + } + + public func lookup(hostname: String) async throws -> Attachment? { + let request = XPCMessage(route: NetworkRoutes.lookup.rawValue) + request.set(key: NetworkKeys.hostname.rawValue, value: hostname) + + let client = createClient() + + let response = try await client.send(request) + return try response.dataNoCopy(key: NetworkKeys.attachment.rawValue).map { + try JSONDecoder().decode(Attachment.self, from: $0) + } + } + + private func createClient() -> XPCClient { + XPCClient(service: machServiceLabel) + } +} + +extension XPCMessage { + public func additionalData() -> XPCMessage? { + guard let additionalData = xpc_dictionary_get_dictionary(self.underlying, NetworkKeys.additionalData.rawValue) else { + return nil + } + return XPCMessage(object: additionalData) + } + + public func attachment() throws -> Attachment { + let data = self.dataNoCopy(key: NetworkKeys.attachment.rawValue) + guard let data else { + throw ContainerizationError(.invalidArgument, message: "no network attachment snapshot data in message") + } + return try JSONDecoder().decode(Attachment.self, from: data) + } + + public func hostname() throws -> String { + let hostname = self.string(key: NetworkKeys.hostname.rawValue) + guard let hostname else { + throw ContainerizationError(.invalidArgument, message: "no hostname data in message") + } + return hostname + } + + public func status() throws -> NetworkStatus { + let data = self.dataNoCopy(key: NetworkKeys.status.rawValue) + guard let data else { + throw ContainerizationError(.invalidArgument, message: "no network snapshot data in message") + } + return try JSONDecoder().decode(NetworkStatus.self, from: data) + } +} diff --git a/Sources/Services/Network/Client/NetworkKeys.swift b/Sources/Services/Network/Client/NetworkKeys.swift new file mode 100644 index 0000000..b2ec741 --- /dev/null +++ b/Sources/Services/Network/Client/NetworkKeys.swift @@ -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. +//===----------------------------------------------------------------------===// + +public enum NetworkKeys: String { + case additionalData + case attachment + case hostname + case macAddress + case network + case status +} diff --git a/Sources/Services/Network/Client/NetworkRoutes.swift b/Sources/Services/Network/Client/NetworkRoutes.swift new file mode 100644 index 0000000..3fca4e9 --- /dev/null +++ b/Sources/Services/Network/Client/NetworkRoutes.swift @@ -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. +//===----------------------------------------------------------------------===// + +public enum NetworkRoutes: String { + /// Return the current status of the network. + case status = "com.apple.container.network/status" + /// Allocates parameters for attaching a sandbox to the network. + case allocate = "com.apple.container.network/allocate" + /// Retrieves the allocation for a hostname. + case lookup = "com.apple.container.network/lookup" +} diff --git a/Sources/Services/Network/Server/AttachmentAllocator.swift b/Sources/Services/Network/Server/AttachmentAllocator.swift new file mode 100644 index 0000000..b7d3aee --- /dev/null +++ b/Sources/Services/Network/Server/AttachmentAllocator.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras + +actor AttachmentAllocator { + private let allocator: any AddressAllocator + private var hostnames: [String: UInt32] = [:] + + init(lower: UInt32, size: Int) throws { + allocator = try UInt32.rotatingAllocator( + lower: lower, + size: UInt32(size) + ) + } + + /// Allocate a network address for a host. + func allocate(hostname: String) async throws -> UInt32 { + // Client is responsible for ensuring two containers don't use same hostname, so provide existing IP if hostname exists + if let index = hostnames[hostname] { + return index + } + + let index = try allocator.allocate() + hostnames[hostname] = index + + return index + } + + /// Free an allocated network address by hostname. + @discardableResult + func deallocate(hostname: String) async throws -> UInt32? { + guard let index = hostnames.removeValue(forKey: hostname) else { + return nil + } + + try allocator.release(index) + return index + } + + /// Retrieve the allocator index for a hostname. + func lookup(hostname: String) async throws -> UInt32? { + hostnames[hostname] + } +} diff --git a/Sources/Services/Network/Server/DefaultNetworkService.swift b/Sources/Services/Network/Server/DefaultNetworkService.swift new file mode 100644 index 0000000..70d17d3 --- /dev/null +++ b/Sources/Services/Network/Server/DefaultNetworkService.swift @@ -0,0 +1,162 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Logging + +public actor DefaultNetworkService: NetworkService { + private let network: any Network + private let log: Logger + private var allocator: AttachmentAllocator + private var macAddresses: [UInt32: MACAddress] + private var allocationsBySession: [XPCServerSession: [(hostname: String, index: UInt32)]] + + /// Set up a network service for the specified network. + public init( + network: any Network, + log: Logger + ) async throws { + guard let status = await network.status else { + throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") + } + + let subnet = status.ipv4Subnet + let size = Int(subnet.upper.value - subnet.lower.value - 3) + self.network = network + self.log = log + self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) + self.macAddresses = [:] + self.allocationsBySession = [:] + } + + @Sendable + public func status() async throws -> NetworkStatus { + guard let status = await network.status else { + throw ContainerizationError(.invalidState, message: "network \(network.id) is not running") + } + return status + } + + @Sendable + public func allocate( + hostname: String, + macAddress: MACAddress?, + session: XPCServerSession + ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + log.debug("enter", metadata: ["func": "\(#function)"]) + defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + + guard let status = await network.status else { + throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") + } + + let macAddress = macAddress ?? MACAddress((UInt64.random(in: 0...UInt64.max) & 0x0cff_ffff_ffff) | 0xf200_0000_0000) + let index = try await allocator.allocate(hostname: hostname) + let ipv6Address = try status.ipv6Subnet + .map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) } + let ip = IPv4Address(index) + let attachment = Attachment( + network: network.id, + hostname: hostname, + ipv4Address: try CIDRv4(ip, prefix: status.ipv4Subnet.prefix), + ipv4Gateway: status.ipv4Gateway, + ipv6Address: ipv6Address, + macAddress: macAddress, + variant: network.variant + ) + log.info( + "allocated attachment", + metadata: [ + "hostname": "\(hostname)", + "ipv4Address": "\(attachment.ipv4Address)", + "ipv4Gateway": "\(attachment.ipv4Gateway)", + "ipv6Address": "\(attachment.ipv6Address?.description ?? "unavailable")", + "macAddress": "\(attachment.macAddress?.description ?? "unspecified")", + ]) + + var additionalData: XPCMessage? + try network.withAdditionalData { + additionalData = $0 + } + macAddresses[index] = macAddress + + let isNewSession = allocationsBySession[session] == nil + allocationsBySession[session, default: []].append((hostname: hostname, index: index)) + if isNewSession { + await session.onDisconnect { [weak self] in + await self?.releaseSession(session) + } + } + + return (attachment: attachment, additionalData: additionalData) + } + + private func releaseSession(_ session: XPCServerSession) async { + guard let allocations = allocationsBySession.removeValue(forKey: session) else { + return + } + for allocation in allocations { + _ = try? await allocator.deallocate(hostname: allocation.hostname) + macAddresses.removeValue(forKey: allocation.index) + } + log.info("released session", metadata: ["allocations": "\(allocations.count)"]) + } + + @Sendable + public func lookup(hostname: String) async throws -> Attachment? { + log.debug("enter", metadata: ["func": "\(#function)"]) + defer { log.debug("exit", metadata: ["func": "\(#function)"]) } + + guard let status = await network.status else { + throw ContainerizationError(.invalidState, message: "network \(network.id) must be running") + } + + // Invariant: hostname -> index if and only if index -> MAC address + let index = try await allocator.lookup(hostname: hostname) + guard let index else { + return nil + } + guard let macAddress = macAddresses[index] else { + return nil + } + + let address = IPv4Address(index) + let subnet = status.ipv4Subnet + let ipv4Address = try CIDRv4(address, prefix: subnet.prefix) + let ipv6Address = try status.ipv6Subnet + .map { try CIDRv6(macAddress.ipv6Address(network: $0.lower), prefix: $0.prefix) } + let attachment = Attachment( + network: network.id, + hostname: hostname, + ipv4Address: ipv4Address, + ipv4Gateway: status.ipv4Gateway, + ipv6Address: ipv6Address, + macAddress: macAddress, + variant: network.variant + ) + log.debug( + "lookup attachment", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + ]) + + return attachment + } +} diff --git a/Sources/Services/Network/Server/Network.swift b/Sources/Services/Network/Server/Network.swift new file mode 100644 index 0000000..6f1ff18 --- /dev/null +++ b/Sources/Services/Network/Server/Network.swift @@ -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 ContainerResource +import ContainerXPC + +/// Defines common characteristics and operations for a network. +public protocol Network: Sendable { + /// The network's identifier. + var id: String { get } + + /// An operational hint passed back to the runtime in the allocate response. + /// Together with the plugin name, the runtime uses this to select the appropriate + /// interface strategy for the sandbox. A `nil` value indicates that the plugin + /// has only a single, default variant. + nonisolated var variant: String? { get } + + /// The network's runtime status. `nil` before ``start()`` completes. + var status: NetworkStatus? { get async } + + /// Use implementation-dependent network attributes. + nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws + + /// Start the network. + func start() async throws +} diff --git a/Sources/Services/Network/Server/NetworkHarness.swift b/Sources/Services/Network/Server/NetworkHarness.swift new file mode 100644 index 0000000..6c9a96a --- /dev/null +++ b/Sources/Services/Network/Server/NetworkHarness.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkClient +import ContainerResource +import ContainerXPC +import ContainerizationExtras +import Foundation + +public actor NetworkHarness: Sendable { + private let service: NetworkService + + public init(service: NetworkService) { + self.service = service + } + + @Sendable + public func status(_ message: XPCMessage) async throws -> XPCMessage { + let reply = message.reply() + let status = try await service.status() + try reply.setStatus(status) + return reply + } + + @Sendable + public func allocate(_ message: XPCMessage, _ session: XPCServerSession) async throws -> XPCMessage { + let hostname = try message.hostname() + let macAddress = + try message.string(key: NetworkKeys.macAddress.rawValue) + .map { try MACAddress($0) } + + let (attachment:attachment, additionalData:additionalData) = try await service.allocate( + hostname: hostname, + macAddress: macAddress, + session: session + ) + + let reply = message.reply() + try reply.setAttachment(attachment) + if let additionalData { + try reply.setAdditionalData(additionalData.underlying) + } + + return reply + } + + @Sendable + public func lookup(_ message: XPCMessage) async throws -> XPCMessage { + let hostname = try message.hostname() + let reply = message.reply() + guard let attachment = try await service.lookup(hostname: hostname) else { + return reply + } + + try reply.setAttachment(attachment) + return reply + } +} + +extension XPCMessage { + fileprivate func setAdditionalData(_ additionalData: xpc_object_t) throws { + xpc_dictionary_set_value(self.underlying, NetworkKeys.additionalData.rawValue, additionalData) + } + + fileprivate func setAttachment(_ attachment: Attachment) throws { + let data = try JSONEncoder().encode(attachment) + self.set(key: NetworkKeys.attachment.rawValue, value: data) + } + + fileprivate func setStatus(_ status: NetworkStatus) throws { + let data = try JSONEncoder().encode(status) + self.set(key: NetworkKeys.status.rawValue, value: data) + } + +} diff --git a/Sources/Services/Network/Server/NetworkService.swift b/Sources/Services/Network/Server/NetworkService.swift new file mode 100644 index 0000000..6e9f2b7 --- /dev/null +++ b/Sources/Services/Network/Server/NetworkService.swift @@ -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 ContainerResource +import ContainerXPC +import ContainerizationExtras + +/// A network service +public protocol NetworkService: Sendable { + /// Gets the properties of the realized network. + func status() async throws -> NetworkStatus + + /// Register a hostname and allocate associated addresses. + func allocate( + hostname: String, + macAddress: MACAddress?, + session: XPCServerSession + ) async throws -> (attachment: Attachment, additionalData: XPCMessage?) + + /// Return the attachment for a hostname if it is registered with the network. + func lookup(hostname: String) async throws -> Attachment? +} diff --git a/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift new file mode 100644 index 0000000..131e8af --- /dev/null +++ b/Sources/Services/NetworkVmnet/Server/AllocationOnlyVmnetNetwork.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkServer +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Logging + +public actor AllocationOnlyVmnetNetwork: Network { + // The IPv4 subnet to be used if none explicitly passed in the `NetworkConfiguration` + private static let defaultIPv4Subnet = try! CIDRv4("192.168.64.1/24") + + private let configuration: NetworkConfiguration + private let log: Logger + private var _status: NetworkStatus? + + /// Configure a bridge network that allows external system access using + /// network address translation. + public init( + configuration: NetworkConfiguration, + log: Logger + ) throws { + guard configuration.mode == .nat else { + throw ContainerizationError(.unsupported, message: "invalid network mode \(configuration.mode)") + } + + guard configuration.ipv6Subnet == nil else { + throw ContainerizationError(.unsupported, message: "IPv6 subnet assignment is not yet implemented") + } + + self.configuration = configuration + self.log = log + self._status = nil + } + + public nonisolated var id: String { configuration.id } + + public nonisolated var variant: String? { "allocationOnly" } + + public var status: NetworkStatus? { _status } + + public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { + try handler(nil) + } + + public func start() async throws { + guard _status == nil else { + throw ContainerizationError(.invalidState, message: "cannot start network \(configuration.id): already started") + } + + log.info( + "starting allocation-only network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(NetworkMode.nat.rawValue)", + ] + ) + + let ipv4Subnet = configuration.ipv4Subnet ?? Self.defaultIPv4Subnet + let gateway = IPv4Address(ipv4Subnet.lower.value + 1) + self._status = NetworkStatus( + ipv4Subnet: ipv4Subnet, + ipv4Gateway: gateway, + ipv6Subnet: nil + ) + log.info( + "started allocation-only network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(configuration.mode)", + "cidr": "\(ipv4Subnet)", + ] + ) + } +} diff --git a/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift new file mode 100644 index 0000000..5b0fee6 --- /dev/null +++ b/Sources/Services/NetworkVmnet/Server/ReservedVmnetNetwork.swift @@ -0,0 +1,202 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkServer +import ContainerResource +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging +import Synchronization +import XPC +import vmnet + +/// Creates a vmnet network with reservation APIs. +@available(macOS 26, *) +public final class ReservedVmnetNetwork: ContainerNetworkServer.Network { + private struct State { + var status: NetworkStatus? + var network: vmnet_network_ref? + } + + private struct NetworkInfo { + let network: vmnet_network_ref + let ipv4Subnet: CIDRv4 + let ipv4Gateway: IPv4Address + let ipv6Subnet: CIDRv6 + } + + private let configuration: NetworkConfiguration + private let stateMutex: Mutex + private let log: Logger + + /// Configure a bridge network that allows external system access using + /// network address translation. + public init( + configuration: NetworkConfiguration, + log: Logger + ) throws { + guard configuration.mode == .nat || configuration.mode == .hostOnly else { + throw ContainerizationError(.unsupported, message: "invalid network mode \(configuration.mode)") + } + + log.info("creating vmnet network") + self.configuration = configuration + self.log = log + stateMutex = Mutex(State()) + log.info("created vmnet network") + } + + public nonisolated var id: String { configuration.id } + + public nonisolated var variant: String? { "reserved" } + + public var status: NetworkStatus? { + stateMutex.withLock { $0.status } + } + + public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { + try stateMutex.withLock { state in + try handler(state.network.map { try Self.serialize_network_ref(ref: $0) }) + } + } + + public func start() async throws { + try stateMutex.withLock { state in + guard state.status == nil else { + throw ContainerizationError(.invalidArgument, message: "cannot start network \(configuration.id): already started") + } + + let networkInfo = try startNetwork(configuration: configuration, log: log) + + state.status = NetworkStatus( + ipv4Subnet: networkInfo.ipv4Subnet, + ipv4Gateway: networkInfo.ipv4Gateway, + ipv6Subnet: networkInfo.ipv6Subnet + ) + state.network = networkInfo.network + } + } + + private static func serialize_network_ref(ref: vmnet_network_ref) throws -> XPCMessage { + var status: vmnet_return_t = .VMNET_SUCCESS + guard let refObject = vmnet_network_copy_serialization(ref, &status) else { + throw ContainerizationError(.invalidArgument, message: "cannot serialize vmnet_network_ref to XPC object, status \(status)") + } + return XPCMessage(object: refObject) + } + + private func startNetwork(configuration: NetworkConfiguration, log: Logger) throws -> NetworkInfo { + log.info( + "starting vmnet network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(configuration.mode)", + ] + ) + + // set up the vmnet configuration + var status: vmnet_return_t = .VMNET_SUCCESS + let mode: vmnet.operating_modes_t = configuration.mode == .hostOnly ? .VMNET_HOST_MODE : .VMNET_SHARED_MODE + guard let vmnetConfiguration = vmnet_network_configuration_create(mode, &status), status == .VMNET_SUCCESS else { + throw ContainerizationError(.unsupported, message: "failed to create vmnet config with status \(status)") + } + + vmnet_network_configuration_disable_dhcp(vmnetConfiguration) + + let ipv4Subnet = configuration.ipv4Subnet + let ipv6Subnet = configuration.ipv6Subnet + + // set the IPv4 subnet + if let ipv4Subnet { + let gateway = IPv4Address(ipv4Subnet.lower.value + 1) + var gatewayAddr = in_addr() + inet_pton(AF_INET, gateway.description, &gatewayAddr) + let mask = IPv4Address(ipv4Subnet.prefix.prefixMask32) + var maskAddr = in_addr() + inet_pton(AF_INET, mask.description, &maskAddr) + log.info( + "configuring vmnet IPv4 subnet", + metadata: ["cidr": "\(ipv4Subnet)"] + ) + let status = vmnet_network_configuration_set_ipv4_subnet(vmnetConfiguration, &gatewayAddr, &maskAddr) + guard status == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to set subnet \(ipv4Subnet) for IPv4 network \(configuration.id)") + } + } + + // set the IPv6 network prefix + if let ipv6Subnet { + let gateway = IPv6Address(ipv6Subnet.lower.value + 1) + var gatewayAddr = in6_addr() + inet_pton(AF_INET6, gateway.description, &gatewayAddr) + log.info( + "configuring vmnet IPv6 prefix", + metadata: ["cidr": "\(ipv6Subnet)"] + ) + let status = vmnet_network_configuration_set_ipv6_prefix(vmnetConfiguration, &gatewayAddr, ipv6Subnet.prefix.length) + guard status == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to set prefix \(ipv6Subnet) for IPv6 network \(configuration.id)") + } + } + + // reserve the network + guard let network = vmnet_network_create(vmnetConfiguration, &status), status == .VMNET_SUCCESS else { + throw ContainerizationError(.unsupported, message: "failed to create vmnet network with status \(status)") + } + + // retrieve the subnet since the caller may not have provided one + var subnetAddr = in_addr() + var maskAddr = in_addr() + vmnet_network_get_ipv4_subnet(network, &subnetAddr, &maskAddr) + let subnetValue = UInt32(bigEndian: subnetAddr.s_addr) + let maskValue = UInt32(bigEndian: maskAddr.s_addr) + let lower = IPv4Address(subnetValue & maskValue) + let upper = IPv4Address(lower.value + ~maskValue) + let runningSubnet = try CIDRv4(lower: lower, upper: upper) + let runningGateway = IPv4Address(runningSubnet.lower.value + 1) + + var prefixAddr = in6_addr() + var prefixLength = UInt8(0) + vmnet_network_get_ipv6_prefix(network, &prefixAddr, &prefixLength) + guard let prefix = Prefix(length: prefixLength) else { + throw ContainerizationError(.internalError, message: "invalid IPv6 prefix length \(prefixLength) for network \(configuration.id)") + } + let prefixIpv6Bytes = withUnsafeBytes(of: prefixAddr.__u6_addr.__u6_addr8) { + Array($0) + } + let prefixIpv6Addr = try IPv6Address(prefixIpv6Bytes) + let runningV6Subnet = try CIDRv6(prefixIpv6Addr, prefix: prefix) + + log.info( + "started vmnet network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(configuration.mode)", + "cidr": "\(runningSubnet)", + "cidrv6": "\(runningV6Subnet)", + ] + ) + + return NetworkInfo( + network: network, + ipv4Subnet: runningSubnet, + ipv4Gateway: runningGateway, + ipv6Subnet: runningV6Subnet, + ) + } +} diff --git a/Sources/Services/Runtime/RuntimeClient/Bundle+Log.swift b/Sources/Services/Runtime/RuntimeClient/Bundle+Log.swift new file mode 100644 index 0000000..72cc27d --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/Bundle+Log.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerResource.Bundle { + /// The pathname for the workload log file. + public var containerLog: URL { + path.appendingPathComponent("stdio.log") + } +} diff --git a/Sources/Services/Runtime/RuntimeClient/ExitMonitor.swift b/Sources/Services/Runtime/RuntimeClient/ExitMonitor.swift new file mode 100644 index 0000000..6ac2fa9 --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/ExitMonitor.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging + +/// Track when long running work exits, and notify the caller via a callback. +public actor ExitMonitor { + /// A callback that receives the client identifier and exit code. + public typealias ExitCallback = @Sendable (String, ExitStatus) async throws -> Void + + /// A function that waits for work to complete, returning an exit code. + public typealias WaitHandler = @Sendable () async throws -> ExitStatus + + /// Create a new monitor. + /// + /// - Parameters: + /// - log: The destination for log messages. + public init(log: Logger? = nil) { + self.log = log + } + + private var exitCallbacks: [String: ExitCallback] = [:] + private var runningTasks: [String: Task] = [:] + private let log: Logger? + + /// Remove tracked work from the monitor. + /// + /// - Parameters: + /// - id: The client identifier for the tracked work. + public func stopTracking(id: String) async { + if let task = self.runningTasks[id] { + task.cancel() + } + exitCallbacks.removeValue(forKey: id) + runningTasks.removeValue(forKey: id) + } + + /// Register long running work so that the monitor invokes + /// a callback when the work completes. + /// + /// - Parameters: + /// - id: The client identifier for the work. + /// - onExit: The callback to invoke when the work completes. + public func registerProcess(id: String, onExit: @escaping ExitCallback) async throws { + guard self.exitCallbacks[id] == nil else { + throw ContainerizationError(.invalidState, message: "ExitMonitor already setup for process \(id)") + } + self.exitCallbacks[id] = onExit + } + + /// Await the completion of previously registered item of work. + /// + /// - Parameters: + /// - id: The client identifier for the work. + /// - waitingOn: A function that waits for the work to complete, + /// and then returns an exit code. + public func track(id: String, waitingOn: @escaping WaitHandler) async throws { + guard let onExit = self.exitCallbacks[id] else { + throw ContainerizationError(.invalidState, message: "ExitMonitor not setup for process \(id)") + } + guard self.runningTasks[id] == nil else { + throw ContainerizationError(.invalidState, message: "already have a running task tracking process \(id)") + } + self.runningTasks[id] = Task { + do { + let exitStatus = try await waitingOn() + try await onExit(id, exitStatus) + } catch { + self.log?.error("WaitHandler for \(id) threw error \(String(describing: error))") + try? await onExit(id, ExitStatus(exitCode: -1)) + } + } + } +} diff --git a/Sources/Services/Runtime/RuntimeClient/InterfaceStrategy.swift b/Sources/Services/Runtime/RuntimeClient/InterfaceStrategy.swift new file mode 100644 index 0000000..24d842d --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/InterfaceStrategy.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization + +/// Key identifying which interface strategy to use for a network attachment. +public struct NetworkInterfaceKey: Hashable, Sendable { + public let plugin: String + public let variant: String? + + public init(plugin: String, variant: String?) { + self.plugin = plugin + self.variant = variant + } +} + +/// A strategy for mapping network attachment information to a network interface. +public protocol InterfaceStrategy: Sendable { + /// Map a client network attachment request to a network interface specification. + /// + /// - Parameters: + /// - attachment: General attachment information that is common + /// for all networks. + /// - interfaceIndex: The zero-based index of the interface. + /// - additionalData: If present, attachment information that is + /// specific for the network to which the container will attach. + /// + /// - Returns: An XPC message with no parameters. + func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface +} diff --git a/Sources/Services/Runtime/RuntimeClient/NetworkBootstrapInfo.swift b/Sources/Services/Runtime/RuntimeClient/NetworkBootstrapInfo.swift new file mode 100644 index 0000000..7f44f05 --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/NetworkBootstrapInfo.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// Plugin info passed from the API server in the sandbox bootstrap message so the +/// runtime can connect to the correct network helper. +public struct NetworkBootstrapInfo: Codable, Sendable { + /// The network plugin name identifying which network helper to contact. + public let plugin: String + + public init(plugin: String) { + self.plugin = plugin + } +} diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift new file mode 100644 index 0000000..087a812 --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeClient.swift @@ -0,0 +1,376 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation +import TerminalProgress + +/// A client for interacting with a container runtime service instance. +public struct RuntimeClient: Sendable { + static let label = "com.apple.container.runtime" + + public static func machServiceLabel(runtime: String, id: String) -> String { + "\(Self.label).\(runtime).\(id)" + } + + private var machServiceLabel: String { + Self.machServiceLabel(runtime: runtime, id: id) + } + + let id: String + let runtime: String + let client: XPCClient + + init(id: String, runtime: String, client: XPCClient) { + self.id = id + self.runtime = runtime + self.client = client + } + + /// Create a RuntimeClient by ID and runtime string. The returned client is ready to be used + /// without additional steps. + public static func create(id: String, runtime: String, timeout: Duration = XPCClient.xpcRegistrationTimeout) async throws -> RuntimeClient { + let label = Self.machServiceLabel(runtime: runtime, id: id) + let client = XPCClient(service: label) + let request = XPCMessage(route: RuntimeRoutes.createEndpoint.rawValue) + + let response: XPCMessage + do { + response = try await client.send(request, responseTimeout: timeout) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create container \(id)", + cause: error + ) + } + guard let endpoint = response.endpoint(key: RuntimeKeys.runtimeServiceEndpoint.rawValue) else { + throw ContainerizationError( + .internalError, + message: "failed to get endpoint for runtime service" + ) + } + + let endpointConnection = xpc_connection_create_from_endpoint(endpoint) + let xpcClient = XPCClient(connection: endpointConnection, label: label) + return RuntimeClient(id: id, runtime: runtime, client: xpcClient) + } +} + +// Runtime Methods +extension RuntimeClient { + public func bootstrap( + stdio: [FileHandle?], + networkBootstrapInfos: [NetworkBootstrapInfo], + dynamicEnv: [String: String] = [:] + ) async throws { + let request = XPCMessage(route: RuntimeRoutes.bootstrap.rawValue) + + for (i, h) in stdio.enumerated() { + let key: RuntimeKeys = try { + switch i { + case 0: .stdin + case 1: .stdout + case 2: .stderr + default: + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") + } + }() + + if let h { + request.set(key: key.rawValue, value: h) + } + } + + do { + let dynamicEnv = try JSONEncoder().encode(dynamicEnv) + request.set(key: RuntimeKeys.dynamicEnv.rawValue, value: dynamicEnv) + + let infosData = try JSONEncoder().encode(networkBootstrapInfos) + request.set(key: RuntimeKeys.networkBootstrapInfos.rawValue, value: infosData) + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to bootstrap container \(self.id)", + cause: error + ) + } + } + + public func state() async throws -> SandboxSnapshot { + let request = XPCMessage(route: RuntimeRoutes.state.rawValue) + let response: XPCMessage + do { + response = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get state for container \(self.id)", + cause: error + ) + } + return try response.sandboxSnapshot() + } + + public func createProcess(_ id: String, config: ProcessConfiguration, stdio: [FileHandle?]) async throws { + let request = XPCMessage(route: RuntimeRoutes.createProcess.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: id) + let data = try JSONEncoder().encode(config) + request.set(key: RuntimeKeys.processConfig.rawValue, value: data) + + for (i, h) in stdio.enumerated() { + let key: RuntimeKeys = try { + switch i { + case 0: .stdin + case 1: .stdout + case 2: .stderr + default: + throw ContainerizationError(.invalidArgument, message: "invalid fd \(i)") + } + }() + + if let h { + request.set(key: key.rawValue, value: h) + } + } + + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create process \(id) in container \(self.id)", + cause: error + ) + } + } + + public func startProcess(_ id: String) async throws { + let request = XPCMessage(route: RuntimeRoutes.start.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: id) + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to start process \(id) in container \(self.id)", + cause: error + ) + } + } + + public func stop(options: ContainerStopOptions) async throws { + let request = XPCMessage(route: RuntimeRoutes.stop.rawValue) + + let data = try JSONEncoder().encode(options) + request.set(key: RuntimeKeys.stopOptions.rawValue, value: data) + + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to stop container \(self.id)", + cause: error + ) + } + } + + public func kill(_ id: String, signal: String) async throws { + let request = XPCMessage(route: RuntimeRoutes.kill.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: id) + request.set(key: RuntimeKeys.signal.rawValue, value: signal) + + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to send signal \(signal) to process \(id) in container \(self.id)", + cause: error + ) + } + } + + public func resize(_ id: String, size: Terminal.Size) async throws { + let request = XPCMessage(route: RuntimeRoutes.resize.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: id) + request.set(key: RuntimeKeys.width.rawValue, value: UInt64(size.width)) + request.set(key: RuntimeKeys.height.rawValue, value: UInt64(size.height)) + + do { + try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to resize pty for process \(id) in container \(self.id)", + cause: error + ) + } + } + + public func wait(_ id: String) async throws -> ExitStatus { + let request = XPCMessage(route: RuntimeRoutes.wait.rawValue) + request.set(key: RuntimeKeys.id.rawValue, value: id) + + let response: XPCMessage + do { + response = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to wait for process \(id) in container \(self.id)", + cause: error + ) + } + let code = response.int64(key: RuntimeKeys.exitCode.rawValue) + let date = response.date(key: RuntimeKeys.exitedAt.rawValue) + return ExitStatus(exitCode: Int32(code), exitedAt: date) + } + + public func dial(_ port: UInt32) async throws -> FileHandle { + let request = XPCMessage(route: RuntimeRoutes.dial.rawValue) + request.set(key: RuntimeKeys.port.rawValue, value: UInt64(port)) + + let response: XPCMessage + do { + response = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to dial \(port) on \(self.id)", + cause: error + ) + } + guard let fh = response.fileHandle(key: RuntimeKeys.fd.rawValue) else { + throw ContainerizationError( + .internalError, + message: "failed to get fd for vsock port \(port)" + ) + } + return fh + } + + public func shutdown() async throws { + let request = XPCMessage(route: RuntimeRoutes.shutdown.rawValue) + + do { + _ = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to shutdown container \(self.id)", + cause: error + ) + } + } + + public func copyIn(source: String, destination: String, mode: UInt32, createParents: Bool = true) async throws { + let request = XPCMessage(route: RuntimeRoutes.copyIn.rawValue) + request.set(key: RuntimeKeys.sourcePath.rawValue, value: source) + request.set(key: RuntimeKeys.destinationPath.rawValue, value: destination) + request.set(key: RuntimeKeys.fileMode.rawValue, value: UInt64(mode)) + request.set(key: RuntimeKeys.createParents.rawValue, value: createParents) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy into container \(self.id)", + cause: error + ) + } + } + + public func copyOut(source: String, destination: String, createParents: Bool = true) async throws { + let request = XPCMessage(route: RuntimeRoutes.copyOut.rawValue) + request.set(key: RuntimeKeys.sourcePath.rawValue, value: source) + request.set(key: RuntimeKeys.destinationPath.rawValue, value: destination) + request.set(key: RuntimeKeys.createParents.rawValue, value: createParents) + + do { + try await self.client.send(request, responseTimeout: .seconds(300)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to copy from container \(self.id)", + cause: error + ) + } + } + + public func statistics() async throws -> ContainerStats { + let request = XPCMessage(route: RuntimeRoutes.statistics.rawValue) + + let response: XPCMessage + do { + response = try await self.client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get statistics for container \(self.id)", + cause: error + ) + } + + guard let data = response.dataNoCopy(key: RuntimeKeys.statistics.rawValue) else { + throw ContainerizationError( + .internalError, + message: "no statistics data returned" + ) + } + + return try JSONDecoder().decode(ContainerStats.self, from: data) + } +} + +extension XPCMessage { + public func id() throws -> String { + let id = self.string(key: RuntimeKeys.id.rawValue) + guard let id else { + throw ContainerizationError( + .invalidArgument, + message: "no id" + ) + } + return id + } + + func sandboxSnapshot() throws -> SandboxSnapshot { + let data = self.dataNoCopy(key: RuntimeKeys.snapshot.rawValue) + guard let data else { + throw ContainerizationError( + .invalidArgument, + message: "no state data returned" + ) + } + return try JSONDecoder().decode(SandboxSnapshot.self, from: data) + } + + public func networkBootstrapInfos() throws -> [NetworkBootstrapInfo] { + guard let data = self.dataNoCopy(key: RuntimeKeys.networkBootstrapInfos.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "missing networkBootstrapInfos in bootstrap message") + } + return try JSONDecoder().decode([NetworkBootstrapInfo].self, from: data) + } +} diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift new file mode 100644 index 0000000..29507bb --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeConfiguration.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import ContainerizationError +import Foundation + +public struct RuntimeConfiguration: Codable, Sendable { + static let runtimeConfigurationFilename = "runtime-configuration.json" + + public let path: URL + // TODO: Remove runtime-specific fields (initialFilesystem, kernel, containerRootFilesystem). + // These should be encoded into the opaque `runtimeData` field by the CLI. + public let initialFilesystem: Filesystem + public let kernel: Kernel + public let containerConfiguration: ContainerConfiguration? + public let containerRootFilesystem: Filesystem? + public let options: ContainerCreateOptions? + public let runtimeData: Data? + + public init( + path: URL, + initialFilesystem: Filesystem, + kernel: Kernel, + containerConfiguration: ContainerConfiguration? = nil, + containerRootFilesystem: Filesystem? = nil, + options: ContainerCreateOptions? = nil, + runtimeData: Data? = nil + ) { + self.path = path + self.initialFilesystem = initialFilesystem + self.kernel = kernel + self.containerConfiguration = containerConfiguration + self.containerRootFilesystem = containerRootFilesystem + self.options = options + self.runtimeData = runtimeData + } + + public var runtimeConfigurationPath: URL { + self.path.appendingPathComponent(Self.runtimeConfigurationFilename) + } + + public func writeRuntimeConfiguration() throws { + // Ensure the parent directory exists + let directory = self.runtimeConfigurationPath.deletingLastPathComponent() + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + + let data = try JSONEncoder().encode(self) + try data.write(to: self.runtimeConfigurationPath) + } + + public static func readRuntimeConfiguration(from runtimeConfigurationPath: URL) throws -> RuntimeConfiguration { + let configurationPath = runtimeConfigurationPath.appendingPathComponent(RuntimeConfiguration.runtimeConfigurationFilename) + let data: Data + do { + data = try Data(contentsOf: configurationPath) + } catch { + throw ContainerizationError( + .notFound, + message: "runtime configuration file not found at path: \(configurationPath.path)" + ) + } + return try JSONDecoder().decode(RuntimeConfiguration.self, from: data) + } +} diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift new file mode 100644 index 0000000..b472d9d --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeKeys.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum RuntimeKeys: String { + /// ID key. + case id + /// Vsock port number key. + case port + /// Exit code for a process + case exitCode + /// Exit timestamp for a process + case exitedAt + /// FD to a container resource key. + case fd + /// Options for stopping a container key. + case stopOptions + /// An endpoint to talk to the runtime service. + case runtimeServiceEndpoint + + /// Process request keys. + case signal + case snapshot + case stdin + case stdout + case stderr + case width + case height + case processConfig + + /// Container statistics + case statistics + + /// Copy parameters + case sourcePath + case destinationPath + case fileMode + case createParents + + /// Special-case environment variables recomputed on each container start + case dynamicEnv + + /// Per-network connection info passed to the runtime so it can allocate directly. + case networkBootstrapInfos +} diff --git a/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift new file mode 100644 index 0000000..cda6043 --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/RuntimeRoutes.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// XPC routes exposed by the runtime service. +public enum RuntimeRoutes: String { + // MARK: - Service lifecycle + + /// Create an XPC endpoint for communicating with the runtime service. + case createEndpoint = "com.apple.container.runtime/createEndpoint" + /// Shut down the runtime service process. Requires the sandbox to be stopped first. + case shutdown = "com.apple.container.runtime/shutdown" + + // MARK: - Sandbox lifecycle + + /// Bootstrap the sandbox: create the VM, configure networks, and boot the guest. + case bootstrap = "com.apple.container.runtime/bootstrap" + /// Stop the sandbox and all processes running inside it. + case stop = "com.apple.container.runtime/stop" + /// Return the current state of the sandbox. + case state = "com.apple.container.runtime/state" + /// Get resource usage statistics for the sandbox. + case statistics = "com.apple.container.runtime/statistics" + /// Open a vsock connection to a port inside the sandbox. + case dial = "com.apple.container.runtime/dial" + + // MARK: - Process management + + /// Register a new process inside the sandbox (used by exec). + case createProcess = "com.apple.container.runtime/createProcess" + /// Start a registered process inside the sandbox. + case start = "com.apple.container.runtime/start" + /// Send a signal to a process inside the sandbox. + case kill = "com.apple.container.runtime/kill" + /// Resize the PTY of a process inside the sandbox. + case resize = "com.apple.container.runtime/resize" + /// Wait for a process inside the sandbox to exit. + case wait = "com.apple.container.runtime/wait" + /// Execute a new process in the sandbox. + case exec = "com.apple.container.runtime/exec" + + // MARK: - File Management + /// Copy a file or directory into the container. + case copyIn = "com.apple.container.runtime/copyIn" + /// Copy a file or directory out of the container. + case copyOut = "com.apple.container.runtime/copyOut" +} diff --git a/Sources/Services/Runtime/RuntimeClient/SandboxSnapshot.swift b/Sources/Services/Runtime/RuntimeClient/SandboxSnapshot.swift new file mode 100644 index 0000000..1ba312b --- /dev/null +++ b/Sources/Services/Runtime/RuntimeClient/SandboxSnapshot.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// A snapshot of a sandbox and its resources. +public struct SandboxSnapshot: Codable, Sendable { + /// The runtime status of the sandbox. + public var status: RuntimeStatus + /// Network attachments for the sandbox. + public var networks: [Attachment] + /// Containers placed in the sandbox. + public var containers: [ContainerSnapshot] + + public init( + status: RuntimeStatus, + networks: [Attachment], + containers: [ContainerSnapshot] + ) { + self.status = status + self.networks = networks + self.containers = containers + } +} diff --git a/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift new file mode 100644 index 0000000..d30185a --- /dev/null +++ b/Sources/Services/RuntimeLinux/Client/LinuxRuntimeData.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +/// Linux-specific runtime data passed through the opaque runtimeData field +/// in RuntimeConfiguration. Encoded by the CLI, decoded by the Linux runtime. +public struct LinuxRuntimeData: Codable, Sendable { + public let variant: String? + + public init(variant: String? = nil) { + self.variant = variant + } +} diff --git a/Sources/Services/RuntimeLinux/Server/IsolatedInterfaceStrategy.swift b/Sources/Services/RuntimeLinux/Server/IsolatedInterfaceStrategy.swift new file mode 100644 index 0000000..d180fac --- /dev/null +++ b/Sources/Services/RuntimeLinux/Server/IsolatedInterfaceStrategy.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerRuntimeClient +import ContainerXPC +import Containerization + +/// Isolated container network interface strategy. This strategy prohibits +/// container to container networking, but it is the only approach that +/// works for macOS Sequoia. +public struct IsolatedInterfaceStrategy: InterfaceStrategy { + public init() {} + + public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) -> Interface { + let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil + return NATInterface( + ipv4Address: attachment.ipv4Address, + ipv4Gateway: ipv4Gateway, + macAddress: attachment.macAddress, + // https://github.com/apple/containerization/pull/38 + mtu: attachment.mtu ?? 1280 + ) + } +} diff --git a/Sources/Services/RuntimeLinux/Server/NonisolatedInterfaceStrategy.swift b/Sources/Services/RuntimeLinux/Server/NonisolatedInterfaceStrategy.swift new file mode 100644 index 0000000..38c1b67 --- /dev/null +++ b/Sources/Services/RuntimeLinux/Server/NonisolatedInterfaceStrategy.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerRuntimeClient +import ContainerXPC +import Containerization +import ContainerizationError +import Logging +import Virtualization +import vmnet + +/// Interface strategy for containers that use macOS's custom network feature. +@available(macOS 26, *) +public struct NonisolatedInterfaceStrategy: InterfaceStrategy { + private let log: Logger + + public init(log: Logger) { + self.log = log + } + + public func toInterface(attachment: Attachment, interfaceIndex: Int, additionalData: XPCMessage?) throws -> Interface { + guard let additionalData else { + throw ContainerizationError(.invalidState, message: "network state does not contain custom network reference") + } + + var status: vmnet_return_t = .VMNET_SUCCESS + guard let networkRef = vmnet_network_create_with_serialization(additionalData.underlying, &status) else { + throw ContainerizationError(.invalidState, message: "cannot deserialize custom network reference, status \(status)") + } + + log.info("creating NATNetworkInterface with network reference") + let ipv4Gateway = interfaceIndex == 0 ? attachment.ipv4Gateway : nil + return NATNetworkInterface( + ipv4Address: attachment.ipv4Address, + ipv4Gateway: ipv4Gateway, + reference: networkRef, + macAddress: attachment.macAddress, + // https://github.com/apple/containerization/pull/38 + mtu: attachment.mtu ?? 1280 + ) + } +} diff --git a/Sources/Services/RuntimeLinux/Server/RuntimeService.swift b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift new file mode 100644 index 0000000..2320ff4 --- /dev/null +++ b/Sources/Services/RuntimeLinux/Server/RuntimeService.swift @@ -0,0 +1,1579 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerNetworkClient +import ContainerOS +import ContainerPersistence +import ContainerResource +import ContainerRuntimeClient +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import Logging +import NIO +import NIOFoundationCompat +import SocketForwarder +import Synchronization +import SystemPackage + +import struct ContainerizationOCI.Mount +import struct ContainerizationOCI.Process + +/// An XPC service that manages the lifecycle of a single VM-backed container. +public actor RuntimeService { + private let connection: xpc_connection_t + private let root: URL + private let interfaceStrategies: [NetworkInterfaceKey: InterfaceStrategy] + private var container: ContainerInfo? + private let monitor: ExitMonitor + private let eventLoopGroup: any EventLoopGroup + private var waiters: [String: ExitWaiter] = [:] + private let lock: AsyncLock = AsyncLock() + private let log: Logging.Logger + private var state: State = .created + private var processes: [String: ProcessInfo] = [:] + private var socketForwarders: [SocketForwarderResult] = [] + private var networkSessions: [XPCClientSession] = [] + + private static let sshAuthSocketGuestPath = "/var/host-services/ssh-auth.sock" + private static let sshAuthSocketEnvVar = "SSH_AUTH_SOCK" + + class ExitWaiter { + public var exitStatus: ExitStatus? = nil + public var continuations: [CheckedContinuation] = [] + + public func wait(_ cc: CheckedContinuation) { + if let exitStatus = exitStatus { + // `doExit` has already been called for this waiter + cc.resume(returning: exitStatus) + return + } + continuations.append(cc) + } + + public func doExit(exitStatus: ExitStatus) { + for cc in continuations { + cc.resume(returning: exitStatus) + } + + self.exitStatus = exitStatus + } + } + + private static func sshAuthSocketHostUrl( + config: ContainerConfiguration, + dynamicEnv: [String: String] = [:], + log: Logger? = nil + ) -> URL? { + guard config.ssh else { + return nil + } + + guard let sshSocket = dynamicEnv[Self.sshAuthSocketEnvVar] else { + log?.warning("ssh forwarding requested but no \(Self.sshAuthSocketEnvVar) found") + return nil + } + + return URL(fileURLWithPath: sshSocket) + } + + public init( + root: URL, + interfaceStrategies: [NetworkInterfaceKey: InterfaceStrategy], + eventLoopGroup: any EventLoopGroup, + connection: xpc_connection_t, + log: Logger + ) { + self.root = root + self.interfaceStrategies = interfaceStrategies + self.log = log + self.monitor = ExitMonitor(log: log) + self.eventLoopGroup = eventLoopGroup + self.connection = connection + } + + /// Returns an endpoint from an anonymous xpc connection. + /// + /// - Parameters: + /// - message: An XPC message with no parameters. + /// + /// - Returns: An XPC message with the following parameters: + /// - endpoint: An XPC endpoint that can be used to communicate + /// with the runtime service. + @Sendable + public func createEndpoint(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + let endpoint = xpc_endpoint_create(self.connection) + let reply = message.reply() + reply.set(key: RuntimeKeys.runtimeServiceEndpoint.rawValue, value: endpoint) + return reply + } + + /// Start the VM and the guest agent process for a container. + /// + /// - Parameters: + /// - message: An XPC message with no parameters. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + // Create the bundle if it doesn't exist yet + if !self.bundleExists(at: self.root) { + try self.createBundle() + } + + return try await self.lock.withLock { _ in + guard await self.state == .created else { + throw ContainerizationError( + .invalidState, + message: "container expected to be in created state, got: \(await self.state)" + ) + } + + let dynamicEnv = try message.dynamicEnv() + + let bundle = ContainerResource.Bundle(path: self.root) + try bundle.createLogFile() + + var config = try bundle.configuration + + var kernel = try bundle.kernel + kernel.commandLine.kernelArgs.append("oops=panic") + kernel.commandLine.kernelArgs.append("lsm=lockdown,capability,landlock,yama,apparmor") + let vmm = VZVirtualMachineManager( + kernel: kernel, + initialFilesystem: bundle.initialFilesystem.asMount, + rosetta: config.rosetta, + logger: self.log + ) + + let networkBootstrapInfos = try message.networkBootstrapInfos() + + var sessions: [XPCClientSession] = [] + var attachments: [Attachment] = [] + var interfaces: [Interface] = [] + do { + for (index, info) in networkBootstrapInfos.enumerated() { + let attachmentConfig = config.networks[index] + let client = ContainerNetworkClient.NetworkClient(id: attachmentConfig.network, plugin: info.plugin) + let session = client.connect() + sessions.append(session) + var (attachment, additionalData) = try await client.allocate( + hostname: attachmentConfig.options.hostname, + macAddress: attachmentConfig.options.macAddress, + on: session + ) + if let mtu = attachmentConfig.options.mtu { + attachment = Attachment( + network: attachment.network, + hostname: attachment.hostname, + ipv4Address: attachment.ipv4Address, + ipv4Gateway: attachment.ipv4Gateway, + ipv6Address: attachment.ipv6Address, + macAddress: attachment.macAddress, + mtu: mtu, + variant: attachment.variant + ) + } + guard let iStrategy = self.interfaceStrategies[NetworkInterfaceKey(plugin: info.plugin, variant: attachment.variant)] else { + throw ContainerizationError( + .internalError, + message: "no available interface strategy for network \(attachment.network), plugin=\(info.plugin) variant=\(attachment.variant ?? "nil")") + } + let interface = try iStrategy.toInterface( + attachment: attachment, + interfaceIndex: index, + additionalData: additionalData + ) + attachments.append(attachment) + interfaces.append(interface) + } + } catch { + for session in sessions { session.close() } + throw error + } + + // Dynamically configure the DNS nameserver from a network if no explicit configuration + if let dns = config.dns, dns.nameservers.isEmpty { + let defaultNameservers = self.getDefaultNameservers(from: attachments) + if !defaultNameservers.isEmpty { + config.dns = ContainerConfiguration.DNSConfiguration( + nameservers: defaultNameservers, + domain: dns.domain, + searchDomains: dns.searchDomains, + options: dns.options + ) + } + } + + let stdio = message.stdio() + let containerLog = try FileHandle(forWritingTo: bundle.containerLog) + let stdout = { + if let h = stdio[1] { + return MultiWriter(handles: [h, containerLog]) + } + return MultiWriter(handles: [containerLog]) + }() + + let stderr: MultiWriter? = { + if !config.initProcess.terminal { + if let h = stdio[2] { + return MultiWriter(handles: [h, containerLog]) + } + return MultiWriter(handles: [containerLog]) + } + return nil + }() + + let stdin = { + stdio[0] ?? nil + }() + + let id = config.id + let rootfs = try bundle.containerRootfs.asMount + let container = try LinuxContainer(id, rootfs: rootfs, vmm: vmm, logger: self.log) { czConfig in + try Self.configureContainer(czConfig: &czConfig, config: config, dynamicEnv: dynamicEnv, log: self.log) + czConfig.interfaces = interfaces + czConfig.process.stdout = stdout + czConfig.process.stderr = stderr + czConfig.process.stdin = stdin + // NOTE: We can support a user providing new entries eventually, but for now craft + // a default /etc/hosts. + var hostsEntries = [Hosts.Entry.localHostIPV4()] + if !interfaces.isEmpty { + let primaryIfaceAddr = interfaces[0].ipv4Address + hostsEntries.append( + Hosts.Entry( + ipAddress: primaryIfaceAddr.address.description, + hostnames: [czConfig.hostname ?? id], + )) + } + czConfig.hosts = Hosts(entries: hostsEntries) + czConfig.bootLog = BootLog.file(path: bundle.bootlog, append: true) + } + + let ctrInfo = ContainerInfo( + container: container, + config: config, + attachments: attachments, + bundle: bundle, + io: (in: stdin, out: stdout, err: stderr) + ) + await self.setContainer(ctrInfo) + await self.setNetworkSessions(sessions) + + do { + try await container.create() + + try await self.initializeWaiters(for: id) + try await self.monitor.registerProcess(id: config.id, onExit: self.onContainerExit) + if !container.interfaces.isEmpty { + try await self.startSocketForwarders(attachment: attachments[0], publishedPorts: config.publishedPorts) + } + await self.setState(.booted) + } catch { + do { + try await self.cleanUpContainer(containerInfo: ctrInfo) + await self.setState(.stopped) + } catch { + self.log.error("failed to clean up container", metadata: ["error": "\(error)"]) + } + throw error + } + return message.reply() + } + } + + /// Start the container workload inside the virtual machine. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: A client identifier for the process. + /// - stdio: An array of file handles for standard input, output, and error. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func startProcess(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + return try await self.lock.withLock { lock in + let id = try message.id() + let containerInfo = try await self.getContainer() + let containerId = containerInfo.container.id + if id == containerId { + try await self.startInitProcess(lock: lock) + await self.setState(.running) + } else { + try await self.startExecProcess(processId: id, lock: lock) + } + return message.reply() + } + } + + /// Get statistics for the container. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: A client identifier for the process. + /// - stdio: An array of file handles for standard input, output, and error. + /// + /// - Returns: An XPC message with the following parameters: + /// - statistics: JSON serialization of the `ContainerStats`. + @Sendable + public func statistics(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + return try await self.lock.withLock { lock in + let containerInfo = try await self.getContainer() + let stats = try await containerInfo.container.statistics() + + let containerStats = ContainerStats( + id: stats.id, + memoryUsageBytes: stats.memory?.usageBytes, + memoryLimitBytes: stats.memory?.limitBytes, + cpuUsageUsec: stats.cpu?.usageUsec, + networkRxBytes: stats.networks?.reduce(0) { $0 + $1.receivedBytes }, + networkTxBytes: stats.networks?.reduce(0) { $0 + $1.transmittedBytes }, + blockReadBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.readBytes }, + blockWriteBytes: stats.blockIO?.devices.reduce(0) { $0 + $1.writeBytes }, + numProcesses: stats.process?.current + ) + + let reply = message.reply() + let data = try JSONEncoder().encode(containerStats) + reply.set(key: RuntimeKeys.statistics.rawValue, value: data) + return reply + } + } + + /// Shutdown the RuntimeService. + /// + /// - Parameters: + /// - message: An XPC message with no parameters. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func shutdown(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + return try await self.lock.withLock { _ in + switch await self.state { + case .created, .stopped, .stopping: + await self.setState(.shuttingDown) + + default: + throw ContainerizationError( + .invalidState, + message: "cannot shutdown: container is not stopped" + ) + } + + return message.reply() + } + } + + /// Create a process inside the virtual machine for the container. + /// + /// Use this procedure to run ad hoc processes in the virtual + /// machine (`container exec`). + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: A client identifier for the process. + /// - processConfig: JSON serialization of the `ProcessConfiguration` + /// containing the process attributes. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func createProcess(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + return try await self.lock.withLock { [self] _ in + switch await self.state { + case .running, .booted: + let id = try message.id() + let config = try message.processConfig() + let stdio = message.stdio() + + try await self.addNewProcess(id, config, stdio) + + try await self.initializeWaiters(for: id) + do { + try await self.monitor.registerProcess( + id: id, + onExit: { id, exitStatus in + await self.releaseWaiters(for: id, status: exitStatus) + + guard let process = await self.processes[id]?.process else { + throw ContainerizationError( + .invalidState, + message: "ProcessInfo missing for process \(id)" + ) + } + try await process.delete() + try await self.setProcessState(id: id, state: .stopped) + } + ) + } catch { + await self.releaseWaiters(for: id, status: ExitStatus(exitCode: -1)) + throw error + } + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot exec: container is not running" + ) + } + } + } + + /// Return the state for the sandbox and its containers. + /// + /// - Parameters: + /// - message: An XPC message with no parameters. + /// + /// - Returns: An XPC message with the following parameters: + /// - snapshot: The JSON serialization of the `SandboxSnapshot` + /// that contains the state information. + @Sendable + public func state(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + var status: RuntimeStatus = .unknown + var networks: [Attachment] = [] + var cs: ContainerSnapshot? + + switch state { + case .created, .stopped, .booted, .shuttingDown: + status = .stopped + case .stopping: + status = .stopping + case .running: + let ctr = try getContainer() + + status = .running + networks = ctr.attachments + cs = ContainerSnapshot( + configuration: ctr.config, + status: RuntimeStatus.running, + networks: networks + ) + } + + let reply = message.reply() + try reply.setState( + .init( + status: status, + networks: networks, + containers: cs != nil ? [cs!] : [] + ) + ) + return reply + } + + /// Stop the container workload, any ad hoc processes, and the underlying + /// virtual machine. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - stopOptions: JSON serialization of `ContainerStopOptions` + /// that modify stop behavior. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func stop(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + let stopOptions = try message.stopOptions() + let signal = try Signal(stopOptions.signal ?? "SIGTERM") + let timeout: Duration = .seconds(stopOptions.timeoutInSeconds) + + return try await self.lock.withLock { _ in + switch await self.state { + case .running, .booted: + await self.setState(.stopping) + + let ctr = try await self.getContainer() + let exitStatus = try await self.gracefulStopContainer( + ctr.container, + signal: signal, + timeout: timeout + ) + + do { + if case .stopped = await self.state { + return message.reply() + } + try await self.cleanUpContainer(containerInfo: ctr, exitStatus: exitStatus) + } catch { + self.log.error("failed to clean up container", metadata: ["error": "\(error)"]) + } + await self.setState(.stopped) + default: + break + } + return message.reply() + } + } + + /// Signal a process running in the virtual machine. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: The process identifier. + /// - signal: The signal value. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func kill(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + let id = try message.id() + let signal = try Signal(message.signal()) + + try await self.lock.withLock { [self] _ in + switch await self.state { + case .running: + let ctr = try await getContainer() + if id != ctr.container.id { + guard let processInfo = await self.processes[id] else { + throw ContainerizationError(.invalidState, message: "process \(id) does not exist") + } + + guard let proc = processInfo.process else { + throw ContainerizationError(.invalidState, message: "process \(id) not started") + } + try await proc.kill(signal) + return + } + + try await ctr.container.kill(signal) + default: + throw ContainerizationError( + .invalidState, + message: "cannot kill: container is not running" + ) + } + } + + // SIGKILL is guaranteed by the kernel to terminate the target, so block + // until we observe the exit. + if signal == .kill { + _ = await withCheckedContinuation { cc in + self.waitForExit(id: id, cont: cc) + } + } + + return message.reply() + } + + /// Resize the terminal for a process. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: The process identifier. + /// - width: The terminal width. + /// - height: The terminal height. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func resize(_ message: XPCMessage) async throws -> XPCMessage { + self.log.trace("enter", metadata: ["func": "\(#function)"]) + defer { self.log.trace("exit", metadata: ["func": "\(#function)"]) } + + switch self.state { + case .running: + let id = try message.id() + let ctr = try getContainer() + let width = message.uint64(key: RuntimeKeys.width.rawValue) + let height = message.uint64(key: RuntimeKeys.height.rawValue) + + if id != ctr.container.id { + guard let processInfo = self.processes[id] else { + throw ContainerizationError( + .invalidState, + message: "process \(id) does not exist" + ) + } + + guard let proc = processInfo.process else { + throw ContainerizationError( + .invalidState, + message: "process \(id) not started" + ) + } + + try await proc.resize( + to: .init( + width: UInt16(width), + height: UInt16(height)) + ) + } else { + try await ctr.container.resize( + to: .init( + width: UInt16(width), + height: UInt16(height)) + ) + } + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot resize: container is not running" + ) + } + } + + /// Wait for a process. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - id: The process identifier. + /// + /// - Returns: An XPC message with the following parameters: + /// - exitCode: The exit code for the process. + @Sendable + public func wait(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + guard let id = message.string(key: RuntimeKeys.id.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "missing id in wait xpc message") + } + + let exitStatus = await withCheckedContinuation { cc in + self.waitForExit(id: id, cont: cc) + } + let reply = message.reply() + reply.set(key: RuntimeKeys.exitCode.rawValue, value: Int64(exitStatus.exitCode)) + reply.set(key: RuntimeKeys.exitedAt.rawValue, value: exitStatus.exitedAt) + return reply + } + + /// Copy a file or directory from the host into the container. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - sourcePath: The host path to copy from. + /// - destinationPath: The container path to copy to. + /// - fileMode: The file permissions mode (UInt64). + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func copyIn(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`copyIn` xpc handler") + switch self.state { + case .running, .booted: + guard let source = message.string(key: RuntimeKeys.sourcePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no source path supplied for copyIn" + ) + } + guard let destination = message.string(key: RuntimeKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for copyIn" + ) + } + let mode = UInt32(message.uint64(key: RuntimeKeys.fileMode.rawValue)) + let createParents = message.bool(key: RuntimeKeys.createParents.rawValue) + + let ctr = try getContainer() + try await ctr.container.copyIn( + from: URL(fileURLWithPath: source), + to: URL(fileURLWithPath: destination), + mode: mode, + createParents: createParents + ) + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot copyIn: container is not running" + ) + } + } + + /// Copy a file or directory from the container to the host. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - sourcePath: The container path to copy from. + /// - destinationPath: The host path to copy to. + /// + /// - Returns: An XPC message with no parameters. + @Sendable + public func copyOut(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`copyOut` xpc handler") + switch self.state { + case .running, .booted: + guard let source = message.string(key: RuntimeKeys.sourcePath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no source path supplied for copyOut" + ) + } + guard let destination = message.string(key: RuntimeKeys.destinationPath.rawValue) else { + throw ContainerizationError( + .invalidArgument, + message: "no destination path supplied for copyOut" + ) + } + + let createParents = message.bool(key: RuntimeKeys.createParents.rawValue) + + let ctr = try getContainer() + try await ctr.container.copyOut( + from: URL(fileURLWithPath: source), + to: URL(fileURLWithPath: destination), + createParents: createParents + ) + + return message.reply() + default: + throw ContainerizationError( + .invalidState, + message: "cannot copyOut: container is not running" + ) + } + } + + /// Dial a vsock port on the virtual machine. + /// + /// - Parameters: + /// - message: An XPC message with the following parameters: + /// - port: The port number. + /// + /// - Returns: An XPC message with the following parameters: + /// - fd: The file descriptor for the vsock. + @Sendable + public func dial(_ message: XPCMessage) async throws -> XPCMessage { + self.log.debug("enter", metadata: ["func": "\(#function)"]) + defer { self.log.debug("exit", metadata: ["func": "\(#function)"]) } + + switch self.state { + case .running, .booted: + let port = message.uint64(key: RuntimeKeys.port.rawValue) + guard port > 0 else { + throw ContainerizationError( + .invalidArgument, + message: "no vsock port supplied for dial" + ) + } + + let ctr = try getContainer() + let fh = try await ctr.container.dialVsock(port: UInt32(port)) + + let reply = message.reply() + reply.set(key: RuntimeKeys.fd.rawValue, value: fh) + return reply + default: + throw ContainerizationError( + .invalidState, + message: "cannot dial: container is not running" + ) + } + } + + private func startInitProcess(lock: AsyncLock.Context) async throws { + let info = try self.getContainer() + let container = info.container + let id = container.id + + guard self.state == .booted else { + throw ContainerizationError( + .invalidState, + message: "container expected to be in booted state, got: \(self.state)" + ) + } + + do { + let io = info.io + + try await container.start() + let waitFunc: ExitMonitor.WaitHandler = { + let code = try await container.wait() + if let out = io.out { + try out.close() + } + if let err = io.err { + try err.close() + } + return code + } + try await self.monitor.track(id: id, waitingOn: waitFunc) + } catch { + try? await self.cleanUpContainer(containerInfo: info) + self.setState(.stopped) + throw error + } + } + + private func startExecProcess(processId id: String, lock: AsyncLock.Context) async throws { + let container = try self.getContainer().container + guard let processInfo = self.processes[id] else { + throw ContainerizationError(.notFound, message: "process with id \(id)") + } + + let containerInfo = try self.getContainer() + let czConfig = try self.configureProcessConfig( + config: processInfo.config, + stdio: processInfo.io, + containerConfig: containerInfo.config, + ) + + let process = try await container.exec(id, configuration: czConfig) + try self.setUnderlyingProcess(id, process) + + try await process.start() + + let waitFunc: ExitMonitor.WaitHandler = { + let code = try await process.wait() + if let out = processInfo.io[1] { + try self.closeHandle(out.fileDescriptor) + } + if let err = processInfo.io[2] { + try self.closeHandle(err.fileDescriptor) + } + return code + } + try await self.monitor.track(id: id, waitingOn: waitFunc) + } + + private func startSocketForwarders(attachment: Attachment, publishedPorts: [PublishPort]) async throws { + guard !publishedPorts.isEmpty else { + return + } + LocalNetworkPrivacy.triggerLocalNetworkPrivacyAlert() + + var forwarders: [SocketForwarderResult] = [] + guard !publishedPorts.hasOverlaps() else { + throw ContainerizationError(.invalidArgument, message: "host ports for different publish port specs may not overlap") + } + + try await withThrowingTaskGroup(of: SocketForwarderResult.self) { group in + for publishedPort in publishedPorts { + for index in 0.. [String] { + for attachment in attachments { + return [attachment.ipv4Gateway.description] + } + return [] + } + + private static func configureInitialProcess( + czConfig: inout LinuxContainer.Configuration, + config: ContainerConfiguration, + ) throws { + let process = config.initProcess + + czConfig.process.arguments = [process.executable] + process.arguments + czConfig.process.environmentVariables = process.environment + + if config.ssh { + if !czConfig.process.environmentVariables.contains(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) { + czConfig.process.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") + } + } + + czConfig.process.terminal = process.terminal + czConfig.process.workingDirectory = process.workingDirectory + try czConfig.process.rlimits = process.rlimits.map { + LinuxRLimit( + kind: try LinuxRLimit.Kind($0.limit), + hard: $0.hard, + soft: $0.soft + ) + } + czConfig.process.capabilities = try Self.effectiveCapabilities( + capAdd: config.capAdd, + capDrop: config.capDrop + ) + switch process.user { + case .raw(let name): + czConfig.process.user = .init( + uid: 0, + gid: 0, + umask: nil, + additionalGids: process.supplementalGroups, + username: name + ) + case .id(let uid, let gid): + czConfig.process.user = .init( + uid: uid, + gid: gid, + umask: nil, + additionalGids: process.supplementalGroups, + username: "" + ) + } + } + + private nonisolated func configureProcessConfig(config: ProcessConfiguration, stdio: [FileHandle?], containerConfig: ContainerConfiguration) + throws -> LinuxProcessConfiguration + { + var proc = LinuxProcessConfiguration() + proc.stdin = stdio[0] + proc.stdout = stdio[1] + proc.stderr = stdio[2] + + proc.arguments = [config.executable] + config.arguments + proc.environmentVariables = config.environment + + if containerConfig.ssh { + if !proc.environmentVariables.contains(where: { $0.starts(with: "\(Self.sshAuthSocketEnvVar)=") }) { + proc.environmentVariables.append("\(Self.sshAuthSocketEnvVar)=\(Self.sshAuthSocketGuestPath)") + } + } + + proc.terminal = config.terminal + proc.workingDirectory = config.workingDirectory + try proc.rlimits = config.rlimits.map { + LinuxRLimit( + kind: try LinuxRLimit.Kind($0.limit), + hard: $0.hard, + soft: $0.soft + ) + } + proc.capabilities = try Self.effectiveCapabilities( + capAdd: containerConfig.capAdd, + capDrop: containerConfig.capDrop + ) + switch config.user { + case .raw(let name): + proc.user = .init( + uid: 0, + gid: 0, + umask: nil, + additionalGids: config.supplementalGroups, + username: name + ) + case .id(let uid, let gid): + proc.user = .init( + uid: uid, + gid: gid, + umask: nil, + additionalGids: config.supplementalGroups, + username: "" + ) + } + + return proc + } + + /// Compute effective Linux capabilities from the OCI default set, capAdd, and capDrop. + /// Steps are processed in order, so later steps override earlier ones: + /// 1. If "ALL" in capDrop, start empty; otherwise start from OCI defaults. + /// 2. If "ALL" in capAdd, replace with all caps (overriding step 1); otherwise add individual caps. + /// 3. Remove individual capDrop entries (skipping "ALL" sentinel). + private static func effectiveCapabilities(capAdd: [String], capDrop: [String]) throws -> Containerization.LinuxCapabilities { + // Step 1: Determine base set + var caps: Set + if capDrop.contains("ALL") { + caps = [] + } else { + caps = Set(Containerization.LinuxCapabilities.defaultOCICapabilities.effective) + } + + // Step 2: Process adds + if capAdd.contains("ALL") { + caps = Set(CapabilityName.allCases) + } else { + for name in capAdd { + caps.insert(try CapabilityName(rawValue: name)) + } + } + + // Step 3: Remove individual drops (skip "ALL" sentinel) + for name in capDrop where name != "ALL" { + caps.remove(try CapabilityName(rawValue: name)) + } + + return Containerization.LinuxCapabilities(capabilities: Array(caps)) + } + + private nonisolated func closeHandle(_ handle: Int32) throws { + guard close(handle) == 0 else { + guard let errCode = POSIXErrorCode(rawValue: errno) else { + fatalError("failed to convert errno to POSIXErrorCode") + } + throw POSIXError(errCode) + } + } + + private func getContainer() throws -> ContainerInfo { + guard let container else { + throw ContainerizationError( + .invalidState, + message: "no container found" + ) + } + return container + } + + private func gracefulStopContainer(_ lc: LinuxContainer, signal: Signal, timeout: Duration) async throws -> ExitStatus { + // Try and gracefully shut down the process. Even if this succeeds we need to power off + // the vm, but we should try this first always. + var code = ExitStatus(exitCode: 255) + do { + code = try await withThrowingTaskGroup(of: ExitStatus.self) { group in + group.addTask { + try await lc.wait() + } + group.addTask { + try await lc.kill(signal) + try await Task.sleep(for: timeout) + try await lc.kill(.kill) + + return ExitStatus(exitCode: 137) + } + guard let code = try await group.next() else { + throw ContainerizationError( + .internalError, + message: "failed to get exit code from gracefully stopping container" + ) + } + group.cancelAll() + + return code + } + } catch { + self.log.error("graceful stop failed; forcing vm shutdown", metadata: ["error": "\(error)"]) + } + + // Now actually bring down the vm. + try await lc.stop() + + return code + } + + private func cleanUpContainer(containerInfo: ContainerInfo, exitStatus: ExitStatus? = nil) async throws { + let container = containerInfo.container + let id = container.id + + do { + try await container.stop() + } catch { + self.log.error("failed to stop container during cleanup", metadata: ["error": "\(error)"]) + } + + await self.stopSocketForwarders() + + for session in networkSessions { session.close() } + networkSessions = [] + + let status = exitStatus ?? ExitStatus(exitCode: 255) + self.releaseWaiters(for: id, status: status) + } +} + +extension XPCMessage { + fileprivate func signal() throws -> String { + guard let signal = self.string(key: RuntimeKeys.signal.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "missing signal in xpc message") + } + return signal + } + + fileprivate func stopOptions() throws -> ContainerStopOptions { + guard let data = self.dataNoCopy(key: RuntimeKeys.stopOptions.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "empty StopOptions") + } + return try JSONDecoder().decode(ContainerStopOptions.self, from: data) + } + + fileprivate func setState(_ state: SandboxSnapshot) throws { + let data = try JSONEncoder().encode(state) + self.set(key: RuntimeKeys.snapshot.rawValue, value: data) + } + + fileprivate func stdio() -> [FileHandle?] { + var handles = [FileHandle?](repeating: nil, count: 3) + if let stdin = self.fileHandle(key: RuntimeKeys.stdin.rawValue) { + handles[0] = stdin + } + if let stdout = self.fileHandle(key: RuntimeKeys.stdout.rawValue) { + handles[1] = stdout + } + if let stderr = self.fileHandle(key: RuntimeKeys.stderr.rawValue) { + handles[2] = stderr + } + return handles + } + + fileprivate func setFileHandle(_ handle: FileHandle) { + self.set(key: RuntimeKeys.fd.rawValue, value: handle) + } + + fileprivate func processConfig() throws -> ProcessConfiguration { + guard let data = self.dataNoCopy(key: RuntimeKeys.processConfig.rawValue) else { + throw ContainerizationError(.invalidArgument, message: "empty process configuration") + } + return try JSONDecoder().decode(ProcessConfiguration.self, from: data) + } + + fileprivate func dynamicEnv() throws -> [String: String] { + let data = self.dataNoCopy(key: RuntimeKeys.dynamicEnv.rawValue) + let dynamicEnv = try data.map { try JSONDecoder().decode([String: String].self, from: $0) } ?? [:] + return dynamicEnv + } + +} + +extension ContainerResource.Bundle { + func createLogFile() throws { + // Create the log file we'll write stdio to. + // O_TRUNC resolves a log delay issue on restarted containers by force-updating internal state + let fd = Darwin.open(self.containerLog.path, O_CREAT | O_RDONLY | O_TRUNC, 0o644) + guard fd > 0 else { + throw POSIXError(.init(rawValue: errno)!) + } + close(fd) + } +} + +extension Filesystem { + var asMount: Containerization.Mount { + switch self.type { + case .tmpfs: + return .any( + type: "tmpfs", + source: self.source, + destination: self.destination, + options: self.options + ) + case .virtiofs: + return .share( + source: self.source, + destination: self.destination, + options: self.options + ) + case .block(let format, let cacheMode, let syncMode): + return .block( + format: format, + source: self.source, + destination: self.destination, + options: self.options, + runtimeOptions: [ + "\(Filesystem.CacheMode.vzRuntimeOptionKey)=\(cacheMode.asVZRuntimeOption)", + "\(Filesystem.SyncMode.vzRuntimeOptionKey)=\(syncMode.asVZRuntimeOption)", + ], + ) + case .volume(_, let format, let cacheMode, let syncMode): + return .block( + format: format, + source: self.source, + destination: self.destination, + options: self.options, + runtimeOptions: [ + "\(Filesystem.CacheMode.vzRuntimeOptionKey)=\(cacheMode.asVZRuntimeOption)", + "\(Filesystem.SyncMode.vzRuntimeOptionKey)=\(syncMode.asVZRuntimeOption)", + ], + ) + } + } + + func isSocket() throws -> Bool { + if !self.isVirtiofs { + return false + } + let info = try File.info(self.source) + return info.isSocket + } +} + +extension Filesystem.CacheMode { + static let vzRuntimeOptionKey = "vzDiskImageCachingMode" + + var asVZRuntimeOption: String { + switch self { + case .on: "cached" + case .off: "uncached" + case .auto: "automatic" + } + } +} + +extension Filesystem.SyncMode { + static let vzRuntimeOptionKey = "vzDiskImageSynchronizationMode" + + var asVZRuntimeOption: String { + switch self { + case .full: "full" + case .fsync: "fsync" + case .nosync: "none" + } + } +} + +struct MultiWriter: Writer { + let handles: [FileHandle] + + init(handles: [FileHandle]) { + self.handles = handles + } + + func close() throws { + for handle in handles { + try handle.close() + } + } + + func write(_ data: Data) throws { + for handle in handles { + try handle.write(contentsOf: data) + } + } +} + +extension FileHandle: @retroactive ReaderStream, @retroactive Writer { + public func write(_ data: Data) throws { + try self.write(contentsOf: data) + } + + public func stream() -> AsyncStream { + .init { cont in + self.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + self.readabilityHandler = nil + cont.finish() + return + } + cont.yield(data) + } + } + } +} + +// MARK: State handler and bundle creation helpers + +extension RuntimeService { + private func initializeWaiters(for id: String) throws { + guard waiters[id] == nil else { + throw ContainerizationError(.invalidState, message: "waiter for \(id) already initialized") + } + waiters[id] = ExitWaiter() + } + + private func waitForExit(id: String, cont: CheckedContinuation) { + guard let waiter = waiters[id] else { + // No waiter was initialized at all, resume immediately + cont.resume(returning: ExitStatus(exitCode: -1)) + return + } + + waiter.wait(cont) + } + + private func releaseWaiters(for id: String, status: ExitStatus) { + waiters[id]?.doExit(exitStatus: status) + } + + private func setUnderlyingProcess(_ id: String, _ process: LinuxProcess) throws { + guard var info = self.processes[id] else { + throw ContainerizationError(.invalidState, message: "process \(id) not found") + } + info.process = process + self.processes[id] = info + } + + private func setProcessState(id: String, state: State) throws { + guard var info = self.processes[id] else { + throw ContainerizationError(.invalidState, message: "process \(id) not found") + } + info.state = state + self.processes[id] = info + } + + private func setContainer(_ info: ContainerInfo) { + self.container = info + } + + private func setNetworkSessions(_ sessions: [XPCClientSession]) { + self.networkSessions = sessions + } + + private func addNewProcess(_ id: String, _ config: ProcessConfiguration, _ io: [FileHandle?]) throws { + guard self.processes[id] == nil else { + throw ContainerizationError(.invalidArgument, message: "process \(id) already exists") + } + self.processes[id] = ProcessInfo(config: config, process: nil, state: .created, io: io) + } + + private struct ProcessInfo { + let config: ProcessConfiguration + var process: LinuxProcess? + var state: State + let io: [FileHandle?] + } + + private struct ContainerInfo { + let container: LinuxContainer + let config: ContainerConfiguration + let attachments: [Attachment] + let bundle: ContainerResource.Bundle + let io: (in: FileHandle?, out: MultiWriter?, err: MultiWriter?) + } + + /// States the underlying sandbox can be in. + public enum State: Sendable, Equatable { + /// Sandbox is created. This should be what the service starts the sandbox in. + case created + /// Bootstrap will transition a .created state to .booted. + case booted + /// startProcess on the init process will transition .booted to .running. + case running + /// At the beginning of stop() .running will be transitioned to .stopping. + case stopping + /// Once a stop is successful, .stopping will transition to .stopped. + case stopped + /// .shuttingDown will be the last state the runtime service will ever be in. Shortly + /// afterwards the process will exit. + case shuttingDown + } + + func setState(_ new: State) { + self.state = new + } + + /// Check if a bundle exists at the given path + private func bundleExists(at path: URL) -> Bool { + guard FileManager.default.fileExists(atPath: path.path) else { + return false + } + + let bundle = ContainerResource.Bundle(path: path) + do { + _ = try bundle.configuration + return true + } catch { + return false + } + } + + /// Create bundle from RuntimeConfiguration + private func createBundle() throws { + do { + let runtimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: self.root) + _ = try ContainerResource.Bundle.create( + path: runtimeConfig.path, + initialFilesystem: runtimeConfig.initialFilesystem, + kernel: runtimeConfig.kernel, + containerConfiguration: runtimeConfig.containerConfiguration, + containerRootFilesystem: runtimeConfig.containerRootFilesystem, + options: runtimeConfig.options + ) + self.log.info("created bundle", metadata: ["configPath": "\(runtimeConfig.path)"]) + } catch { + self.log.error("failed to create bundle", metadata: ["error": "\(error)"]) + throw error + } + } +} diff --git a/Sources/SocketForwarder/ConnectHandler.swift b/Sources/SocketForwarder/ConnectHandler.swift new file mode 100644 index 0000000..5f98e80 --- /dev/null +++ b/Sources/SocketForwarder/ConnectHandler.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// 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 Logging +import NIOCore +import NIOPosix + +final class ConnectHandler { + private var pendingBytes: [NIOAny] + private let serverAddress: SocketAddress + private var log: Logger? = nil + + init(serverAddress: SocketAddress, log: Logger?) { + self.pendingBytes = [] + self.serverAddress = serverAddress + self.log = log + } +} + +extension ConnectHandler: ChannelInboundHandler { + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + self.pendingBytes.append(data) + } + + func handlerAdded(context: ChannelHandlerContext) { + // Add logger metadata. + self.log?[metadataKey: "proxy"] = "\(context.channel.localAddress?.description ?? "none")" + self.log?[metadataKey: "server"] = "\(context.channel.remoteAddress?.description ?? "none")" + } + + func channelActive(context: ChannelHandlerContext) { + self.log?.trace("frontend - channel active, connecting to backend") + self.connectToServer(context: context) + context.fireChannelActive() + } +} + +extension ConnectHandler: RemovableChannelHandler { + func removeHandler(context: ChannelHandlerContext, removalToken: ChannelHandlerContext.RemovalToken) { + var didRead = false + + // We are being removed, and need to deliver any pending bytes we may have if we're upgrading. + while self.pendingBytes.count > 0 { + let data = self.pendingBytes.removeFirst() + context.fireChannelRead(data) + didRead = true + } + + if didRead { + context.fireChannelReadComplete() + } + + self.log?.trace("backend - removing connect handler from pipeline") + context.leavePipeline(removalToken: removalToken) + } +} + +extension ConnectHandler { + private func connectToServer(context: ChannelHandlerContext) { + self.log?.trace("backend - connecting") + + ClientBootstrap(group: context.eventLoop) + .connect(to: serverAddress) + .assumeIsolatedUnsafeUnchecked() + .whenComplete { result in + switch result { + case .success(let channel): + guard context.channel.isActive else { + self.log?.trace("backend - frontend channel closed, closing backend connection") + context.channel.close(promise: nil) + return + } + self.log?.trace("backend - connected") + self.glue(channel, context: context) + case .failure(let error): + self.log?.error("backend - connect failed: \(error)") + context.close(promise: nil) + context.fireErrorCaught(error) + } + } + } + + private func glue(_ peerChannel: Channel, context: ChannelHandlerContext) { + self.log?.trace("backend - gluing channels") + + // Now we need to glue our channel and the peer channel together. + let (localGlue, peerGlue) = GlueHandler.matchedPair() + do { + try context.channel.pipeline.syncOperations.addHandler(localGlue) + try peerChannel.pipeline.syncOperations.addHandler(peerGlue) + context.pipeline.syncOperations.removeHandler(self, promise: nil) + } catch { + // Close connected peer channel before closing our channel. + peerChannel.close(mode: .all, promise: nil) + context.close(promise: nil) + } + } +} diff --git a/Sources/SocketForwarder/GlueHandler.swift b/Sources/SocketForwarder/GlueHandler.swift new file mode 100644 index 0000000..c24d7bc --- /dev/null +++ b/Sources/SocketForwarder/GlueHandler.swift @@ -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 NIOCore + +final class GlueHandler { + + private var partner: GlueHandler? + + private var context: ChannelHandlerContext? + + private var pendingRead: Bool = false + + private init() {} +} + +extension GlueHandler { + static func matchedPair() -> (GlueHandler, GlueHandler) { + let first = GlueHandler() + let second = GlueHandler() + + first.partner = second + second.partner = first + + return (first, second) + } +} + +extension GlueHandler { + private func partnerWrite(_ data: NIOAny) { + self.context?.write(data, promise: nil) + } + + private func partnerFlush() { + self.context?.flush() + } + + private func partnerWriteEOF() { + self.context?.close(mode: .output, promise: nil) + } + + private func partnerCloseFull() { + self.context?.close(promise: nil) + } + + private func partnerBecameWritable() { + if self.pendingRead { + self.pendingRead = false + self.context?.read() + } + } + + private var partnerWritable: Bool { + self.context?.channel.isWritable ?? false + } +} + +extension GlueHandler: ChannelDuplexHandler { + typealias InboundIn = NIOAny + typealias OutboundIn = NIOAny + typealias OutboundOut = NIOAny + + func handlerAdded(context: ChannelHandlerContext) { + self.context = context + } + + func handlerRemoved(context: ChannelHandlerContext) { + self.context = nil + self.partner = nil + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + self.partner?.partnerWrite(data) + } + + func channelReadComplete(context: ChannelHandlerContext) { + self.partner?.partnerFlush() + } + + func channelInactive(context: ChannelHandlerContext) { + self.partner?.partnerCloseFull() + } + + func userInboundEventTriggered(context: ChannelHandlerContext, event: Any) { + if let event = event as? ChannelEvent, case .inputClosed = event { + // We have read EOF. + self.partner?.partnerWriteEOF() + } + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + self.partner?.partnerCloseFull() + } + + func channelWritabilityChanged(context: ChannelHandlerContext) { + if context.channel.isWritable { + self.partner?.partnerBecameWritable() + } + } + + func read(context: ChannelHandlerContext) { + if let partner = self.partner, partner.partnerWritable { + context.read() + } else { + self.pendingRead = true + } + } +} diff --git a/Sources/SocketForwarder/LRUCache.swift b/Sources/SocketForwarder/LRUCache.swift new file mode 100644 index 0000000..82cb9f6 --- /dev/null +++ b/Sources/SocketForwarder/LRUCache.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +struct KeyExistsError: Error {} + +class LRUCache { + private class Node { + fileprivate var prev: Node? + fileprivate var next: Node? + fileprivate let key: K + fileprivate let value: V + + init(key: K, value: V) { + self.prev = nil + self.next = nil + self.key = key + self.value = value + } + } + + private let size: UInt + private var head: Node? + private var tail: Node? + private var members: [K: Node] + + init(size: UInt) { + self.size = size + self.head = nil + self.tail = nil + self.members = [:] + } + + var count: Int { members.count } + + func get(_ key: K) -> V? { + guard let node = members[key] else { + return nil + } + listRemove(node: node) + listInsert(node: node, after: tail) + return node.value + } + + func put(key: K, value: V) -> (K, V)? { + let node = Node(key: key, value: value) + var evicted: (K, V)? = nil + + if let existingNode = members[key] { + // evict the replaced node + listRemove(node: existingNode) + evicted = (existingNode.key, existingNode.value) + } else if self.count >= self.size { + // evict the least recently used node + evicted = evict() + } + + // insert the new node and return any evicted node + members[key] = node + listInsert(node: node, after: tail) + return evicted + } + + private func evict() -> (K, V)? { + guard let head else { + return nil + } + let ret = (head.key, head.value) + listRemove(node: head) + members.removeValue(forKey: head.key) + return ret + } + + private func listRemove(node: Node) { + if let prev = node.prev { + prev.next = node.next + } else { + head = node.next + } + if let next = node.next { + next.prev = node.prev + } else { + tail = node.prev + } + } + + private func listInsert(node: Node, after: Node?) { + let before: Node? + if let after { + before = after.next + after.next = node + } else { + before = head + head = node + } + + if let before { + before.prev = node + } else { + tail = node + } + + node.prev = after + node.next = before + } +} diff --git a/Sources/SocketForwarder/SocketForwarder.swift b/Sources/SocketForwarder/SocketForwarder.swift new file mode 100644 index 0000000..ac67497 --- /dev/null +++ b/Sources/SocketForwarder/SocketForwarder.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// 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 NIO + +public protocol SocketForwarder: Sendable { + func run() throws -> EventLoopFuture +} diff --git a/Sources/SocketForwarder/SocketForwarderResult.swift b/Sources/SocketForwarder/SocketForwarderResult.swift new file mode 100644 index 0000000..c28b54d --- /dev/null +++ b/Sources/SocketForwarder/SocketForwarderResult.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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 NIO + +public struct SocketForwarderResult: Sendable { + private let channel: any Channel + + public init(channel: Channel) { + self.channel = channel + } + + public var proxyAddress: SocketAddress? { self.channel.localAddress } + + public func close() { + self.channel.eventLoop.execute { + _ = channel.close() + } + } + + public func wait() async throws { + try await self.channel.closeFuture.get() + } +} diff --git a/Sources/SocketForwarder/TCPForwarder.swift b/Sources/SocketForwarder/TCPForwarder.swift new file mode 100644 index 0000000..e510336 --- /dev/null +++ b/Sources/SocketForwarder/TCPForwarder.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// 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 +import NIO +import NIOFoundationCompat + +public struct TCPForwarder: SocketForwarder { + private let proxyAddress: SocketAddress + + private let serverAddress: SocketAddress + + private let eventLoopGroup: any EventLoopGroup + + private let log: Logger? + + public init( + proxyAddress: SocketAddress, + serverAddress: SocketAddress, + eventLoopGroup: any EventLoopGroup, + log: Logger? = nil + ) throws { + self.proxyAddress = proxyAddress + self.serverAddress = serverAddress + self.eventLoopGroup = eventLoopGroup + self.log = log + } + + public func run() throws -> EventLoopFuture { + self.log?.trace("frontend - creating listener") + + let bootstrap = ServerBootstrap(group: self.eventLoopGroup) + .serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + ConnectHandler(serverAddress: self.serverAddress, log: log) + ) + } + } + + return + bootstrap + .bind(to: self.proxyAddress) + .map { SocketForwarderResult(channel: $0) } + } +} diff --git a/Sources/SocketForwarder/UDPForwarder.swift b/Sources/SocketForwarder/UDPForwarder.swift new file mode 100644 index 0000000..54d472e --- /dev/null +++ b/Sources/SocketForwarder/UDPForwarder.swift @@ -0,0 +1,205 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Logging +import NIO +import NIOFoundationCompat +import Synchronization + +// Proxy backend for a single client address (clientIP, clientPort). +private final class UDPProxyBackend: ChannelInboundHandler { + typealias InboundIn = AddressedEnvelope + typealias OutboundOut = AddressedEnvelope + + private struct State { + var queuedPayloads: Deque + var channel: (any Channel)? + } + + private let clientAddress: SocketAddress + private let serverAddress: SocketAddress + private let frontendChannel: any Channel + private let log: Logger? + private var state: State + + init(clientAddress: SocketAddress, serverAddress: SocketAddress, frontendChannel: any Channel, log: Logger? = nil) { + self.clientAddress = clientAddress + self.serverAddress = serverAddress + self.frontendChannel = frontendChannel + self.log = log + let initialState = State(queuedPayloads: Deque(), channel: nil) + self.state = initialState + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + // relay data from server to client. + let inbound = self.unwrapInboundIn(data) + let outbound = OutboundOut(remoteAddress: self.clientAddress, data: inbound.data) + self.log?.trace("backend - writing datagram to client") + self.frontendChannel.writeAndFlush(outbound, promise: nil) + } + + func channelActive(context: ChannelHandlerContext) { + if !state.queuedPayloads.isEmpty { + self.log?.trace("backend - writing \(state.queuedPayloads.count) queued datagrams to server") + while let queuedData = state.queuedPayloads.popFirst() { + let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: queuedData) + context.channel.writeAndFlush(outbound, promise: nil) + } + } + state.channel = context.channel + } + + func write(data: ByteBuffer) { + // change package remote address from proxy server to real server + if let channel = state.channel { + // channel has been initialized, so relay any queued packets, along with this one to outbound + self.log?.trace("backend - writing datagram to server") + let outbound: UDPProxyBackend.OutboundOut = OutboundOut(remoteAddress: self.serverAddress, data: data) + channel.writeAndFlush(outbound, promise: nil) + } else { + // channel is initializing, queue + self.log?.trace("backend - queuing datagram") + state.queuedPayloads.append(data) + } + } + + func close() { + guard let channel = state.channel else { + self.log?.warning("backend - close on inactive channel") + return + } + _ = channel.close() + } +} + +private struct ProxyContext { + public let proxy: UDPProxyBackend + public let closeFuture: EventLoopFuture +} + +private final class UDPProxyFrontend: ChannelInboundHandler { + typealias InboundIn = AddressedEnvelope + typealias OutboundOut = AddressedEnvelope + private let maxProxies = UInt(256) + + private let proxyAddress: SocketAddress + private let serverAddress: SocketAddress + private let log: Logger? + + private var proxies: LRUCache + + init(proxyAddress: SocketAddress, serverAddress: SocketAddress, log: Logger? = nil) { + self.proxyAddress = proxyAddress + self.serverAddress = serverAddress + self.proxies = LRUCache(size: maxProxies) + self.log = log + } + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + let inbound = self.unwrapInboundIn(data) + + guard let clientIP = inbound.remoteAddress.ipAddress else { + log?.error("frontend - no client IP address in inbound payload") + return + } + + guard let clientPort = inbound.remoteAddress.port else { + log?.error("frontend - no client port in inbound payload") + return + } + + let key = "\(clientIP):\(clientPort)" + do { + if let context = proxies.get(key) { + context.proxy.write(data: inbound.data) + } else { + self.log?.trace("frontend - creating backend") + let proxy = UDPProxyBackend( + clientAddress: inbound.remoteAddress, + serverAddress: self.serverAddress, + frontendChannel: context.channel, + log: log + ) + let proxyAddress = try SocketAddress(ipAddress: "0.0.0.0", port: 0) + let loopBoundProxy = NIOLoopBound(proxy, eventLoop: context.eventLoop) + let proxyToServerFuture = DatagramBootstrap(group: context.eventLoop) + .channelInitializer { [log] channel in + log?.trace("frontend - initializing backend") + return channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler(loopBoundProxy.value) + } + } + .bind(to: proxyAddress) + .flatMap { $0.closeFuture } + let context = ProxyContext(proxy: proxy, closeFuture: proxyToServerFuture) + if let (_, evictedContext) = proxies.put(key: key, value: context) { + self.log?.trace("frontend - closing evicted backend") + evictedContext.proxy.close() + } + + proxy.write(data: inbound.data) + } + } catch { + log?.error("server handler - backend channel creation failed with error: \(error)") + return + } + } +} + +public struct UDPForwarder: SocketForwarder { + private let proxyAddress: SocketAddress + + private let serverAddress: SocketAddress + + private let eventLoopGroup: any EventLoopGroup + + private let log: Logger? + + public init( + proxyAddress: SocketAddress, + serverAddress: SocketAddress, + eventLoopGroup: any EventLoopGroup, + log: Logger? = nil + ) throws { + self.proxyAddress = proxyAddress + self.serverAddress = serverAddress + self.eventLoopGroup = eventLoopGroup + self.log = log + } + + public func run() throws -> EventLoopFuture { + self.log?.trace("frontend - creating channel") + let bootstrap = DatagramBootstrap(group: self.eventLoopGroup) + .channelInitializer { serverChannel in + self.log?.trace("frontend - initializing channel") + let proxyToServerHandler = UDPProxyFrontend( + proxyAddress: proxyAddress, + serverAddress: serverAddress, + log: log + ) + return serverChannel.eventLoop.makeCompletedFuture { + try serverChannel.pipeline.syncOperations.addHandler(proxyToServerHandler) + } + } + return + bootstrap + .bind(to: proxyAddress) + .map { SocketForwarderResult(channel: $0) } + } +} diff --git a/Sources/TerminalProgress/Int+Formatted.swift b/Sources/TerminalProgress/Int+Formatted.swift new file mode 100644 index 0000000..f320b3a --- /dev/null +++ b/Sources/TerminalProgress/Int+Formatted.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// 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 Int { + func formattedTime() -> String { + let secondsInMinute = 60 + let secondsInHour = secondsInMinute * 60 + let secondsInDay = secondsInHour * 24 + + let days = self / secondsInDay + let hours = (self % secondsInDay) / secondsInHour + let minutes = (self % secondsInHour) / secondsInMinute + let seconds = self % secondsInMinute + + var components = [String]() + if days > 0 { + components.append("\(days)d") + } + if hours > 0 || days > 0 { + components.append("\(hours)h") + } + if minutes > 0 || hours > 0 || days > 0 { + components.append("\(minutes)m") + } + components.append("\(seconds)s") + return components.joined(separator: " ") + } + + func formattedNumber() -> String { + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + guard let formattedNumber = formatter.string(from: NSNumber(value: self)) else { + return "" + } + return formattedNumber + } +} diff --git a/Sources/TerminalProgress/Int64+Formatted.swift b/Sources/TerminalProgress/Int64+Formatted.swift new file mode 100644 index 0000000..4abc7a9 --- /dev/null +++ b/Sources/TerminalProgress/Int64+Formatted.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// 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 Int64 { + func formattedSize() -> String { + let formattedSize = ByteCountFormatter.string(fromByteCount: self, countStyle: .binary) + return formattedSize + } + + func formattedSizeSpeed(from startTime: DispatchTime) -> String { + let elapsedTimeNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds + let elapsedTimeSeconds = Double(elapsedTimeNanoseconds) / 1_000_000_000 + guard elapsedTimeSeconds > 0 else { + return "0 B/s" + } + + let speed = Double(self) / elapsedTimeSeconds + let formattedSpeed = ByteCountFormatter.string(fromByteCount: Int64(speed), countStyle: .binary) + return "\(formattedSpeed)/s" + } +} diff --git a/Sources/TerminalProgress/ProgressBar+Add.swift b/Sources/TerminalProgress/ProgressBar+Add.swift new file mode 100644 index 0000000..29c299b --- /dev/null +++ b/Sources/TerminalProgress/ProgressBar+Add.swift @@ -0,0 +1,236 @@ +//===----------------------------------------------------------------------===// +// 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 ProgressBar { + /// A handler function to update the progress bar. + /// - Parameter events: The events to handle. + public func handler(_ events: [ProgressUpdateEvent]) { + for event in events { + switch event { + case .setDescription(let description): + set(description: description) + case .setSubDescription(let subDescription): + set(subDescription: subDescription) + case .setItemsName(let itemsName): + set(itemsName: itemsName) + case .addTasks(let tasks): + add(tasks: tasks) + case .setTasks(let tasks): + set(tasks: tasks) + case .addTotalTasks(let totalTasks): + add(totalTasks: totalTasks) + case .setTotalTasks(let totalTasks): + set(totalTasks: totalTasks) + case .addSize(let size): + add(size: size) + case .setSize(let size): + set(size: size) + case .addTotalSize(let totalSize): + add(totalSize: totalSize) + case .setTotalSize(let totalSize): + set(totalSize: totalSize) + case .addItems(let items): + add(items: items) + case .setItems(let items): + set(items: items) + case .addTotalItems(let totalItems): + add(totalItems: totalItems) + case .setTotalItems(let totalItems): + set(totalItems: totalItems) + case .custom: + // Custom events are handled by the client. + break + } + } + } + + /// Performs a check to see if the progress bar should be finished. + public func checkIfFinished() { + let state = self.state.withLock { $0 } + + var finished = true + var defined = false + if let totalTasks = state.totalTasks, totalTasks > 0 { + // For tasks, we're showing the current task rather than the number of completed tasks. + finished = finished && state.tasks == totalTasks + defined = true + } + if let totalItems = state.totalItems, totalItems > 0 { + finished = finished && state.items == totalItems + defined = true + } + if let totalSize = state.totalSize, totalSize > 0 { + finished = finished && state.size == totalSize + defined = true + } + if defined && finished { + finish() + } + } + + /// Sets the current tasks. + /// - Parameter newTasks: The current tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(tasks newTasks: Int, render: Bool = true) { + state.withLock { $0.tasks = newTasks } + if render { + self.render() + } + checkIfFinished() + } + + /// Performs an addition to the current tasks. + /// - Parameter delta: The tasks to add to the current tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(tasks delta: Int, render: Bool = true) { + state.withLock { + let newTasks = $0.tasks + delta + $0.tasks = newTasks + } + if render { + self.render() + } + } + + /// Sets the total tasks. + /// - Parameter newTotalTasks: The total tasks to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalTasks newTotalTasks: Int, render: Bool = true) { + state.withLock { $0.totalTasks = newTotalTasks } + if render { + self.render() + } + } + + /// Performs an addition to the total tasks. + /// - Parameter delta: The tasks to add to the total tasks. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalTasks delta: Int, render: Bool = true) { + state.withLock { + let totalTasks = $0.totalTasks ?? 0 + let newTotalTasks = totalTasks + delta + $0.totalTasks = newTotalTasks + } + if render { + self.render() + } + } + + /// Sets the items name. + /// - Parameter newItemsName: The current items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(itemsName newItemsName: String, render: Bool = true) { + state.withLock { $0.itemsName = newItemsName } + if render { + self.render() + } + } + + /// Sets the current items. + /// - Parameter newItems: The current items to set. + public func set(items newItems: Int, render: Bool = true) { + state.withLock { $0.items = newItems } + if render { + self.render() + } + } + + /// Performs an addition to the current items. + /// - Parameter delta: The items to add to the current items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(items delta: Int, render: Bool = true) { + state.withLock { + let newItems = $0.items + delta + $0.items = newItems + } + if render { + self.render() + } + } + + /// Sets the total items. + /// - Parameter newTotalItems: The total items to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalItems newTotalItems: Int, render: Bool = true) { + state.withLock { $0.totalItems = newTotalItems } + if render { + self.render() + } + } + + /// Performs an addition to the total items. + /// - Parameter delta: The items to add to the total items. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalItems delta: Int, render: Bool = true) { + state.withLock { + let totalItems = $0.totalItems ?? 0 + let newTotalItems = totalItems + delta + $0.totalItems = newTotalItems + } + if render { + self.render() + } + } + + /// Sets the current size. + /// - Parameter newSize: The current size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(size newSize: Int64, render: Bool = true) { + state.withLock { $0.size = newSize } + if render { + self.render() + } + } + + /// Performs an addition to the current size. + /// - Parameter delta: The size to add to the current size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(size delta: Int64, render: Bool = true) { + state.withLock { + let newSize = $0.size + delta + $0.size = newSize + } + if render { + self.render() + } + } + + /// Sets the total size. + /// - Parameter newTotalSize: The total size to set. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func set(totalSize newTotalSize: Int64, render: Bool = true) { + state.withLock { $0.totalSize = newTotalSize } + if render { + self.render() + } + } + + /// Performs an addition to the total size. + /// - Parameter delta: The size to add to the total size. + /// - Parameter render: The flag indicating whether the progress bar has to render after the update. + public func add(totalSize delta: Int64, render: Bool = true) { + state.withLock { + let totalSize = $0.totalSize ?? 0 + let newTotalSize = totalSize + delta + $0.totalSize = newTotalSize + } + if render { + self.render() + } + } +} diff --git a/Sources/TerminalProgress/ProgressBar+State.swift b/Sources/TerminalProgress/ProgressBar+State.swift new file mode 100644 index 0000000..8e42f7c --- /dev/null +++ b/Sources/TerminalProgress/ProgressBar+State.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 ProgressBar { + /// State for the progress bar. + struct State { + /// A flag indicating whether the progress bar is finished. + var finished = false + var iteration = 0 + private let speedInterval: DispatchTimeInterval = .seconds(1) + + var description: String + var subDescription: String + var itemsName: String + + var tasks: Int + var totalTasks: Int? + + var items: Int + var totalItems: Int? + + private var sizeUpdateTime: DispatchTime? + private var sizeUpdateValue: Int64 = 0 + var size: Int64 { + didSet { + calculateSizeSpeed() + } + } + + var totalSize: Int64? + private var sizeUpdateSpeed: String? + var sizeSpeed: String? { + guard sizeUpdateTime == nil || sizeUpdateTime! > .now() - speedInterval - speedInterval else { + return Int64(0).formattedSizeSpeed(from: startTime) + } + return sizeUpdateSpeed + } + var averageSizeSpeed: String { + size.formattedSizeSpeed(from: startTime) + } + + var percent: String { + var value = 0 + if let totalSize, totalSize > 0 { + value = Int(size * 100 / totalSize) + } else if let totalItems, totalItems > 0 { + value = Int(items * 100 / totalItems) + } + value = min(value, 100) + return "\(value)%" + } + + var startTime: DispatchTime + var lastPlainRenderTime: DispatchTime? + var output = "" + var renderTask: Task? + + init( + description: String = "", subDescription: String = "", itemsName: String = "", tasks: Int = 0, totalTasks: Int? = nil, items: Int = 0, totalItems: Int? = nil, + size: Int64 = 0, totalSize: Int64? = nil, startTime: DispatchTime = .now() + ) { + self.description = description + self.subDescription = subDescription + self.itemsName = itemsName + self.tasks = tasks + self.totalTasks = totalTasks + self.items = items + self.totalItems = totalItems + self.size = size + self.totalSize = totalSize + self.startTime = startTime + } + + private mutating func calculateSizeSpeed() { + if sizeUpdateTime == nil || sizeUpdateTime! < .now() - speedInterval { + let partSize = size - sizeUpdateValue + let partStartTime = sizeUpdateTime ?? startTime + let partSizeSpeed = partSize.formattedSizeSpeed(from: partStartTime) + self.sizeUpdateSpeed = partSizeSpeed + + sizeUpdateTime = .now() + sizeUpdateValue = size + } + } + } +} diff --git a/Sources/TerminalProgress/ProgressBar+Terminal.swift b/Sources/TerminalProgress/ProgressBar+Terminal.swift new file mode 100644 index 0000000..0dd2b53 --- /dev/null +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -0,0 +1,127 @@ +//===----------------------------------------------------------------------===// +// 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 + +enum EscapeSequence { + static let hideCursor = "\u{001B}[?25l" + static let showCursor = "\u{001B}[?25h" + static let moveUp = "\u{001B}[1A" + static let clearToEndOfLine = "\u{001B}[K" + + // Color codes + static let reset = "\u{001B}[0m" + static let bold = "\u{001B}[1m" + static let dim = "\u{001B}[2m" + static let green = "\u{001B}[32m" + static let yellow = "\u{001B}[33m" + static let cyan = "\u{001B}[36m" + + /// Wraps text in an ANSI color code with a reset suffix. + static func colored(_ text: String, _ code: String) -> String { + "\(code)\(text)\(reset)" + } +} + +extension ProgressBar { + var termWidth: Int { + guard + let terminalHandle = term, + let terminal = try? Terminal(descriptor: terminalHandle.fileDescriptor) + else { + return 0 + } + + return (try? Int(terminal.size.width)) ?? 0 + } + + /// Clears the progress bar and resets the cursor. + public func clearAndResetCursor() { + state.withLock { s in + clear(state: &s) + switch config.outputMode { + case .ansi, .color: + resetCursor() + case .plain: + break + } + } + } + + /// Clears the progress bar. + public func clear() { + state.withLock { s in + clear(state: &s) + } + } + + /// Clears the progress bar (caller must hold state lock). + func clear(state: inout State) { + displayText("", state: &state) + } + + /// Resets the cursor. + public func resetCursor() { + display(EscapeSequence.showCursor) + } + + func display(_ text: String) { + guard let term else { + return + } + termQueue.sync { + try? term.write(contentsOf: Data(text.utf8)) + try? term.synchronize() + } + } + + func displayText(_ text: String, terminating: String = "\r") { + state.withLock { s in + displayText(text, state: &s, terminating: terminating) + } + } + + func displayText(_ text: String, state: inout State, terminating: String = "\r") { + state.output = text + + switch config.outputMode { + case .plain: + guard !text.isEmpty else { return } + display("\(text)\(terminating)") + case .ansi, .color: + // Clears previously printed lines. + var lines = "" + if terminating.hasSuffix("\r") && termWidth > 0 { + let textLength = config.outputMode == .color ? text.visibleLength : text.count + let lineCount = (textLength - 1) / termWidth + for _ in 0.. + let term: FileHandle? + let termQueue = DispatchQueue(label: "com.apple.container.ProgressBar") + + /// Returns `true` if the progress bar has finished. + public var isFinished: Bool { + state.withLock { $0.finished } + } + + /// Creates a new progress bar. + /// - Parameter config: The configuration for the progress bar. + public init(config: ProgressConfig) { + self.config = config + switch config.outputMode { + case .ansi, .color: + term = isatty(config.terminal.fileDescriptor) == 1 ? config.terminal : nil + case .plain: + term = config.terminal + } + let state = State( + description: config.initialDescription, itemsName: config.initialItemsName, totalTasks: config.initialTotalTasks, + totalItems: config.initialTotalItems, + totalSize: config.initialTotalSize) + self.state = Mutex(state) + switch config.outputMode { + case .ansi, .color: + display(EscapeSequence.hideCursor) + case .plain: + break + } + } + + /// Allows resetting the progress state. + public func reset() { + state.withLock { + $0 = State(description: config.initialDescription) + } + } + + /// Allows resetting the progress state of the current task. + public func resetCurrentTask() { + state.withLock { + $0 = State(description: $0.description, itemsName: $0.itemsName, tasks: $0.tasks, totalTasks: $0.totalTasks, startTime: $0.startTime) + } + } + + /// Updates the description of the progress bar and increments the tasks by one. + /// - Parameter description: The description of the action being performed. + public func set(description: String) { + resetCurrentTask() + + state.withLock { + $0.description = description + $0.subDescription = "" + $0.tasks += 1 + } + } + + /// Updates the additional description of the progress bar. + /// - Parameter subDescription: The additional description of the action being performed. + public func set(subDescription: String) { + resetCurrentTask() + + state.withLock { $0.subDescription = subDescription } + } + + private func start(intervalSeconds: TimeInterval) async { + while true { + let done = state.withLock { s -> Bool in + guard !s.finished else { + return true + } + render(state: &s) + s.iteration += 1 + return false + } + + if done { + return + } + + let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) + guard (try? await Task.sleep(nanoseconds: intervalNanoseconds)) != nil else { + return + } + } + } + + /// Starts an animation of the progress bar. + /// - Parameter intervalSeconds: The time interval between updates in seconds. + public func start(intervalSeconds: TimeInterval = 0.04) { + state.withLock { + if $0.renderTask != nil { + return + } + $0.renderTask = Task(priority: .utility) { + await start(intervalSeconds: intervalSeconds) + } + } + } + + /// Finishes the progress bar. + /// - Parameter clearScreen: If true, clears the progress bar from the screen. + public func finish(clearScreen: Bool = false) { + state.withLock { s in + guard !s.finished else { return } + + s.finished = true + s.renderTask?.cancel() + + let shouldClear = clearScreen || config.clearOnFinish + if !config.disableProgressUpdates && !shouldClear { + let output = draw(state: s) + displayText(output, state: &s, terminating: "\n") + } + + if shouldClear { + clear(state: &s) + } + switch config.outputMode { + case .ansi, .color: + resetCursor() + case .plain: + break + } + } + } +} + +extension ProgressBar { + private func secondsSinceStart(from startTime: DispatchTime) -> Int { + let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - startTime.uptimeNanoseconds + let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000)) + return timeDifferenceSeconds + } + + func render(force: Bool = false) { + guard term != nil && !config.disableProgressUpdates else { + return + } + state.withLock { s in + render(state: &s, force: force) + } + } + + func render(state: inout State, force: Bool = false) { + guard term != nil && !config.disableProgressUpdates else { + return + } + guard force || !state.finished else { + return + } + + if config.outputMode == .plain && !force { + let now = DispatchTime.now() + if let lastRender = state.lastPlainRenderTime { + let elapsed = now.uptimeNanoseconds - lastRender.uptimeNanoseconds + guard elapsed >= 1_000_000_000 else { + return + } + } + state.lastPlainRenderTime = now + } + + let output = draw(state: state) + let terminating = config.outputMode == .plain ? "\n" : "\r" + displayText(output, state: &state, terminating: terminating) + } + + /// Detail levels for progressive truncation. + enum DetailLevel: Int, CaseIterable { + case full = 0 // Everything shown + case noSpeed // Drop speed from parens + case noSize // Drop size from parens + case noParens // Drop parens entirely (items, size, speed) + case noTime // Drop time + case noDescription // Drop description/subdescription + case minimal // Just spinner, tasks, percent + } + + func draw(state: State) -> String { + let width = termWidth + // If no terminal or width unknown, use full detail + guard width > 0 else { + return draw(state: state, detail: .full) + } + + // Add a small buffer to prevent wrapping issues during resize + let bufferChars = 4 + let targetWidth = max(1, width - bufferChars) + + for detail in DetailLevel.allCases { + let output = draw(state: state, detail: detail) + let length = config.outputMode == .color ? output.visibleLength : output.count + if length <= targetWidth { + return output + } + } + + return draw(state: state, detail: .minimal) + } + + func draw(state: State, detail: DetailLevel) -> String { + let useColor = config.outputMode == .color + + /// Wraps text in ANSI color when color mode is active; returns text unchanged otherwise. + func colored(_ text: String, _ code: String) -> String { + useColor ? EscapeSequence.colored(text, code) : text + } + + var components = [String]() + + // Spinner - always shown if configured (unless using progress bar) + if config.showSpinner && !config.showProgressBar { + if !state.finished { + let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) + components.append(colored("\(spinnerIcon)", EscapeSequence.cyan)) + } else { + components.append(colored("\(config.theme.done)", EscapeSequence.green)) + } + } + + // Tasks [x/y] - always shown if configured + if config.showTasks, let totalTasks = state.totalTasks { + let tasks = min(state.tasks, totalTasks) + components.append(colored("[\(tasks)/\(totalTasks)]", EscapeSequence.cyan)) + } + + // Description - dropped at noDescription level + if detail.rawValue < DetailLevel.noDescription.rawValue { + if config.showDescription && !state.description.isEmpty { + components.append(colored("\(state.description)", EscapeSequence.bold)) + if !state.subDescription.isEmpty { + components.append(colored("\(state.subDescription)", EscapeSequence.bold)) + } + } + } + + let allowProgress = !config.ignoreSmallSize || state.totalSize == nil || state.totalSize! > Int64(1024 * 1024) + let value = state.totalSize != nil ? state.size : Int64(state.items) + let total = state.totalSize ?? Int64(state.totalItems ?? 0) + + // Percent - always shown if configured + if config.showPercent && total > 0 && allowProgress { + let percentText = state.finished ? "100%" : state.percent + let percentColor = state.finished ? EscapeSequence.green : EscapeSequence.yellow + components.append(colored(percentText, percentColor)) + } + + // Progress bar - always shown if configured + if config.showProgressBar, total > 0, allowProgress { + // reserve spaces between components, plus 45 for components rendered after the bar (size, speed, time, etc.) + let joinedComponents = components.joined(separator: " ") + let reservedWidth = (useColor ? joinedComponents.visibleLength : joinedComponents.count) + 45 + let totalBarWidth = max(config.width - reservedWidth, 1) + let completedWidth: Int + if state.finished { + completedWidth = totalBarWidth + } else { + let progressWidth = max(0, Int(Int64(totalBarWidth) * value / total)) + completedWidth = min(totalBarWidth, progressWidth) + } + + let uncompletedWidth = totalBarWidth - completedWidth + if useColor { + let filledBar = EscapeSequence.colored(String(repeating: config.theme.bar, count: completedWidth), EscapeSequence.green) + let emptyBar = String(repeating: " ", count: uncompletedWidth) + components.append("|\(filledBar)\(emptyBar)|") + } else { + let bar = "\(String(repeating: config.theme.bar, count: completedWidth))\(String(repeating: " ", count: uncompletedWidth))" + components.append("|\(bar)|") + } + } + + // Additional components in parens - progressively dropped + if detail.rawValue < DetailLevel.noParens.rawValue { + var additionalComponents = [String]() + + // Items - dropped at noParens level + if config.showItems, state.items > 0 { + var itemsName = "" + if !state.itemsName.isEmpty { + itemsName = " \(state.itemsName)" + } + if state.finished { + if let totalItems = state.totalItems { + additionalComponents.append("\(totalItems.formattedNumber())\(itemsName)") + } + } else { + if let totalItems = state.totalItems { + additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + } else { + additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + } + } + } + + // Size and speed - progressively dropped + if state.size > 0 && allowProgress { + if state.finished { + // Size - dropped at noSize level + if detail.rawValue < DetailLevel.noSize.rawValue { + if config.showSize { + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + additionalComponents.append(formattedTotalSize) + } + } + } + } else { + // Size - dropped at noSize level + var formattedCombinedSize = "" + if detail.rawValue < DetailLevel.noSize.rawValue && config.showSize { + var formattedSize = state.size.formattedSize() + formattedSize = adjustFormattedSize(formattedSize) + if let totalSize = state.totalSize { + var formattedTotalSize = totalSize.formattedSize() + formattedTotalSize = adjustFormattedSize(formattedTotalSize) + formattedCombinedSize = combineSize(size: formattedSize, totalSize: formattedTotalSize) + } else { + formattedCombinedSize = formattedSize + } + } + + // Speed - dropped at noSpeed level + var formattedSpeed = "" + if detail.rawValue < DetailLevel.noSpeed.rawValue && config.showSpeed { + formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" + formattedSpeed = adjustFormattedSize(formattedSpeed) + } + + if !formattedCombinedSize.isEmpty && !formattedSpeed.isEmpty { + additionalComponents.append(formattedCombinedSize) + additionalComponents.append(formattedSpeed) + } else if !formattedCombinedSize.isEmpty { + additionalComponents.append(formattedCombinedSize) + } else if !formattedSpeed.isEmpty { + additionalComponents.append(formattedSpeed) + } + } + } + + if additionalComponents.count > 0 { + let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") + components.append(colored("(\(joinedAdditionalComponents))", EscapeSequence.dim)) + } + } + + // Time - dropped at noTime level + if detail.rawValue < DetailLevel.noTime.rawValue && config.showTime { + let timeDifferenceSeconds = secondsSinceStart(from: state.startTime) + let formattedTime = timeDifferenceSeconds.formattedTime() + components.append(colored("[\(formattedTime)]", EscapeSequence.dim)) + } + + return components.joined(separator: " ") + } + + private func adjustFormattedSize(_ size: String) -> String { + // Ensure we always have one digit after the decimal point to prevent flickering. + let zero = Int64(0).formattedSize() + let decimalSep = Locale.current.decimalSeparator ?? "." + guard !size.contains(decimalSep), let first = size.first, first.isNumber || !size.contains(zero) else { + return size + } + var size = size + for unit in ["MB", "GB", "TB"] { + size = size.replacingOccurrences(of: " \(unit)", with: "\(decimalSep)0 \(unit)") + } + return size + } + + private func combineSize(size: String, totalSize: String) -> String { + let sizeComponents = size.split(separator: " ", maxSplits: 1) + let totalSizeComponents = totalSize.split(separator: " ", maxSplits: 1) + guard sizeComponents.count == 2, totalSizeComponents.count == 2 else { + return "\(size)/\(totalSize)" + } + let sizeNumber = sizeComponents[0] + let sizeUnit = sizeComponents[1] + let totalSizeNumber = totalSizeComponents[0] + let totalSizeUnit = totalSizeComponents[1] + guard sizeUnit == totalSizeUnit else { + return "\(size)/\(totalSize)" + } + return "\(sizeNumber)/\(totalSizeNumber) \(totalSizeUnit)" + } + + func draw() -> String { + state.withLock { draw(state: $0) } + } +} diff --git a/Sources/TerminalProgress/ProgressConfig.swift b/Sources/TerminalProgress/ProgressConfig.swift new file mode 100644 index 0000000..53d795b --- /dev/null +++ b/Sources/TerminalProgress/ProgressConfig.swift @@ -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 Foundation + +/// A configuration for displaying a progress bar. +public struct ProgressConfig: Sendable { + /// The file handle for progress updates. + let terminal: FileHandle + /// The initial description of the progress bar. + let initialDescription: String + /// The initial additional description of the progress bar. + let initialSubDescription: String + /// The initial items name (e.g., "files"). + let initialItemsName: String + /// A flag indicating whether to show a spinner (e.g., "⠋"). + /// The spinner is hidden when a progress bar is shown. + public let showSpinner: Bool + /// A flag indicating whether to show tasks and total tasks (e.g., "[1]" or "[1/3]"). + public let showTasks: Bool + /// A flag indicating whether to show the description (e.g., "Downloading..."). + public let showDescription: Bool + /// A flag indicating whether to show a percentage (e.g., "100%"). + /// The percentage is hidden when no total size and total items are set. + public let showPercent: Bool + /// A flag indicating whether to show a progress bar (e.g., "|███ |"). + /// The progress bar is hidden when no total size and total items are set. + public let showProgressBar: Bool + /// A flag indicating whether to show items and total items (e.g., "(22 it)" or "(22/22 it)"). + public let showItems: Bool + /// A flag indicating whether to show a size and a total size (e.g., "(22 MB)" or "(22/22 MB)"). + public let showSize: Bool + /// A flag indicating whether to show a speed (e.g., "(4.834 MB/s)"). + /// The speed is combined with the size and total size (e.g., "(22/22 MB, 4.834 MB/s)"). + /// The speed is hidden when no total size is set. + public let showSpeed: Bool + /// A flag indicating whether to show the elapsed time (e.g., "[4s]"). + public let showTime: Bool + /// The flag indicating whether to ignore small size values (less than 1 MB). For example, this may help to avoid reaching 100% after downloading metadata before downloading content. + public let ignoreSmallSize: Bool + /// The initial total tasks of the progress bar. + let initialTotalTasks: Int? + /// The initial total size of the progress bar. + let initialTotalSize: Int64? + /// The initial total items of the progress bar. + let initialTotalItems: Int? + /// The width of the progress bar in characters. + public let width: Int + /// The theme of the progress bar. + public let theme: ProgressTheme + /// The flag indicating whether to clear the progress bar before resetting the cursor. + /// In `.plain` mode, `finish()` only emits a final line when this value is `false`. + public let clearOnFinish: Bool + /// The flag indicating whether to update the progress bar. + public let disableProgressUpdates: Bool + /// The output mode for progress rendering. + public let outputMode: OutputMode + + /// Creates a new instance of `ProgressConfig`. + /// - Parameters: + /// - terminal: The file handle for progress updates. The default value is `FileHandle.standardError`. + /// - description: The initial description of the progress bar. The default value is `""`. + /// - subDescription: The initial additional description of the progress bar. The default value is `""`. + /// - itemsName: The initial items name. The default value is `"it"`. + /// - showSpinner: A flag indicating whether to show a spinner. The default value is `true`. + /// - showTasks: A flag indicating whether to show tasks and total tasks. The default value is `false`. + /// - showDescription: A flag indicating whether to show the description. The default value is `true`. + /// - showPercent: A flag indicating whether to show a percentage. The default value is `true`. + /// - showProgressBar: A flag indicating whether to show a progress bar. The default value is `false`. + /// - showItems: A flag indicating whether to show items and a total items. The default value is `false`. + /// - showSize: A flag indicating whether to show a size and a total size. The default value is `true`. + /// - showSpeed: A flag indicating whether to show a speed. The default value is `true`. + /// - showTime: A flag indicating whether to show the elapsed time. The default value is `true`. + /// - ignoreSmallSize: A flag indicating whether to ignore small size values. The default value is `false`. + /// - totalTasks: The initial total tasks of the progress bar. The default value is `nil`. + /// - totalItems: The initial total items of the progress bar. The default value is `nil`. + /// - totalSize: The initial total size of the progress bar. The default value is `nil`. + /// - width: The width of the progress bar in characters. The default value is `120`. + /// - theme: The theme of the progress bar. The default value is `nil`. + /// - clearOnFinish: The flag indicating whether to clear the progress bar before resetting the cursor. In `.plain` mode, + /// `finish()` only emits a final line when this value is `false`. The default is `true`. + /// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`. + /// - outputMode: The output mode for progress rendering. The default is `.ansi`. + public init( + terminal: FileHandle = .standardError, + description: String = "", + subDescription: String = "", + itemsName: String = "it", + showSpinner: Bool = true, + showTasks: Bool = false, + showDescription: Bool = true, + showPercent: Bool = true, + showProgressBar: Bool = false, + showItems: Bool = false, + showSize: Bool = true, + showSpeed: Bool = true, + showTime: Bool = true, + ignoreSmallSize: Bool = false, + totalTasks: Int? = nil, + totalItems: Int? = nil, + totalSize: Int64? = nil, + width: Int = 120, + theme: ProgressTheme? = nil, + clearOnFinish: Bool = true, + disableProgressUpdates: Bool = false, + outputMode: OutputMode = .ansi + ) throws { + if let totalTasks { + guard totalTasks > 0 else { + throw Error.invalid("totalTasks must be greater than zero") + } + } + if let totalItems { + guard totalItems > 0 else { + throw Error.invalid("totalItems must be greater than zero") + } + } + if let totalSize { + guard totalSize > 0 else { + throw Error.invalid("totalSize must be greater than zero") + } + } + + self.terminal = terminal + self.initialDescription = description + self.initialSubDescription = subDescription + self.initialItemsName = itemsName + + self.showSpinner = showSpinner + self.showTasks = showTasks + self.showDescription = showDescription + self.showPercent = showPercent + self.showProgressBar = showProgressBar + self.showItems = showItems + self.showSize = showSize + self.showSpeed = showSpeed + self.showTime = showTime + + self.ignoreSmallSize = ignoreSmallSize + self.initialTotalTasks = totalTasks + self.initialTotalItems = totalItems + self.initialTotalSize = totalSize + + self.width = width + self.theme = theme ?? DefaultProgressTheme() + self.clearOnFinish = clearOnFinish + self.disableProgressUpdates = disableProgressUpdates + self.outputMode = outputMode + } +} + +extension ProgressConfig { + /// The output mode for progress rendering. + public enum OutputMode: Sendable { + /// ANSI escape code mode with cursor control and line overwriting. + case ansi + /// Plain text mode with newline-separated output, no ANSI codes. + case plain + /// ANSI escape code mode with cursor control and color-coded output. + case color + } + + /// An enumeration of errors that can occur when creating a `ProgressConfig`. + public enum Error: Swift.Error, CustomStringConvertible { + case invalid(String) + + /// The description of the error. + public var description: String { + switch self { + case .invalid(let reason): + return "failed to validate config (\(reason))" + } + } + } +} diff --git a/Sources/TerminalProgress/ProgressTaskCoordinator.swift b/Sources/TerminalProgress/ProgressTaskCoordinator.swift new file mode 100644 index 0000000..88cc73a --- /dev/null +++ b/Sources/TerminalProgress/ProgressTaskCoordinator.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 + +/// A type that represents a task whose progress is being monitored. +public struct ProgressTask: Sendable, Equatable { + private var id = UUID() + private var coordinator: ProgressTaskCoordinator + + init(manager: ProgressTaskCoordinator) { + self.coordinator = manager + } + + static public func == (lhs: ProgressTask, rhs: ProgressTask) -> Bool { + lhs.id == rhs.id + } + + /// Returns `true` if this task is the currently active task, `false` otherwise. + public func isCurrent() async -> Bool { + guard let currentTask = await coordinator.currentTask else { + return false + } + return currentTask == self + } +} + +/// A type that coordinates progress tasks to ignore updates from completed tasks. +public actor ProgressTaskCoordinator { + var currentTask: ProgressTask? + + /// Creates an instance of `ProgressTaskCoordinator`. + public init() {} + + /// Returns a new task that should be monitored for progress updates. + public func startTask() -> ProgressTask { + let newTask = ProgressTask(manager: self) + currentTask = newTask + return newTask + } + + /// Performs cleanup when the monitored tasks complete. + public func finish() { + currentTask = nil + } + + /// Returns a handler that updates the progress of a given task. + /// - Parameters: + /// - task: The task whose progress is being updated. + /// - progressUpdate: The handler to invoke when progress updates are received. + public static func handler(for task: ProgressTask, from progressUpdate: @escaping ProgressUpdateHandler) -> ProgressUpdateHandler { + { events in + // Ignore updates from completed tasks. + if await task.isCurrent() { + await progressUpdate(events) + } + } + } +} diff --git a/Sources/TerminalProgress/ProgressTheme.swift b/Sources/TerminalProgress/ProgressTheme.swift new file mode 100644 index 0000000..406b552 --- /dev/null +++ b/Sources/TerminalProgress/ProgressTheme.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +/// A theme for progress bar. +public protocol ProgressTheme: Sendable { + /// The icons used to represent a spinner. + var spinner: [String] { get } + /// The icon used to represent a progress bar. + var bar: String { get } + /// The icon used to indicate that a progress bar finished. + var done: String { get } +} + +public struct DefaultProgressTheme: ProgressTheme { + public let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + public let bar = "█" + public let done = "✔" +} + +extension ProgressTheme { + func getSpinnerIcon(_ iteration: Int) -> String { + spinner[iteration % spinner.count] + } +} diff --git a/Sources/TerminalProgress/ProgressUpdate.swift b/Sources/TerminalProgress/ProgressUpdate.swift new file mode 100644 index 0000000..395dc7b --- /dev/null +++ b/Sources/TerminalProgress/ProgressUpdate.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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. +//===----------------------------------------------------------------------===// + +public enum ProgressUpdateEvent: Sendable { + case setDescription(String) + case setSubDescription(String) + case setItemsName(String) + case addTasks(Int) + case setTasks(Int) + case addTotalTasks(Int) + case setTotalTasks(Int) + case addItems(Int) + case setItems(Int) + case addTotalItems(Int) + case setTotalItems(Int) + case addSize(Int64) + case setSize(Int64) + case addTotalSize(Int64) + case setTotalSize(Int64) + case custom(String) +} + +public typealias ProgressUpdateHandler = @Sendable (_ events: [ProgressUpdateEvent]) async -> Void + +public protocol ProgressAdapter { + associatedtype T + static func handler(from progressUpdate: ProgressUpdateHandler?) -> (@Sendable ([T]) async -> Void)? +} diff --git a/Sources/TerminalProgress/StandardError.swift b/Sources/TerminalProgress/StandardError.swift new file mode 100644 index 0000000..c27b5a9 --- /dev/null +++ b/Sources/TerminalProgress/StandardError.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// 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 StandardError { + func write(_ string: String) { + if let data = string.data(using: .utf8) { + FileHandle.standardError.write(data) + } + } +} diff --git a/Tests/ContainerAPIClientTests/ArchTests.swift b/Tests/ContainerAPIClientTests/ArchTests.swift new file mode 100644 index 0000000..f4236b4 --- /dev/null +++ b/Tests/ContainerAPIClientTests/ArchTests.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerAPIClient + +struct ArchTests { + + @Test func testAmd64Initialization() throws { + let arch = Arch(rawValue: "amd64") + #expect(arch != nil) + #expect(arch == .amd64) + } + + @Test func testX86_64Alias() throws { + let arch = Arch(rawValue: "x86_64") + #expect(arch != nil) + #expect(arch == .amd64) + } + + @Test func testX86_64WithDashAlias() throws { + let arch = Arch(rawValue: "x86-64") + #expect(arch != nil) + #expect(arch == .amd64) + } + + @Test func testArm64Initialization() throws { + let arch = Arch(rawValue: "arm64") + #expect(arch != nil) + #expect(arch == .arm64) + } + + @Test func testAarch64Alias() throws { + let arch = Arch(rawValue: "aarch64") + #expect(arch != nil) + #expect(arch == .arm64) + } + + @Test func testCaseInsensitive() throws { + #expect(Arch(rawValue: "AMD64") == .amd64) + #expect(Arch(rawValue: "X86_64") == .amd64) + #expect(Arch(rawValue: "ARM64") == .arm64) + #expect(Arch(rawValue: "AARCH64") == .arm64) + #expect(Arch(rawValue: "Amd64") == .amd64) + } + + @Test func testInvalidArchitecture() throws { + #expect(Arch(rawValue: "invalid") == nil) + #expect(Arch(rawValue: "i386") == nil) + #expect(Arch(rawValue: "powerpc") == nil) + #expect(Arch(rawValue: "") == nil) + } + + @Test func testRawValueRoundTrip() throws { + #expect(Arch.amd64.rawValue == "amd64") + #expect(Arch.arm64.rawValue == "arm64") + } +} diff --git a/Tests/ContainerAPIClientTests/DefaultPlatformTests.swift b/Tests/ContainerAPIClientTests/DefaultPlatformTests.swift new file mode 100644 index 0000000..45dd41a --- /dev/null +++ b/Tests/ContainerAPIClientTests/DefaultPlatformTests.swift @@ -0,0 +1,243 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationOCI +import Testing + +@testable import ContainerAPIClient + +struct DefaultPlatformTests { + + // MARK: - fromEnvironment + + @Test + func testFromEnvironmentWithLinuxAmd64() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] + let result = try DefaultPlatform.fromEnvironment(environment: env) + #expect(result != nil) + #expect(result?.os == "linux") + #expect(result?.architecture == "amd64") + } + + @Test + func testFromEnvironmentWithLinuxArm64() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] + let result = try DefaultPlatform.fromEnvironment(environment: env) + #expect(result != nil) + #expect(result?.os == "linux") + #expect(result?.architecture == "arm64") + } + + @Test + func testFromEnvironmentNotSet() throws { + let env: [String: String] = [:] + let result = try DefaultPlatform.fromEnvironment(environment: env) + #expect(result == nil) + } + + @Test + func testFromEnvironmentEmptyString() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": ""] + let result = try DefaultPlatform.fromEnvironment(environment: env) + #expect(result == nil) + } + + @Test + func testFromEnvironmentInvalidPlatformThrows() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "not-a-valid-platform"] + #expect { + _ = try DefaultPlatform.fromEnvironment(environment: env) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("CONTAINER_DEFAULT_PLATFORM") + && error.description.contains("not-a-valid-platform") + } + } + + @Test + func testFromEnvironmentIgnoresOtherVariables() throws { + let env = ["SOME_OTHER_VAR": "linux/amd64"] + let result = try DefaultPlatform.fromEnvironment(environment: env) + #expect(result == nil) + } + + @Test + func testFromEnvironmentWithVariant() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm/v7"] + let result = try DefaultPlatform.fromEnvironment(environment: env) + #expect(result != nil) + #expect(result?.os == "linux") + #expect(result?.architecture == "arm") + #expect(result?.variant == "v7") + } + + // MARK: - resolve (optional os/arch, used by image pull/push/save) + + @Test + func testResolveExplicitPlatformWins() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] + let result = try DefaultPlatform.resolve( + platform: "linux/amd64", os: nil, arch: nil, environment: env + ) + #expect(result != nil) + #expect(result?.architecture == "amd64") + #expect(result?.os == "linux") + } + + @Test + func testResolveExplicitArchWinsOverEnvVar() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] + let result = try DefaultPlatform.resolve( + platform: nil, os: nil, arch: "amd64", environment: env + ) + #expect(result != nil) + #expect(result?.architecture == "amd64") + #expect(result?.os == "linux") + } + + @Test + func testResolveExplicitOsAndArchWinOverEnvVar() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] + let result = try DefaultPlatform.resolve( + platform: nil, os: "linux", arch: "amd64", environment: env + ) + #expect(result != nil) + #expect(result?.architecture == "amd64") + } + + @Test + func testResolveExplicitOsWinsOverEnvVar() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] + let result = try DefaultPlatform.resolve( + platform: nil, os: "linux", arch: nil, environment: env + ) + #expect(result != nil) + #expect(result?.os == "linux") + } + + @Test + func testResolveFallsBackToEnvVar() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] + let result = try DefaultPlatform.resolve( + platform: nil, os: nil, arch: nil, environment: env + ) + #expect(result != nil) + #expect(result?.os == "linux") + #expect(result?.architecture == "amd64") + } + + @Test + func testResolveReturnsNilWithNoFlagsOrEnvVar() throws { + let env: [String: String] = [:] + let result = try DefaultPlatform.resolve( + platform: nil, os: nil, arch: nil, environment: env + ) + #expect(result == nil) + } + + @Test + func testResolveExplicitPlatformOverridesEverything() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] + let result = try DefaultPlatform.resolve( + platform: "linux/amd64", os: "linux", arch: "arm64", environment: env + ) + #expect(result?.architecture == "amd64") + } + + @Test + func testResolveExplicitPlatformIgnoresInvalidEnvVar() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "garbage"] + let result = try DefaultPlatform.resolve( + platform: "linux/amd64", os: nil, arch: nil, environment: env + ) + #expect(result?.architecture == "amd64") + #expect(result?.os == "linux") + } + + // MARK: - resolveWithDefaults (required os/arch, used by run/create) + + @Test + func testResolveWithDefaultsExplicitPlatformWins() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/arm64"] + let result = try DefaultPlatform.resolveWithDefaults( + platform: "linux/amd64", os: "linux", arch: "arm64", environment: env + ) + #expect(result.architecture == "amd64") + } + + @Test + func testResolveWithDefaultsEnvVarOverridesDefaults() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] + let result = try DefaultPlatform.resolveWithDefaults( + platform: nil, os: "linux", arch: "arm64", environment: env + ) + #expect(result.architecture == "amd64") + #expect(result.os == "linux") + } + + @Test + func testResolveWithDefaultsFallsBackToOsArch() throws { + let env: [String: String] = [:] + let result = try DefaultPlatform.resolveWithDefaults( + platform: nil, os: "linux", arch: "arm64", environment: env + ) + #expect(result.os == "linux") + #expect(result.architecture == "arm64") + } + + @Test + func testResolveWithDefaultsEnvVarWithDifferentOs() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "linux/amd64"] + let result = try DefaultPlatform.resolveWithDefaults( + platform: nil, os: "linux", arch: Arch.hostArchitecture().rawValue, environment: env + ) + #expect(result.architecture == "amd64") + } + + @Test + func testResolveWithDefaultsInvalidEnvVarThrows() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "garbage"] + #expect { + _ = try DefaultPlatform.resolveWithDefaults( + platform: nil, os: "linux", arch: "arm64", environment: env + ) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("CONTAINER_DEFAULT_PLATFORM") + } + } + + @Test + func testResolveWithDefaultsExplicitPlatformIgnoresInvalidEnvVar() throws { + let env = ["CONTAINER_DEFAULT_PLATFORM": "garbage"] + let result = try DefaultPlatform.resolveWithDefaults( + platform: "linux/amd64", os: "linux", arch: "arm64", environment: env + ) + #expect(result.architecture == "amd64") + } + + // MARK: - Environment variable name + + @Test + func testEnvironmentVariableName() { + #expect(DefaultPlatform.environmentVariable == "CONTAINER_DEFAULT_PLATFORM") + } +} diff --git a/Tests/ContainerAPIClientTests/DiskUsageTests.swift b/Tests/ContainerAPIClientTests/DiskUsageTests.swift new file mode 100644 index 0000000..793ad9d --- /dev/null +++ b/Tests/ContainerAPIClientTests/DiskUsageTests.swift @@ -0,0 +1,105 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerAPIClient + +struct DiskUsageTests { + + @Test("DiskUsageStats JSON encoding and decoding") + func testJSONSerialization() throws { + let stats = DiskUsageStats( + images: ResourceUsage(total: 10, active: 5, sizeInBytes: 1024, reclaimable: 512), + containers: ResourceUsage(total: 3, active: 2, sizeInBytes: 2048, reclaimable: 1024), + volumes: ResourceUsage(total: 7, active: 4, sizeInBytes: 4096, reclaimable: 2048) + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(stats) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(DiskUsageStats.self, from: data) + + #expect(decoded.images.total == stats.images.total) + #expect(decoded.images.active == stats.images.active) + #expect(decoded.images.sizeInBytes == stats.images.sizeInBytes) + #expect(decoded.images.reclaimable == stats.images.reclaimable) + + #expect(decoded.containers.total == stats.containers.total) + #expect(decoded.containers.active == stats.containers.active) + #expect(decoded.containers.sizeInBytes == stats.containers.sizeInBytes) + #expect(decoded.containers.reclaimable == stats.containers.reclaimable) + + #expect(decoded.volumes.total == stats.volumes.total) + #expect(decoded.volumes.active == stats.volumes.active) + #expect(decoded.volumes.sizeInBytes == stats.volumes.sizeInBytes) + #expect(decoded.volumes.reclaimable == stats.volumes.reclaimable) + } + + @Test("ResourceUsage with zero values") + func testZeroValues() throws { + let emptyUsage = ResourceUsage(total: 0, active: 0, sizeInBytes: 0, reclaimable: 0) + + let encoder = JSONEncoder() + let data = try encoder.encode(emptyUsage) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(ResourceUsage.self, from: data) + + #expect(decoded.total == 0) + #expect(decoded.active == 0) + #expect(decoded.sizeInBytes == 0) + #expect(decoded.reclaimable == 0) + } + + @Test("ResourceUsage with large values") + func testLargeValues() throws { + let largeUsage = ResourceUsage( + total: 1000, + active: 500, + sizeInBytes: UInt64.max, + reclaimable: UInt64.max / 2 + ) + + let encoder = JSONEncoder() + let data = try encoder.encode(largeUsage) + + let decoder = JSONDecoder() + let decoded = try decoder.decode(ResourceUsage.self, from: data) + + #expect(decoded.total == 1000) + #expect(decoded.active == 500) + #expect(decoded.sizeInBytes == UInt64.max) + #expect(decoded.reclaimable == UInt64.max / 2) + } + + @Test("ResourceUsage percentage calculations") + func testPercentageCalculations() throws { + // 0% reclaimable + let noneReclaimable = ResourceUsage(total: 10, active: 10, sizeInBytes: 1000, reclaimable: 0) + #expect(Double(noneReclaimable.reclaimable) / Double(noneReclaimable.sizeInBytes) == 0.0) + + // 50% reclaimable + let halfReclaimable = ResourceUsage(total: 10, active: 5, sizeInBytes: 1000, reclaimable: 500) + #expect(Double(halfReclaimable.reclaimable) / Double(halfReclaimable.sizeInBytes) == 0.5) + + // 100% reclaimable + let allReclaimable = ResourceUsage(total: 10, active: 0, sizeInBytes: 1000, reclaimable: 1000) + #expect(Double(allReclaimable.reclaimable) / Double(allReclaimable.sizeInBytes) == 1.0) + } +} diff --git a/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift new file mode 100644 index 0000000..4f95517 --- /dev/null +++ b/Tests/ContainerAPIClientTests/HostDNSResolverTest.swift @@ -0,0 +1,144 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import DNSServer +import Foundation +import SystemPackage +import Testing + +@testable import ContainerAPIClient + +struct HostDNSResolverTest { + @Test + func testHostDNSCreate() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + let tempPath = FilePath(tempURL.path) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let resolver = HostDNSResolver(configPath: tempPath) + try resolver.createDomain(name: try! DNSName("foo.bar")) + let resolverConfigPath = tempPath.appending(FilePath.Component("containerization.foo.bar")) + let actualText = try String(contentsOfFile: resolverConfigPath.string, encoding: .utf8) + let expectedText = """ + domain foo.bar + search foo.bar + nameserver 127.0.0.1 + port 2053 + + """ + + #expect(actualText == expectedText) + + try resolver.createDomain(name: try! DNSName("bar.foo")) + let domains = resolver.listDomains() + #expect(domains.map { $0.pqdn } == ["bar.foo", "foo.bar"]) + } + + @Test + func testHostDNSCreateAlreadyExists() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + let tempPath = FilePath(tempURL.path) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let resolver = HostDNSResolver(configPath: tempPath) + try resolver.createDomain(name: try! DNSName("foo.bar")) + #expect { + try resolver.createDomain(name: try! DNSName("foo.bar")) + } throws: { error in + guard let error = error as? ContainerizationError, error.code == .exists else { + return false + } + return true + } + } + + @Test + func testHostDNSDelete() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + let tempPath = FilePath(tempURL.path) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let resolver = HostDNSResolver(configPath: tempPath) + try resolver.createDomain(name: try! DNSName("foo.bar")) + _ = try resolver.deleteDomain(name: try! DNSName("foo.bar")) + + let localhost = try! IPAddress("127.0.0.1") + try resolver.createDomain(name: try! DNSName("bar.baz"), localhost: localhost) + let deletedLocalhost = try resolver.deleteDomain(name: try! DNSName("bar.baz")) + #expect(localhost == deletedLocalhost) + + let domains = resolver.listDomains() + #expect(domains == []) + } + + @Test + func testHostDNSDeleteNotFound() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + let tempPath = FilePath(tempURL.path) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let resolver = HostDNSResolver(configPath: tempPath) + try resolver.createDomain(name: try! DNSName("foo.bar")) + #expect { + _ = try resolver.deleteDomain(name: try! DNSName("bar.foo")) + } throws: { error in + guard let error = error as? ContainerizationError, error.code == .notFound else { + return false + } + return true + } + } + + @Test + func testHostDNSReinitialize() async throws { + let isAdmin = getuid() == 0 + do { + try HostDNSResolver.reinitialize() + #expect(isAdmin) + } catch { + let containerizationError = try #require(error as? ContainerizationError) + #expect(containerizationError.code == .internalError) + #expect(containerizationError.message == "mDNSResponder restart failed with status 1") + #expect(!isAdmin) + } + } +} diff --git a/Tests/ContainerAPIClientTests/Measurement+ParseTests.swift b/Tests/ContainerAPIClientTests/Measurement+ParseTests.swift new file mode 100644 index 0000000..4214a7d --- /dev/null +++ b/Tests/ContainerAPIClientTests/Measurement+ParseTests.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerAPIClient + +struct MeasurementParseTests { + + @Test("Parse binary units - bare unit symbols") + func testBinaryUnits() throws { + let result1 = try Measurement.parse(parsing: "4k") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2m") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1g") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "512b") + #expect(result4.value == 512.0) + #expect(result4.unit == .bytes) + } + + @Test("Parse binary units - ib suffix") + func testBinaryUnitsWithIbSuffix() throws { + let result1 = try Measurement.parse(parsing: "4kib") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2mib") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1gib") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "3tib") + #expect(result4.value == 3.0) + #expect(result4.unit == .tebibytes) + + let result5 = try Measurement.parse(parsing: "1pib") + #expect(result5.value == 1.0) + #expect(result5.unit == .pebibytes) + } + + @Test("Parse binary units - all suffixes now use binary") + func testAllSuffixesUseBinary() throws { + let result1 = try Measurement.parse(parsing: "4kb") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2mb") + #expect(result2.value == 2.0) + #expect(result2.unit == .mebibytes) + + let result3 = try Measurement.parse(parsing: "1gb") + #expect(result3.value == 1.0) + #expect(result3.unit == .gibibytes) + + let result4 = try Measurement.parse(parsing: "3tb") + #expect(result4.value == 3.0) + #expect(result4.unit == .tebibytes) + + let result5 = try Measurement.parse(parsing: "1pb") + #expect(result5.value == 1.0) + #expect(result5.unit == .pebibytes) + } + + @Test("Parse with whitespace") + func testParsingWithWhitespace() throws { + let result1 = try Measurement.parse(parsing: " 4k ") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: " 2.5mb ") + #expect(result2.value == 2.5) + #expect(result2.unit == .mebibytes) + } + + @Test("Parse decimal values") + func testDecimalValues() throws { + let result1 = try Measurement.parse(parsing: "4.5k") + #expect(result1.value == 4.5) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "1.25gb") + #expect(result2.value == 1.25) + #expect(result2.unit == .gibibytes) + + let result3 = try Measurement.parse(parsing: "0.5mib") + #expect(result3.value == 0.5) + #expect(result3.unit == .mebibytes) + } + + @Test("Parse case insensitive") + func testCaseInsensitive() throws { + let result1 = try Measurement.parse(parsing: "4K") + #expect(result1.value == 4.0) + #expect(result1.unit == .kibibytes) + + let result2 = try Measurement.parse(parsing: "2GB") + #expect(result2.value == 2.0) + #expect(result2.unit == .gibibytes) + + let result3 = try Measurement.parse(parsing: "1MIB") + #expect(result3.value == 1.0) + #expect(result3.unit == .mebibytes) + } + + @Test("Parse bytes unit") + func testBytesUnit() throws { + let result1 = try Measurement.parse(parsing: "1024") + #expect(result1.value == 1024.0) + #expect(result1.unit == .bytes) + + let result2 = try Measurement.parse(parsing: "512b") + #expect(result2.value == 512.0) + #expect(result2.unit == .bytes) + } + + @Test("Parse invalid size throws error") + func testInvalidSizeThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "abc") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + + #expect { + _ = try Measurement.parse(parsing: "k4") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + } + + @Test("Parse invalid symbol throws error") + func testInvalidSymbolThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "4x") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid symbol: x" + } + + #expect { + _ = try Measurement.parse(parsing: "4kx") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid symbol: kx" + } + } + + @Test("Parse empty string throws error") + func testEmptyStringThrowsError() { + #expect { + _ = try Measurement.parse(parsing: "") + } throws: { error in + guard let parseError = error as? Measurement.ParseError else { + return false + } + return parseError.description == "invalid size" + } + } + + @Test("Verify all suffixes now use binary units") + func testAllSuffixesUseBinaryUnits() throws { + let bareK = try Measurement.parse(parsing: "1k") + let kib = try Measurement.parse(parsing: "1kib") + let kb = try Measurement.parse(parsing: "1kb") + + #expect(bareK.unit == .kibibytes) + #expect(kib.unit == .kibibytes) + #expect(kb.unit == .kibibytes) + + let allInBytes = bareK.converted(to: .bytes).value + + #expect(allInBytes == 1024.0) + } +} diff --git a/Tests/ContainerAPIClientTests/MemorySizeTests.swift b/Tests/ContainerAPIClientTests/MemorySizeTests.swift new file mode 100644 index 0000000..1d518db --- /dev/null +++ b/Tests/ContainerAPIClientTests/MemorySizeTests.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import Foundation +import Testing + +struct MemorySizeTests { + @Test(arguments: [ + ("1gb", "1gb"), + ("2048MB", "2048mb"), + ("512kb", "512kb"), + ("1024b", "1024b"), + ("4tb", "4tb"), + ]) + func testFormattedOutput(input: String, expected: String) throws { + let size = try MemorySize(input) + #expect(size.formatted == expected) + } + + @Test func testMeasurementValue() throws { + let size = try MemorySize("2048mb") + #expect(size.measurement.value == 2048) + #expect(size.measurement.unit == .mebibytes) + + let sizeGB = try MemorySize("4gb") + #expect(sizeGB.measurement.value == 4) + #expect(sizeGB.measurement.unit == .gibibytes) + } + + @Test func testDescription() throws { + let size = try MemorySize("1gb") + #expect(size.description == "1gb") + } + + @Test func testEquality() throws { + let a = try MemorySize("1gb") + let b = try MemorySize("1gb") + #expect(a == b) + } + + @Test func testRoundTripEncoding() throws { + let original = try MemorySize("2048mb") + let encoder = JSONEncoder() + let data = try encoder.encode(original) + let decoder = JSONDecoder() + let decoded = try decoder.decode(MemorySize.self, from: data) + #expect(original == decoded) + } + + @Test func testDecodingFromString() throws { + let json = Data("\"512kb\"".utf8) + let decoded = try JSONDecoder().decode(MemorySize.self, from: json) + #expect(decoded.formatted == "512kb") + } + + @Test func testInvalidInputThrows() throws { + #expect(throws: (any Error).self) { + _ = try MemorySize("notasize") + } + } + + @Test( + arguments: [ + ("1gb", UInt64(1 * 1024 * 1024 * 1024)), + ("2048mb", UInt64(2048 * 1024 * 1024)), + ("512kb", UInt64(512 * 1024)), + ("1024b", UInt64(1024)), + ("4tb", UInt64(4) * 1024 * 1024 * 1024 * 1024), + ] as [(String, UInt64)]) + func testToUInt64Bytes(input: String, expected: UInt64) throws { + let size = try MemorySize(input) + #expect(size.toUInt64(unit: .bytes) == expected) + } + + @Test func testToUInt64SameUnit() throws { + let size = try MemorySize("2048mb") + #expect(size.toUInt64(unit: .mebibytes) == 2048) + } +} diff --git a/Tests/ContainerAPIClientTests/PacketFilterTest.swift b/Tests/ContainerAPIClientTests/PacketFilterTest.swift new file mode 100644 index 0000000..48cf049 --- /dev/null +++ b/Tests/ContainerAPIClientTests/PacketFilterTest.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import DNSServer +import Foundation +import SystemPackage +import Testing + +@testable import ContainerAPIClient + +struct PacketFilterTest { + @Test + func testRedirectRuleUpdate() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + let tempPath = FilePath(tempURL.path) + defer { try? FileManager.default.removeItem(at: tempURL) } + let configPath = tempPath.appending("pf.conf") + + let pf = PacketFilter(configPath: configPath, anchorsPath: tempPath) + let from1 = try! IPAddress("203.0.113.113") + let domain1 = try! DNSName("aaa.com") + let to = try! IPAddress("127.0.0.1") + try pf.createRedirectRule(from: from1, to: to, domain: domain1) + + let anchorPath = tempPath.appending("com.apple.container") + var actualAnchorText = try String(contentsOfFile: anchorPath.string, encoding: .utf8) + var expectedAnchorTest = """ + rdr inet from any to \(from1) -> \(to) # \(domain1.pqdn)\n + """ + + #expect(actualAnchorText == expectedAnchorTest) + + let from2 = try! IPAddress("172.31.72.1") + let domain2 = try! DNSName("bbb.com") + try pf.createRedirectRule(from: from2, to: to, domain: domain2) + + actualAnchorText = try String(contentsOfFile: anchorPath.string, encoding: .utf8) + expectedAnchorTest += """ + rdr inet from any to \(from2) -> \(to) # \(domain2.pqdn)\n + """ + #expect(actualAnchorText == expectedAnchorTest) + + let actualConfigText = try String(contentsOfFile: configPath.string, encoding: .utf8) + let expectedConfigText = try Regex( + #""" + scrub-anchor "([^"]+)" + nat-anchor "([^"]+)" + rdr-anchor "([^"]+)" + dummynet-anchor "([^"]+)" + anchor "([^"]+)" + load anchor "([^"]+)" from "[^"]+" + """# + ) + + #expect(actualConfigText.contains(expectedConfigText)) + + try pf.removeRedirectRule(from: from1, to: to, domain: domain1) + try pf.removeRedirectRule(from: from2, to: to, domain: domain2) + + #expect(!fm.fileExists(atPath: anchorPath.string)) + let configText = try String(contentsOfFile: configPath.string, encoding: .utf8) + #expect(configText == "") + } + + @Test + func testPacketFilterReinitialize() async throws { + let pf = PacketFilter() + #expect(throws: ContainerizationError.self) { + try pf.reinitialize() + } + } +} diff --git a/Tests/ContainerAPIClientTests/ParserTest.swift b/Tests/ContainerAPIClientTests/ParserTest.swift new file mode 100644 index 0000000..0dcc6f7 --- /dev/null +++ b/Tests/ContainerAPIClientTests/ParserTest.swift @@ -0,0 +1,1396 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Foundation +import SystemPackage +import Testing + +@testable import ContainerAPIClient +@testable import ContainerPersistence + +struct ParserTest { + @Test + func testPublishPortParserTcp() throws { + let result = try Parser.publishPorts(["127.0.0.1:8080:8000/tcp"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(8000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortParserUdp() throws { + let result = try Parser.publishPorts(["192.168.32.36:8000:8080/UDP"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("192.168.32.36") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8000)) + #expect(result[0].containerPort == UInt16(8080)) + #expect(result[0].proto == .udp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortRange() throws { + let result = try Parser.publishPorts(["127.0.0.1:8080-8179:9000-9099/tcp"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(9000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 100) + } + + @Test + func testPublishPortRangeSingle() throws { + let result = try Parser.publishPorts(["127.0.0.1:8080-8080:9000-9000/tcp"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("127.0.0.1") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(9000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortNoHostAddress() throws { + let result = try Parser.publishPorts(["8080:8000/tcp"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("0.0.0.0") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(8000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortNoProtocol() throws { + let result = try Parser.publishPorts(["8080:8000"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("0.0.0.0") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(8000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortParserIPv6() throws { + let result = try Parser.publishPorts(["[fe80::36f3:5e50:ed71:1bb]:8080:8000/tcp"]) + #expect(result.count == 1) + let expectedAddress = try IPAddress("fe80::36f3:5e50:ed71:1bb") + #expect(result[0].hostAddress == expectedAddress) + #expect(result[0].hostPort == UInt16(8080)) + #expect(result[0].containerPort == UInt16(8000)) + #expect(result[0].proto == .tcp) + #expect(result[0].count == 1) + } + + @Test + func testPublishPortInvalidProtocol() throws { + #expect { + _ = try Parser.publishPorts(["8080:8000/sctp"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish protocol") + } + } + + @Test + func testPublishPortInvalidValue() throws { + #expect { + _ = try Parser.publishPorts([""]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish value") + } + } + + @Test + func testPublishPortMissingPort() throws { + #expect { + _ = try Parser.publishPorts(["1234"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish value") + } + } + + @Test + func testPublishInvalidIPv4Address() throws { + #expect { + _ = try Parser.publishPorts(["1234:8080:8000"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish IPv4 address") + } + } + + @Test + func testPublishInvalidIPv6Address() throws { + #expect { + _ = try Parser.publishPorts([ + "[1234:5678]:8080:8000", + "[2001::db8::1]:8080:8080", + "[2001:db8:85a3::8a2e:370g:7334]:8080:8080", + "[2001:db8:85a3::][8a2e::7334]:8080:8080", + ]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish IPv6 address") + } + } + + @Test + func testPublishPortInvalidHostPort() throws { + #expect { + _ = try Parser.publishPorts(["65536:1234"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish host port") + } + } + + @Test + func testPublishPortInvalidContainerPort() throws { + #expect { + _ = try Parser.publishPorts(["1234:65536"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish container port") + } + } + + @Test + func testPublishPortRangeMismatch() throws { + #expect { + _ = try Parser.publishPorts(["8000-8000:9000-9001"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("counts are not equal") + } + } + + @Test + func testPublishPortRangeInvalidHostPortStart() throws { + #expect { + _ = try Parser.publishPorts(["65536-65537:9000-9001"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish host port") + } + } + + @Test + func testPublishPortRangeZeroHostPortStart() throws { + #expect { + _ = try Parser.publishPorts(["0-1:9000-9001"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish host port") + } + } + + @Test + func testPublishPortRangeInvalidHostPortEnd() throws { + #expect { + _ = try Parser.publishPorts(["65535-65536:9000-9001"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish host port") + } + } + + @Test + func testPublishPortRangeInvalidHostPortRange() throws { + #expect { + _ = try Parser.publishPorts(["8000-8001-8002:9000-9001"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish host port") + } + } + + @Test + func testPublishPortRangeNegativeHostPortRange() throws { + #expect { + _ = try Parser.publishPorts(["8001-8000:9000-9001"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish host port") + } + } + + @Test + func testPublishPortRangeInvalidContainerPortStart() throws { + #expect { + _ = try Parser.publishPorts(["8000-8001:65536-65537"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish container port") + } + } + + @Test + func testPublishPortRangeZeroContainerPortStart() throws { + #expect { + _ = try Parser.publishPorts(["8000-8001:0-1"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish container port") + } + } + + @Test + func testPublishPortRangeInvalidContainerPortEnd() throws { + #expect { + _ = try Parser.publishPorts(["8000-8001:65535-65536"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish container port") + } + } + + @Test + func testPublishPortRangeInvalidContainerPortRange() throws { + #expect { + _ = try Parser.publishPorts(["8000-8001:9000-9001-9002"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish container port") + } + } + + @Test + func testPublishPortRangeNegativeContainerPortRange() throws { + #expect { + _ = try Parser.publishPorts(["8000-8001:9001-9000"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid publish container port") + } + } + + @Test + func testRelativePaths() throws { + // Test bind mount with relative path "." + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-bind-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let result = try Parser.mount("type=bind,src=.,dst=/foo", relativeTo: tempDir) + + switch result { + case .filesystem(let fs): + #expect(fs.source == tempDir.standardizedFileURL.path) + #expect(fs.destination == "/foo") + #expect(!fs.isVolume) + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + + // Test volume with relative path "./" + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-rel-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let result = try Parser.volume("./:/foo", relativeTo: tempDir) + + switch result { + case .filesystem(let fs): + let expectedPath = tempDir.standardizedFileURL.path + // Normalize trailing slashes for comparison + #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + #expect(fs.destination == "/foo") + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + + // Test volume with nested relative path "./subdir" + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-rel-nested-\(UUID().uuidString)") + let nestedDir = tempDir.appendingPathComponent("subdir") + try FileManager.default.createDirectory(at: nestedDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let result = try Parser.volume("./subdir:/foo", relativeTo: tempDir) + + switch result { + case .filesystem(let fs): + let expectedPath = nestedDir.standardizedFileURL.path + // Normalize trailing slashes for comparison + #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + #expect(fs.destination == "/foo") + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + + // Test volume with bare "." as source (current directory) + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-dot-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let result = try Parser.volume(".:/docs:ro", relativeTo: tempDir) + + switch result { + case .filesystem(let fs): + let expectedPath = tempDir.standardizedFileURL.path + #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + #expect(fs.destination == "/docs") + #expect(fs.options.contains("ro")) + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + + // Test volume with ".." as source (parent directory) + do { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-volume-dotdot-\(UUID().uuidString)") + let childDir = tempDir.appendingPathComponent("child") + try FileManager.default.createDirectory(at: childDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let result = try Parser.volume("..:/data", relativeTo: childDir) + + switch result { + case .filesystem(let fs): + let expectedPath = tempDir.standardizedFileURL.path + #expect(fs.source.trimmingCharacters(in: CharacterSet(charactersIn: "/")) == expectedPath.trimmingCharacters(in: CharacterSet(charactersIn: "/"))) + #expect(fs.destination == "/data") + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + } + + @Test + func testMountBindAbsolutePath() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("test-bind-abs-\(UUID().uuidString)") + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let result = try Parser.mount("type=bind,src=\(tempDir.path),dst=/foo") + + switch result { + case .filesystem(let fs): + #expect(fs.source == tempDir.path) + #expect(fs.destination == "/foo") + #expect(!fs.isVolume) + case .volume: + #expect(Bool(false), "Expected filesystem mount, got volume") + } + } + + @Test + func testMountVolumeValidName() throws { + let result = try Parser.mount("type=volume,src=myvolume,dst=/data") + + switch result { + case .filesystem: + #expect(Bool(false), "Expected volume mount, got filesystem") + case .volume(let vol): + #expect(vol.name == "myvolume") + #expect(vol.destination == "/data") + } + } + + @Test + func testMountVolumeInvalidName() throws { + #expect { + _ = try Parser.mount("type=volume,src=.,dst=/data") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid volume name") + } + } + + @Test + func testMountBindNonExistentPath() throws { + #expect { + _ = try Parser.mount("type=bind,src=/nonexistent/path,dst=/foo") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("path") && error.description.contains("does not exist") + } + } + + @Test + func testMountBindFileInsteadOfDirectory() throws { + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-file-\(UUID().uuidString)") + try "test content".write(to: tempFile, atomically: true, encoding: .utf8) + defer { + try? FileManager.default.removeItem(at: tempFile) + } + + #expect { + _ = try Parser.mount("type=bind,src=\(tempFile.path),dst=/foo") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("path") && error.description.contains("is not a directory") + } + } + + @Test + func testIsValidDomainNameOk() throws { + let names = [ + "a", + "a.b", + "foo.bar", + "F-O.B-R", + [ + String(repeating: "0", count: 63), + String(repeating: "1", count: 63), + String(repeating: "2", count: 63), + String(repeating: "3", count: 63), + ].joined(separator: "."), + ] + for name in names { + #expect(Parser.isValidDomainName(name)) + } + } + + @Test + func testIsValidDomainNameBad() throws { + let names = [ + ".foo", + "foo.", + ".foo.bar", + "foo.bar.", + "-foo.bar", + "foo.bar-", + [ + String(repeating: "0", count: 63), + String(repeating: "1", count: 63), + String(repeating: "2", count: 63), + String(repeating: "3", count: 62), + "4", + ].joined(separator: "."), + ] + for name in names { + #expect(!Parser.isValidDomainName(name)) + } + } + + // MARK: - Environment Variable Tests + + @Test + func testEnvExplicitValue() throws { + let result = Parser.env(envList: ["FOO=bar", "BAZ=qux"]) + #expect(result == ["FOO=bar", "BAZ=qux"]) + } + + @Test + func testEnvImplicitInheritance() throws { + guard let homeValue = ProcessInfo.processInfo.environment["PATH"] else { + Issue.record("PATH environment variable not set") + return + } + + let result = Parser.env(envList: ["PATH"]) + #expect(result == ["PATH=\(homeValue)"]) + } + + @Test + func testEnvImplicitUndefinedVariable() throws { + // A variable that doesn't exist should be silently skipped + let result = Parser.env(envList: ["THIS_VAR_DEFINITELY_DOES_NOT_EXIST_12345"]) + #expect(result.isEmpty) + } + + @Test + func testEnvMixedExplicitAndImplicit() throws { + guard let homeValue = ProcessInfo.processInfo.environment["HOME"] else { + Issue.record("HOME environment variable not set") + return + } + + let result = Parser.env(envList: ["FOO=bar", "HOME", "BAZ=qux"]) + #expect(result == ["FOO=bar", "HOME=\(homeValue)", "BAZ=qux"]) + } + + @Test + func testEnvEmptyValue() throws { + // Explicit empty value should be preserved + let result = Parser.env(envList: ["EMPTY="]) + #expect(result == ["EMPTY="]) + } + + @Test + func testAllEnvUserOverridesImage() throws { + let result = try Parser.allEnv( + imageEnvs: ["FOO=fromimage", "BAR=kept"], + envFiles: [], + envs: ["FOO=fromuser"] + ) + #expect(Set(result) == Set(["FOO=fromuser", "BAR=kept"])) + } + + @Test + func testAllEnvFileOverridesImage() throws { + let tmpFile = try tmpFileWithContent("FOO=fromfile\n") + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let result = try Parser.allEnv( + imageEnvs: ["FOO=fromimage", "BAR=kept"], + envFiles: [tmpFile.path], + envs: [] + ) + #expect(Set(result) == Set(["FOO=fromfile", "BAR=kept"])) + } + + @Test + func testAllEnvUserOverridesFileOverridesImage() throws { + let tmpFile = try tmpFileWithContent("FOO=fromfile\nBAZ=fromfile\n") + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let result = try Parser.allEnv( + imageEnvs: ["FOO=fromimage", "BAR=fromimage"], + envFiles: [tmpFile.path], + envs: ["FOO=fromuser"] + ) + #expect(Set(result) == Set(["FOO=fromuser", "BAR=fromimage", "BAZ=fromfile"])) + } + + private func tmpFileWithContent(_ content: String) throws -> URL { + let tempDir = FileManager.default.temporaryDirectory + let tempFile = tempDir.appendingPathComponent("envfile-test-\(UUID().uuidString)") + try content.write(to: tempFile, atomically: true, encoding: .utf8) + return tempFile + } + + // NOTE: A lot of these env-file tests are recreations of the docker cli's unit tests for their + // env-file support. + + @Test + func testParseEnvFileGoodFile() throws { + var content = """ + foo=bar + baz=quux + # comment + + _foobar=foobaz + with.dots=working + and_underscore=working too + """ + content += "\n \t " + + let tmpFile = try tmpFileWithContent(content) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let lines = try Parser.envFile(path: tmpFile.path) + + let expectedLines = [ + "foo=bar", + "baz=quux", + "_foobar=foobaz", + "with.dots=working", + "and_underscore=working too", + ] + + #expect(lines == expectedLines) + } + + @Test + func testParseEnvFileMultipleEqualsSigns() throws { + let content = """ + URL=https://foo.bar?baz=woo + """ + let tmpFile = try tmpFileWithContent(content) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let lines = try Parser.envFile(path: tmpFile.path) + + let expectedLines = [ + "URL=https://foo.bar?baz=woo" + ] + + #expect(lines == expectedLines) + } + + @Test + func testParseEnvFileEmptyFile() throws { + let tmpFile = try tmpFileWithContent("") + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let lines = try Parser.envFile(path: tmpFile.path) + #expect(lines.isEmpty) + } + + @Test + func testParseEnvFileNonExistentFile() throws { + #expect { + _ = try Parser.envFile(path: "/nonexistent/foo_bar_baz") + } throws: { error in + guard let error = error as? ContainerizationError, + let cause = error.cause + else { + return false + } + return String(describing: cause).contains("No such file or directory") + } + } + + @Test + func testParseEnvFileBadlyFormattedFile() throws { + let content = """ + foo=bar + f =quux + """ + let tmpFile = try tmpFileWithContent(content) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + #expect { + _ = try Parser.envFile(path: tmpFile.path) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("contains whitespaces") + } + } + + @Test + func testParseEnvFileRandomFile() throws { + let content = """ + first line + another invalid line + """ + let tmpFile = try tmpFileWithContent(content) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + #expect { + _ = try Parser.envFile(path: tmpFile.path) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("first line") && error.description.contains("contains whitespaces") + } + } + + @Test + func testParseEnvVariableDefinitionsFile() throws { + let content = """ + # comment= + UNDEFINED_VAR + HOME + """ + let tmpFile = try tmpFileWithContent(content) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + let variables = try Parser.envFile(path: tmpFile.path) + + // HOME should be imported from environment + guard let homeValue = ProcessInfo.processInfo.environment["HOME"] else { + Issue.record("HOME environment variable not set") + return + } + + #expect(variables.count == 1) + #expect(variables[0] == "HOME=\(homeValue)") + } + + @Test + func testParseEnvVariableWithNoNameFile() throws { + let content = """ + # comment= + =blank variable names are an error case + """ + let tmpFile = try tmpFileWithContent(content) + defer { try? FileManager.default.removeItem(at: tmpFile) } + + #expect { + _ = try Parser.envFile(path: tmpFile.path) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("no variable name") + } + } + + @Test + func testParseEnvFileFromNamedPipe() throws { + let pipePath = FileManager.default.temporaryDirectory + .appendingPathComponent("envfile-pipe-\(UUID().uuidString)") + + // Create a named pipe (FIFO) + let result = mkfifo(pipePath.path, 0o600) + guard result == 0 else { + throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM) + } + defer { try? FileManager.default.removeItem(at: pipePath) } + + let group = DispatchGroup() + + group.enter() + DispatchQueue.global().async { + do { + let handle = try FileHandle(forWritingTo: pipePath) + try handle.write(contentsOf: "SECRET_KEY=value123\n".data(using: .utf8)!) + try handle.close() + } catch { + Issue.record(error) + } + group.leave() + } + + // Read from pipe (blocks until writer connects) + let lines = try Parser.envFile(path: pipePath.path) + + // Wait for write to complete + group.wait() + + #expect(lines == ["SECRET_KEY=value123"]) + } + + // MARK: Network Parser Tests + + @Test + func testParseNetworkSimpleName() throws { + let result = try Parser.network("default") + #expect(result.name == "default") + #expect(result.macAddress == nil) + } + + @Test + func testParseNetworkWithMACAddress() throws { + let result = try Parser.network("backend,mac=02:42:ac:11:00:02") + #expect(result.name == "backend") + #expect(result.macAddress == "02:42:ac:11:00:02") + } + + @Test + func testParseNetworkWithMACAddressHyphenSeparator() throws { + let result = try Parser.network("backend,mac=02-42-ac-11-00-02") + #expect(result.name == "backend") + #expect(result.macAddress == "02-42-ac-11-00-02") + } + + @Test + func testParseNetworkEmptyString() throws { + #expect { + _ = try Parser.network("") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("network specification cannot be empty") + } + } + + @Test + func testParseNetworkEmptyName() throws { + #expect { + _ = try Parser.network(",mac=02:42:ac:11:00:02") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("network name cannot be empty") + } + } + + @Test + func testParseNetworkEmptyMACAddress() throws { + #expect { + _ = try Parser.network("backend,mac=") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("mac address value cannot be empty") + } + } + + @Test + func testParseNetworkUnknownProperty() throws { + #expect { + _ = try Parser.network("backend,unknown=value") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("unknown network property") && error.description.contains("unknown") + } + } + + @Test + func testParseNetworkInvalidPropertyFormat() throws { + #expect { + _ = try Parser.network("backend,invalidproperty") + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid property format") + } + } + + // MARK: - Relative Path Passthrough Tests + + @Test + func testProcessEntrypointRelativePathPassthrough() throws { + let processFlags = try Flags.Process.parse(["--cwd", "/bin"]) + let managementFlags = try Flags.Management.parse(["--entrypoint", "./uname"]) + + let result = try Parser.process( + arguments: [], + processFlags: processFlags, + managementFlags: managementFlags, + config: nil + ) + + #expect(result.executable == "./uname") + #expect(result.workingDirectory == "/bin") + } + + @Test + func testUlimitParserSoftAndHard() throws { + let result = try Parser.rlimits(["nofile=1024:2048"]) + #expect(result.count == 1) + #expect(result[0].limit == "RLIMIT_NOFILE") + #expect(result[0].soft == 1024) + #expect(result[0].hard == 2048) + } + + @Test + func testUlimitParserSingleValue() throws { + let result = try Parser.rlimits(["nproc=512"]) + #expect(result.count == 1) + #expect(result[0].limit == "RLIMIT_NPROC") + #expect(result[0].soft == 512) + #expect(result[0].hard == 512) + } + + @Test + func testUlimitParserUnlimited() throws { + let result = try Parser.rlimits(["memlock=unlimited"]) + #expect(result.count == 1) + #expect(result[0].limit == "RLIMIT_MEMLOCK") + #expect(result[0].soft == UInt64.max) + #expect(result[0].hard == UInt64.max) + } + + @Test + func testUlimitParserUnlimitedHardOnly() throws { + let result = try Parser.rlimits(["stack=8192:unlimited"]) + #expect(result.count == 1) + #expect(result[0].limit == "RLIMIT_STACK") + #expect(result[0].soft == 8192) + #expect(result[0].hard == UInt64.max) + } + + @Test + func testUlimitParserMinusOneAsUnlimited() throws { + let result = try Parser.rlimits(["core=-1"]) + #expect(result.count == 1) + #expect(result[0].limit == "RLIMIT_CORE") + #expect(result[0].soft == UInt64.max) + #expect(result[0].hard == UInt64.max) + } + + @Test + func testUlimitParserMultipleUlimits() throws { + let result = try Parser.rlimits(["nofile=1024:2048", "nproc=256", "cpu=60:120"]) + #expect(result.count == 3) + #expect(result[0].limit == "RLIMIT_NOFILE") + #expect(result[1].limit == "RLIMIT_NPROC") + #expect(result[2].limit == "RLIMIT_CPU") + } + + @Test + func testUlimitParserAllSupportedTypes() throws { + let types = ["core", "cpu", "data", "fsize", "memlock", "nofile", "nproc", "rss", "stack"] + let expectedRlimits = [ + "RLIMIT_CORE", "RLIMIT_CPU", "RLIMIT_DATA", "RLIMIT_FSIZE", + "RLIMIT_MEMLOCK", "RLIMIT_NOFILE", "RLIMIT_NPROC", "RLIMIT_RSS", "RLIMIT_STACK", + ] + + for (i, type) in types.enumerated() { + let result = try Parser.rlimits(["\(type)=100"]) + #expect(result.count == 1) + #expect(result[0].limit == expectedRlimits[i]) + } + } + + @Test + func testUlimitParserCaseInsensitive() throws { + let result = try Parser.rlimits(["NOFILE=1024", "Nproc=512"]) + #expect(result.count == 2) + #expect(result[0].limit == "RLIMIT_NOFILE") + #expect(result[1].limit == "RLIMIT_NPROC") + } + + @Test + func testUlimitParserInvalidFormat() throws { + #expect { + _ = try Parser.rlimits(["nofile"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid ulimit format") + } + } + + @Test + func testUlimitParserUnsupportedType() throws { + #expect { + _ = try Parser.rlimits(["foo=100"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("unsupported ulimit type") + } + } + + @Test + func testUlimitParserSoftExceedsHard() throws { + #expect { + _ = try Parser.rlimits(["nofile=2048:1024"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("soft limit") && error.description.contains("cannot exceed hard limit") + } + } + + @Test + func testUlimitParserDuplicateType() throws { + #expect { + _ = try Parser.rlimits(["nofile=1024", "nofile=2048"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("duplicate ulimit type") + } + } + + @Test + func testUlimitParserInvalidValue() throws { + #expect { + _ = try Parser.rlimits(["nofile=abc"]) + } throws: { error in + guard let error = error as? ContainerizationError else { + return false + } + return error.description.contains("invalid ulimit value") + } + } + + @Test + func testUlimitParserEmptyArray() throws { + let result = try Parser.rlimits([]) + #expect(result.isEmpty) + } + + @Test + func testUlimitParserZeroValue() throws { + let result = try Parser.rlimits(["core=0"]) + #expect(result.count == 1) + #expect(result[0].limit == "RLIMIT_CORE") + #expect(result[0].soft == 0) + #expect(result[0].hard == 0) + } + + @Test + func testUlimitParserLargeValues() throws { + let result = try Parser.rlimits(["nproc=\(UInt64.max - 1):\(UInt64.max)"]) + #expect(result.count == 1) + #expect(result[0].limit == "RLIMIT_NPROC") + #expect(result[0].soft == UInt64.max - 1) + #expect(result[0].hard == UInt64.max) + } + + // MARK: - Capabilities Parser Tests + + @Test + func testCapabilitiesParserEmpty() throws { + let result = try Parser.capabilities(capAdd: [], capDrop: []) + #expect(result.capAdd.isEmpty) + #expect(result.capDrop.isEmpty) + } + + @Test + func testCapabilitiesParserAddSingle() throws { + let result = try Parser.capabilities(capAdd: ["CAP_NET_RAW"], capDrop: []) + #expect(result.capAdd == ["CAP_NET_RAW"]) + #expect(result.capDrop.isEmpty) + } + + @Test + func testCapabilitiesParserDropSingle() throws { + let result = try Parser.capabilities(capAdd: [], capDrop: ["CAP_MKNOD"]) + #expect(result.capAdd.isEmpty) + #expect(result.capDrop == ["CAP_MKNOD"]) + } + + @Test + func testCapabilitiesParserWithoutPrefix() throws { + let result = try Parser.capabilities(capAdd: ["NET_RAW"], capDrop: ["MKNOD"]) + #expect(result.capAdd == ["CAP_NET_RAW"]) + #expect(result.capDrop == ["CAP_MKNOD"]) + } + + @Test + func testCapabilitiesParserCaseInsensitive() throws { + let result = try Parser.capabilities(capAdd: ["net_raw"], capDrop: ["mknod"]) + #expect(result.capAdd == ["CAP_NET_RAW"]) + #expect(result.capDrop == ["CAP_MKNOD"]) + } + + @Test + func testCapabilitiesParserLowercaseWithPrefix() throws { + let result = try Parser.capabilities(capAdd: ["cap_net_raw"], capDrop: []) + #expect(result.capAdd == ["CAP_NET_RAW"]) + } + + @Test + func testCapabilitiesParserALL() throws { + let result = try Parser.capabilities(capAdd: ["ALL"], capDrop: ["ALL"]) + #expect(result.capAdd == ["ALL"]) + #expect(result.capDrop == ["ALL"]) + } + + @Test + func testCapabilitiesParserDropALLWithAdd() throws { + let result = try Parser.capabilities(capAdd: ["CAP_NET_RAW", "CAP_MKNOD"], capDrop: ["ALL"]) + #expect(result.capAdd == ["CAP_NET_RAW", "CAP_MKNOD"]) + #expect(result.capDrop == ["ALL"]) + } + + @Test + func testCapabilitiesParserAddALLWithDrop() throws { + let result = try Parser.capabilities(capAdd: ["ALL"], capDrop: ["CAP_NET_ADMIN"]) + #expect(result.capAdd == ["ALL"]) + #expect(result.capDrop == ["CAP_NET_ADMIN"]) + } + + @Test + func testCapabilitiesParserMultiple() throws { + let result = try Parser.capabilities( + capAdd: ["CAP_NET_RAW", "CAP_SYS_ADMIN"], + capDrop: ["CAP_MKNOD", "CAP_CHOWN"] + ) + #expect(result.capAdd.count == 2) + #expect(result.capAdd.contains("CAP_NET_RAW")) + #expect(result.capAdd.contains("CAP_SYS_ADMIN")) + #expect(result.capDrop.count == 2) + #expect(result.capDrop.contains("CAP_MKNOD")) + #expect(result.capDrop.contains("CAP_CHOWN")) + } + + @Test + func testCapabilitiesParserInvalidAdd() throws { + #expect { + _ = try Parser.capabilities(capAdd: ["CHWOWZERS"], capDrop: []) + } throws: { _ in + true + } + } + + @Test + func testCapabilitiesParserInvalidDrop() throws { + #expect { + _ = try Parser.capabilities(capAdd: [], capDrop: ["CHWOWZERS"]) + } throws: { _ in + true + } + } + + // MARK: - Parser.resources + + @Test func testResourcesCustomDefaults() throws { + let result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: 2, defaultMemory: try MemorySize("2048MB") + ) + #expect(result.cpus == 2) + #expect(result.memoryInBytes == 2048.mib()) + } + + @Test func testResourcesFlagOverridesDefaults() throws { + let result = try Parser.resources(cpus: 1, memory: "256m", defaultCPUs: 8, defaultMemory: MemorySize("2g")) + #expect(result.cpus == 1) + #expect(result.memoryInBytes == 256.mib()) + } + + @Test func testResourcesBuildPropertyLookup() async throws { + let content = """ + [build] + cpus = 8 + memory = "4g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-build-lookup.toml") + FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) + defer { try? FileManager.default.removeItem(at: tempFile) } + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))]) + let result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.build.cpus, + defaultMemory: config.build.memory + ) + #expect(result.cpus == 8) + #expect(result.memoryInBytes == 4096.mib()) + } + + @Test func testResourcesCPUsFromProperty() async throws { + let content = """ + [container] + cpus = 8 + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-cpus-property.toml") + FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) + defer { try? FileManager.default.removeItem(at: tempFile) } + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))]) + let result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.container.cpus, + defaultMemory: config.container.memory + ) + #expect(result.cpus == 8) + } + + @Test func testResourcesMemoryFromProperty() async throws { + let content = """ + [container] + memory = "2g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-memory-property.toml") + FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) + defer { try? FileManager.default.removeItem(at: tempFile) } + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))]) + let result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.container.cpus, + defaultMemory: config.container.memory + ) + #expect(result.memoryInBytes == 2048.mib()) + } + + @Test func testResourcesFlagOverridesProperty() async throws { + let content = """ + [container] + cpus = 8 + memory = "2g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-flag-overrides.toml") + FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) + defer { try? FileManager.default.removeItem(at: tempFile) } + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))]) + let result = try Parser.resources( + cpus: 1, memory: "256m", + defaultCPUs: config.container.cpus, + defaultMemory: config.container.memory + ) + #expect(result.cpus == 1) + #expect(result.memoryInBytes == 256.mib()) + } + + @Test func testResourcesPropertyKeysAreIsolated() async throws { + let content = """ + [container] + cpus = 16 + memory = "8g" + """ + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test-keys-isolated.toml") + FileManager.default.createFile(atPath: tempFile.path(), contents: Data(content.utf8)) + defer { try? FileManager.default.removeItem(at: tempFile) } + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [FilePath(tempFile.path(percentEncoded: false))]) + let result = try Parser.resources( + cpus: nil, memory: nil, + defaultCPUs: config.build.cpus, + defaultMemory: config.build.memory + ) + #expect(result.cpus == 2) + #expect(result.memoryInBytes == 2048.mib()) + } + + // MARK: - DNS Flag Validation Tests + + @Test + func testManagementFlagsRejectsNoDNSWithDNS() throws { + #expect(throws: (any Error).self) { + _ = try Flags.Management.parse(["--dns", "1.1.1.1", "--no-dns"]) + } + } + + @Test + func testManagementFlagsRejectsNoDNSWithDNSDomain() throws { + #expect(throws: (any Error).self) { + _ = try Flags.Management.parse(["--dns-domain", "example.com", "--no-dns"]) + } + } + + @Test + func testManagementFlagsRejectsNoDNSWithDNSSearch() throws { + #expect(throws: (any Error).self) { + _ = try Flags.Management.parse(["--dns-search", "example.com", "--no-dns"]) + } + } + + @Test + func testManagementFlagsRejectsNoDNSWithDNSOption() throws { + #expect(throws: (any Error).self) { + _ = try Flags.Management.parse(["--dns-option", "debug", "--no-dns"]) + } + } + + @Test + func testManagementFlagsAcceptsDNSAlone() throws { + _ = try Flags.Management.parse(["--dns", "1.1.1.1"]) + } + + @Test + func testManagementFlagsAcceptsNoDNSAlone() throws { + _ = try Flags.Management.parse(["--no-dns"]) + } + + // MARK: - Collection capacity hints + + @Test("labels with large input preserves all entries") + func testLabelsLargeInput() throws { + let labels = (0..<100).map { "key\($0)=value\($0)" } + let result = try Parser.labels(labels) + #expect(result.count == 100) + #expect(result["key42"] == "value42") + #expect(result["key99"] == "value99") + } + + @Test("resolve with large input preserves all entries") + func testParseKeyValuePairsLargeInput() { + let pairs = (0..<100).map { "key\($0)=value\($0)" } + let result = Utility.parseKeyValuePairs(pairs) + #expect(result.count == 100) + #expect(result["key0"] == "value0") + #expect(result["key99"] == "value99") + } + + @Test("tmpfsMounts with large input") + func testTmpfsMountsLargeInput() throws { + let mounts = (0..<20).map { "/mnt/tmpfs\($0)" } + let result = try Parser.tmpfsMounts(mounts) + #expect(result.count == 20) + } + + @Test("volumes with large input") + func testVolumesLargeInput() throws { + let volumes = (0..<20).map { "vol\($0):/mnt/vol\($0)" } + let result = try Parser.volumes(volumes) + #expect(result.count == 20) + } + + @Test("capabilities with large input") + func testCapabilitiesLargeInput() throws { + let result = try Parser.capabilities(capAdd: ["ALL", "SYS_ADMIN", "NET_RAW", "CHOWN"], capDrop: ["SETUID", "KILL"]) + #expect(result.capAdd.count == 4) + #expect(result.capDrop.count == 2) + #expect(result.capAdd.first == "ALL") + } + + @Test("rlimits with large input") + func testRlimitsLargeInput() throws { + let result = try Parser.rlimits(["nofile=1024:2048", "nproc=100:200", "memlock=65536:65536"]) + #expect(result.count == 3) + #expect(result[0].limit == "RLIMIT_NOFILE") + } + + @Test("allEnv with large env lists") + func testAllEnvLargeInput() throws { + let imageEnvs = (0..<50).map { "IMAGE_VAR\($0)=value\($0)" } + let envs = (0..<50).map { "USER_VAR\($0)=value\($0)" } + let result = try Parser.allEnv(imageEnvs: imageEnvs, envFiles: [], envs: envs) + #expect(result.count == 100) + } +} diff --git a/Tests/ContainerAPIClientTests/RequestSchemeTests.swift b/Tests/ContainerAPIClientTests/RequestSchemeTests.swift new file mode 100644 index 0000000..69ef1ce --- /dev/null +++ b/Tests/ContainerAPIClientTests/RequestSchemeTests.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerAPIClient + +struct RequestSchemeTests { + static let defaultDnsDomain = "test" + + internal struct TestArg { + let scheme: String + let host: String + let expected: RequestScheme + } + + @Test(arguments: [ + TestArg(scheme: "http", host: "myregistry.io", expected: .http), + TestArg(scheme: "https", host: "myregistry.io", expected: .https), + TestArg(scheme: "auto", host: "myregistry.io", expected: .https), + TestArg(scheme: "https", host: "localhost", expected: .https), + TestArg(scheme: "http", host: "localhost", expected: .http), + TestArg(scheme: "auto", host: "localhost", expected: .http), + TestArg(scheme: "auto", host: "localhost.evil.com", expected: .https), + TestArg(scheme: "http", host: "127.0.0.1", expected: .http), + TestArg(scheme: "https", host: "127.0.0.1", expected: .https), + TestArg(scheme: "auto", host: "127.0.0.1", expected: .http), + TestArg(scheme: "auto", host: "127.255.255.255", expected: .http), + TestArg(scheme: "auto", host: "127.0.0.1.evil.com", expected: .https), + TestArg(scheme: "https", host: "10.3.4.1", expected: .https), + TestArg(scheme: "auto", host: "10.3.4.1", expected: .http), + TestArg(scheme: "auto", host: "10.255.255.255", expected: .http), + TestArg(scheme: "auto", host: "10.0.0.1.evil.com", expected: .https), + TestArg(scheme: "auto", host: "192.168.0.1", expected: .http), + TestArg(scheme: "auto", host: "192.168.255.255", expected: .http), + TestArg(scheme: "auto", host: "192.169.0.1", expected: .https), + TestArg(scheme: "auto", host: "192.168.1.1.evil.com", expected: .https), + TestArg(scheme: "auto", host: "some-dns-name.io", expected: .https), + TestArg(scheme: "auto", host: "172.32.0.1", expected: .https), + TestArg(scheme: "auto", host: "172.22.23.61", expected: .http), + TestArg(scheme: "auto", host: "172.16.0.0", expected: .http), + TestArg(scheme: "auto", host: "172.31.255.255", expected: .http), + ]) + + func testIsConnectionSecure(arg: TestArg) throws { + let requestScheme = RequestScheme(rawValue: arg.scheme)! + #expect(try requestScheme.schemeFor(host: arg.host, internalDnsDomain: Self.defaultDnsDomain) == arg.expected) + } + + @Test func testEmptyHostThrowsError() throws { + #expect(throws: (any Error).self) { + let requestScheme = RequestScheme(rawValue: "https")! + _ = try requestScheme.schemeFor(host: "", internalDnsDomain: Self.defaultDnsDomain) + } + } + + @Test func testIsInternalHostWithDefaultDNSDomain() throws { + let hostName = "some-dns-name.io.\(Self.defaultDnsDomain)" + #expect(RequestScheme.isInternalHost(host: hostName, internalDnsDomain: Self.defaultDnsDomain)) + } +} diff --git a/Tests/ContainerAPIClientTests/UtilityTests.swift b/Tests/ContainerAPIClientTests/UtilityTests.swift new file mode 100644 index 0000000..3a9b3a4 --- /dev/null +++ b/Tests/ContainerAPIClientTests/UtilityTests.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationError +import Foundation +import Testing + +@testable import ContainerAPIClient + +struct UtilityTests { + + @Test("Parse simple key-value pairs") + func testSimpleKeyValuePairs() { + let result = Utility.parseKeyValuePairs(["key1=value1", "key2=value2"]) + + #expect(result["key1"] == "value1") + #expect(result["key2"] == "value2") + } + + @Test("Parse standalone keys") + func testStandaloneKeys() { + let result = Utility.parseKeyValuePairs(["standalone"]) + + #expect(result["standalone"] == "") + } + + @Test("Parse empty input") + func testEmptyInput() { + let result = Utility.parseKeyValuePairs([]) + + #expect(result.isEmpty) + } + + @Test("Parse mixed format") + func testMixedFormat() { + let result = Utility.parseKeyValuePairs(["key1=value1", "standalone", "key2=value2"]) + + #expect(result["key1"] == "value1") + #expect(result["standalone"] == "") + #expect(result["key2"] == "value2") + } + + @Test("Valid MAC address with colons") + func testValidMACAddressWithColons() throws { + try Utility.validMACAddress("02:42:ac:11:00:02") + try Utility.validMACAddress("AA:BB:CC:DD:EE:FF") + try Utility.validMACAddress("00:00:00:00:00:00") + try Utility.validMACAddress("ff:ff:ff:ff:ff:ff") + } + + @Test("Valid MAC address with hyphens") + func testValidMACAddressWithHyphens() throws { + try Utility.validMACAddress("02-42-ac-11-00-02") + try Utility.validMACAddress("AA-BB-CC-DD-EE-FF") + } + + @Test("Invalid MAC address format") + func testInvalidMACAddressFormat() { + #expect(throws: Error.self) { + try Utility.validMACAddress("invalid") + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02:42:ac:11:00") // Too short + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02:42:ac:11:00:02:03") // Too long + } + #expect(throws: Error.self) { + try Utility.validMACAddress("ZZ:ZZ:ZZ:ZZ:ZZ:ZZ") // Invalid hex + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02:42:ac:11:00:") // Incomplete + } + #expect(throws: Error.self) { + try Utility.validMACAddress("02.42.ac.11.00.02") // Wrong separator + } + } + + @Test("Trim fully-qualified digest strips scheme and truncates to 12 chars") + func testTrimDigestFullyQualified() { + let hex = "0be69a25c33692845efb1e93f4254f28505a330896376bf8" + #expect(Utility.trimDigest(digest: "sha256:\(hex)") == String(hex.prefix(12))) + } + + @Test("Trim digest with unknown scheme strips scheme prefix") + func testTrimDigestUnknownScheme() { + let hex = "abcdef123456789012345678" + #expect(Utility.trimDigest(digest: "blake3:\(hex)") == String(hex.prefix(12))) + } + + @Test("Trim digest with no scheme truncates directly") + func testTrimDigestNoScheme() { + let hex = "abcdef1234567890" + #expect(Utility.trimDigest(digest: hex) == String(hex.prefix(12))) + } + + @Test("Trim digest shorter than 12 chars returns value unchanged") + func testTrimDigestShort() { + #expect(Utility.trimDigest(digest: "sha256:abc") == "abc") + } + + @Test + func testPublishPortParser() throws { + let ports = try Parser.publishPorts([ + "127.0.0.1:8000:9080", + "8080-8179:9000-9099/udp", + ]) + #expect(ports.count == 2) + #expect(ports[0].hostAddress.description == "127.0.0.1") + #expect(ports[0].hostPort == 8000) + #expect(ports[0].containerPort == 9080) + #expect(ports[0].proto == .tcp) + #expect(ports[0].count == 1) + #expect(ports[1].hostAddress.description == "0.0.0.0") + #expect(ports[1].hostPort == 8080) + #expect(ports[1].containerPort == 9000) + #expect(ports[1].proto == .udp) + #expect(ports[1].count == 100) + } +} diff --git a/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift b/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift new file mode 100644 index 0000000..a1529d3 --- /dev/null +++ b/Tests/ContainerAPIServiceTests/RuntimeConfigurationTests.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerRuntimeClient +import ContainerRuntimeLinuxClient +import Containerization +import Foundation +import Testing + +/// Unit tests for RuntimeConfiguration functionality. +/// +/// These tests verify the runtime configuration serialization and deserialization, +/// ensuring that configuration can be properly written, read, and used to create bundles. +struct RuntimeConfigurationTests { + + /// Test that reading non-existent runtime configuration file throws + /// appropriate error + @Test + func testReadNonExistentRuntimeConfiguration() throws { + let tempDir = FileManager.default.temporaryDirectory + let nonExistentPath = tempDir.appendingPathComponent("non-existent-\(UUID()).json") + + #expect(throws: Error.self) { + _ = try RuntimeConfiguration.readRuntimeConfiguration(from: nonExistentPath) + } + } + + /// Test that runtime configuration reads and writes as expected + @Test + func testRuntimeConfigurationReadWrite() throws { + let tempDir = FileManager.default.temporaryDirectory + let bundlePath = tempDir.appendingPathComponent("test-bundle-\(UUID())") + + defer { + try? FileManager.default.removeItem(at: bundlePath) + } + + let initFs = Filesystem.virtiofs( + source: "/path/to/initfs", + destination: "/", + options: ["ro"] + ) + + let kernel = Kernel( + path: URL(fileURLWithPath: "/path/to/kernel"), + platform: .linuxArm + ) + + let runtimeConfig = RuntimeConfiguration( + path: bundlePath, + initialFilesystem: initFs, + kernel: kernel, + containerConfiguration: nil, + containerRootFilesystem: nil, + options: nil + ) + + try runtimeConfig.writeRuntimeConfiguration() + + let readRuntimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + + #expect( + readRuntimeConfig.path == bundlePath, + "Path should match") + #expect( + readRuntimeConfig.kernel.path == kernel.path, + "Kernel path should match") + #expect( + readRuntimeConfig.initialFilesystem.source == initFs.source, + "Initial filesystem source should match") + #expect( + readRuntimeConfig.containerConfiguration == nil, + "Container configuration should be nil") + #expect( + readRuntimeConfig.containerRootFilesystem == nil, + "Root filesystem should be nil") + #expect( + readRuntimeConfig.options == nil, + "Options should be nil") + } + + @Test + func testRuntimeConfigurationWithVariant() throws { + let tempDir = FileManager.default.temporaryDirectory + let bundlePath = tempDir.appendingPathComponent("test-bundle-\(UUID())") + + defer { + try? FileManager.default.removeItem(at: bundlePath) + } + + let initFs = Filesystem.virtiofs( + source: "/path/to/initfs", + destination: "/", + options: ["ro"] + ) + + let kernel = Kernel( + path: URL(fileURLWithPath: "/path/to/kernel"), + platform: .linuxArm + ) + + let linuxData = LinuxRuntimeData(variant: "test-variant") + let encodedData = try JSONEncoder().encode(linuxData) + + let runtimeConfig = RuntimeConfiguration( + path: bundlePath, + initialFilesystem: initFs, + kernel: kernel, + runtimeData: encodedData + ) + + try runtimeConfig.writeRuntimeConfiguration() + + let readRuntimeConfig = try RuntimeConfiguration.readRuntimeConfiguration(from: bundlePath) + + #expect(readRuntimeConfig.runtimeData != nil, "runtimeData should be persisted") + + let decodedData = try JSONDecoder().decode(LinuxRuntimeData.self, from: readRuntimeConfig.runtimeData!) + #expect(decodedData.variant == "test-variant", "Variant should round-trip through RuntimeConfiguration") + } +} diff --git a/Tests/ContainerBuildTests/BuildFile.swift b/Tests/ContainerBuildTests/BuildFile.swift new file mode 100644 index 0000000..662b04e --- /dev/null +++ b/Tests/ContainerBuildTests/BuildFile.swift @@ -0,0 +1,122 @@ +//===----------------------------------------------------------------------===// +// 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 +import Testing + +@testable import ContainerBuild + +@Suite class BuildFileResolvePathTests { + private var baseTempURL: URL + private let fileManager = FileManager.default + + init() throws { + self.baseTempURL = URL.temporaryDirectory + .appendingPathComponent("BuildFileTests-\(UUID().uuidString)") + try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil) + } + + deinit { + try? fileManager.removeItem(at: baseTempURL) + } + + private func createFile(at url: URL, content: String = "") throws { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + let created = fileManager.createFile( + atPath: url.path, + contents: content.data(using: .utf8), + attributes: nil + ) + try #require(created) + } + + @Test func testResolvePathFindsDockerfile() throws { + let contextDir = baseTempURL.path + let dockerfilePath = baseTempURL.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM alpine") + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == dockerfilePath.path) + } + + @Test func testResolvePathFindsContainerfile() throws { + let contextDir = baseTempURL.path + let containerfilePath = baseTempURL.appendingPathComponent("Containerfile") + try createFile(at: containerfilePath, content: "FROM alpine") + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == containerfilePath.path) + } + + @Test func testResolvePathPrefersDockerfileWhenBothExist() throws { + let contextDir = baseTempURL.path + let dockerfilePath = baseTempURL.appendingPathComponent("Dockerfile") + let containerfilePath = baseTempURL.appendingPathComponent("Containerfile") + try createFile(at: dockerfilePath, content: "FROM alpine") + try createFile(at: containerfilePath, content: "FROM ubuntu") + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == dockerfilePath.path) + } + + @Test func testResolvePathReturnsNilWhenNoFilesExist() throws { + let contextDir = baseTempURL.path + + let result = try BuildFile.resolvePath(contextDir: contextDir) + + #expect(result == nil) + } + + @Test func testResolvePathWithEmptyDirectory() throws { + let emptyDir = baseTempURL.appendingPathComponent("empty") + try fileManager.createDirectory(at: emptyDir, withIntermediateDirectories: true, attributes: nil) + + let result = try BuildFile.resolvePath(contextDir: emptyDir.path) + + #expect(result == nil) + } + + @Test func testResolvePathWithNestedContextDirectory() throws { + let nestedDir = baseTempURL.appendingPathComponent("project/build") + try fileManager.createDirectory(at: nestedDir, withIntermediateDirectories: true, attributes: nil) + let dockerfilePath = nestedDir.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM node") + + let result = try BuildFile.resolvePath(contextDir: nestedDir.path) + + #expect(result == dockerfilePath.path) + } + + @Test func testResolvePathWithRelativeContextDirectory() throws { + let nestedDir = baseTempURL.appendingPathComponent("project") + try fileManager.createDirectory(at: nestedDir, withIntermediateDirectories: true, attributes: nil) + let dockerfilePath = nestedDir.appendingPathComponent("Dockerfile") + try createFile(at: dockerfilePath, content: "FROM python") + + // Test with the absolute path + let result = try BuildFile.resolvePath(contextDir: nestedDir.path) + + #expect(result == dockerfilePath.path) + } +} diff --git a/Tests/ContainerBuildTests/BuilderExtensionsTests.swift b/Tests/ContainerBuildTests/BuilderExtensionsTests.swift new file mode 100644 index 0000000..05789d2 --- /dev/null +++ b/Tests/ContainerBuildTests/BuilderExtensionsTests.swift @@ -0,0 +1,337 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerBuild + +@Suite class URLExtensionFileSystemTests { + + private var baseTempURL: URL! + private let fileManager = FileManager.default + + init() throws { + baseTempURL = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("URLExtensionTests-\(UUID().uuidString)") + try fileManager.createDirectory(at: baseTempURL, withIntermediateDirectories: true, attributes: nil) + } + + deinit { + if let baseTempURL = baseTempURL { + try? fileManager.removeItem(at: baseTempURL) + } + } + + // MARK: - Helpers + + private func createDirectory(at url: URL) throws { + try fileManager.createDirectory(at: url, withIntermediateDirectories: true, attributes: nil) + } + + private func createFile(at url: URL, content: String = "") throws { + try fileManager.createDirectory( + at: url.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil) + #expect( + fileManager.createFile( + atPath: url.path, + contents: content.data(using: .utf8), + attributes: nil)) + } + + // MARK: - parentOf Tests + + @Test func testParentOfDirectParent() throws { + let parentDir = baseTempURL.appendingPathComponent("dir1") + let childDir = parentDir.appendingPathComponent("dir2") + try createDirectory(at: childDir) + #expect(parentDir.parentOf(childDir)) + } + + @Test func testParentOfGrandparent() throws { + let grandParent = baseTempURL.appendingPathComponent("dir3").appendingPathComponent("test") + let childDir = grandParent.appendingPathComponent("dir4").appendingPathComponent("dir2") + try createDirectory(at: childDir) + #expect(grandParent.parentOf(childDir)) + } + + @Test func testParentOfBaseTemp() throws { + let childDir = baseTempURL.appendingPathComponent("dir4").appendingPathComponent("dir2") + try createDirectory(at: childDir) + #expect(baseTempURL.parentOf(childDir)) + } + + @Test func testParentOfRoot() throws { + let rootURL = URL(fileURLWithPath: "/") + let childDir = baseTempURL.appendingPathComponent("dir4") + try createDirectory(at: childDir) + #expect(rootURL.parentOf(childDir)) + #expect(rootURL.parentOf(baseTempURL)) + } + + @Test func testParentOfSamePath() throws { + let dir = baseTempURL.appendingPathComponent("dir4") + try createDirectory(at: dir) + let sameDir = URL(fileURLWithPath: dir.path) + #expect(dir.parentOf(sameDir)) + #expect(sameDir.parentOf(dir)) + } + + @Test func testParentOfRootToRoot() { + let root1 = URL(fileURLWithPath: "/") + let root2 = URL(fileURLWithPath: "/") + #expect(root1.parentOf(root2)) + } + + @Test func testParentOfDifferentPaths() throws { + let dir1 = + baseTempURL + .appendingPathComponent("dir3") + .appendingPathComponent("test") + .appendingPathComponent("dir4") + let dir2 = + baseTempURL + .appendingPathComponent("dir3") + .appendingPathComponent("another") + .appendingPathComponent("file") + try createDirectory(at: dir1) + try createDirectory(at: dir2) + #expect(false == dir1.parentOf(dir2)) + #expect(false == dir2.parentOf(dir1)) + } + + @Test func testParentOfSiblingPaths() throws { + let parentDir = baseTempURL.appendingPathComponent("dir3").appendingPathComponent("test") + let sibling1 = parentDir.appendingPathComponent("dir4") + let sibling2 = parentDir.appendingPathComponent("dir5") + try createDirectory(at: sibling1) + try createDirectory(at: sibling2) + #expect(false == sibling1.parentOf(sibling2)) + #expect(false == sibling2.parentOf(sibling1)) + } + + @Test func testParentOfChildIsParentFalse() throws { + let parentDir = baseTempURL.appendingPathComponent("dir4") + let childDir = parentDir.appendingPathComponent("dir2") + try createDirectory(at: childDir) + #expect(false == childDir.parentOf(parentDir)) + } + + @Test func testParentOfPartialNameMatch() throws { + let partial = baseTempURL.appendingPathComponent("Doc") + let actualDir = baseTempURL.appendingPathComponent("dir4") + try createDirectory(at: actualDir) + #expect(false == partial.parentOf(actualDir)) + } + + @Test func testParentOfPathNormalization() throws { + let parentDir = baseTempURL.appendingPathComponent("dir4") + let childDir = parentDir.appendingPathComponent("dir2") + try createDirectory(at: childDir) + let normalized = + baseTempURL + .appendingPathComponent("dir8") + .appendingPathComponent("..") + .appendingPathComponent("dir4") + #expect(normalized.parentOf(childDir)) + } + + @Test func testParentOfChildWithNormalization() throws { + let parentDir = baseTempURL.appendingPathComponent("dir4") + let targetChildDir = parentDir.appendingPathComponent("dir2") + try createDirectory(at: targetChildDir) + let normalizedChild = + parentDir + .appendingPathComponent("dir9") + .appendingPathComponent("..") + .appendingPathComponent("dir2") + #expect(parentDir.parentOf(normalizedChild)) + } + + @Test func testParentOfPercentEncoding() throws { + let parentDir = baseTempURL.appendingPathComponent("My dir4") + let childDir = parentDir.appendingPathComponent("dir2 X") + try createDirectory(at: childDir) + let parentEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4") + let childEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4/dir2%20X") + #expect(parentDir.parentOf(childDir)) + #expect(parentEncoded.parentOf(childEncoded)) + #expect(parentDir.parentOf(childEncoded)) + #expect(parentEncoded.parentOf(childDir)) + } + + @Test func testParentOfNonFileURL() throws { + let httpURL = URL(string: "http://example.com/path")! + let fileURL = baseTempURL.appendingPathComponent("file") + try createFile(at: fileURL) + #expect(false == httpURL.parentOf(fileURL)) + #expect(false == fileURL.parentOf(httpURL)) + } + + @Test func testRelativeChildPathDirectChild() throws { + let parentDir = baseTempURL.appendingPathComponent("dir1") + let childFile = parentDir.appendingPathComponent("dir2").appendingPathComponent("file") + try createFile(at: childFile) + let relative = try childFile.relativeChildPath(to: parentDir) + #expect(relative == "dir2/file") + } + + @Test func testRelativeChildPathDeeperChild() throws { + let parentDir = baseTempURL.appendingPathComponent("dir3").appendingPathComponent("test") + let childFile = parentDir.appendingPathComponent("dir4/dir2/file") + try createFile(at: childFile) + let relative = try childFile.relativeChildPath(to: parentDir) + #expect(relative == "dir4/dir2/file") + } + + @Test func testRelativeChildPathDirectlyInsideBase() throws { + let childFile = baseTempURL.appendingPathComponent("file") + try createFile(at: childFile) + let relative = try childFile.relativeChildPath(to: baseTempURL) + #expect(relative == "file") + } + + @Test func testRelativeChildPathSamePath() throws { + let dir = baseTempURL.appendingPathComponent("dir4") + try createDirectory(at: dir) + let dirCopy = URL(fileURLWithPath: dir.path) + #expect(try dir.relativeChildPath(to: dirCopy) == "") + #expect(try dirCopy.relativeChildPath(to: dir) == "") + } + + @Test func testRelativeChildPathRootChild() throws { + let rootURL = URL(fileURLWithPath: "/") + let childDir = baseTempURL.appendingPathComponent("dir4") + try createDirectory(at: childDir) + + // Compare only the portion that comes after "/" + let expected = + baseTempURL + .standardizedFileURL + .pathComponents + .dropFirst() // remove "/" + .joined(separator: "/") + "/dir4" + + let relative = try childDir.relativeChildPath(to: rootURL) + #expect(relative == expected) + } + + @Test func testRelativeChildPathRootToRootIsEmpty() throws { + let root1 = URL(fileURLWithPath: "/") + let root2 = URL(fileURLWithPath: "/") + #expect(try root1.relativeChildPath(to: root2) == "") + } + + @Test func testRelativeChildPathNormalization() throws { + let parentDir = baseTempURL.appendingPathComponent("dir4") + let childFile = parentDir.appendingPathComponent("dir2/file") + try createFile(at: childFile) + let normalizedParent = + baseTempURL + .appendingPathComponent("dir8") + .appendingPathComponent("..") + .appendingPathComponent("dir4") + #expect(try childFile.relativeChildPath(to: normalizedParent) == "dir2/file") + } + + @Test func testRelativeChildPathNormalizedChild() throws { + let parentDir = baseTempURL.appendingPathComponent("dir4") + let childFile = parentDir.appendingPathComponent("dir2/file") + try createFile(at: childFile) + let normalizedChild = + parentDir + .appendingPathComponent("dir9") + .appendingPathComponent("..") + .appendingPathComponent("dir2") + .appendingPathComponent("file") + #expect(try normalizedChild.relativeChildPath(to: parentDir) == "dir2/file") + } + + @Test func testRelativeChildPathPercentEncoding() throws { + let parentDir = baseTempURL.appendingPathComponent("My dir4") + let childFile = parentDir.appendingPathComponent("dir2 X/file1") + try createFile(at: childFile) + #expect(try childFile.relativeChildPath(to: parentDir) == "dir2 X/file1") + + let parentEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4") + let childEncoded = URL(fileURLWithPath: baseTempURL.path + "/My%20dir4/dir2%20X/file1") + + #expect(try childEncoded.relativeChildPath(to: parentDir) == "dir2 X/file1") + #expect(try childEncoded.relativeChildPath(to: parentEncoded) == "dir2 X/file1") + } + + // MARK: - relativeChildPath Error Tests + + @Test func testRelativeChildPathThrowsWhenNotAChild() throws { + let parentDir = baseTempURL.appendingPathComponent("dir4") + let otherDir = baseTempURL.appendingPathComponent("dir7/file") + try createDirectory(at: parentDir) + try createDirectory(at: otherDir) + + #expect(throws: (BuildFSSync.Error.pathIsNotChild(otherDir.cleanPath, parentDir.cleanPath)).self) { + try otherDir.relativeChildPath(to: parentDir) + } + } + + @Test func testRelativeChildPathThrowsForSiblings() throws { + let parentDir = baseTempURL.appendingPathComponent("dir3/test") + let sibling1 = parentDir.appendingPathComponent("dir4") + let sibling2 = parentDir.appendingPathComponent("dir5") + try createDirectory(at: sibling1) + try createDirectory(at: sibling2) + #expect(throws: (BuildFSSync.Error.pathIsNotChild(sibling2.cleanPath, sibling1.cleanPath)).self) { + try sibling2.relativeChildPath(to: sibling1) + } + } + + @Test func testRelativeChildPathParentAsChildThrows() throws { + let parentDir = baseTempURL.appendingPathComponent("dir4") + let childDir = parentDir.appendingPathComponent("dir2") + try createDirectory(at: childDir) + #expect(throws: (BuildFSSync.Error.pathIsNotChild(parentDir.cleanPath, childDir.cleanPath)).self) { + try parentDir.relativeChildPath(to: childDir) + } + } + + // MARK: - cleanPath Tests + + @Test func testCleanPathSimple() throws { + let file = baseTempURL.appendingPathComponent("file") + try createFile(at: file) + #expect(file.cleanPath.hasSuffix("/file")) + #expect(file.cleanPath.contains(baseTempURL.lastPathComponent)) + } + + @Test func testCleanPathWithSpaces() throws { + let file = baseTempURL.appendingPathComponent("my file with spaces") + try createFile(at: file) + #expect(file.cleanPath.hasSuffix("/my file with spaces")) + #expect(file.cleanPath.contains(baseTempURL.lastPathComponent)) + } + + @Test func testCleanPathWithPercentEncoding() throws { + let fileWithSpace = baseTempURL.appendingPathComponent("my file") + try createFile(at: fileWithSpace) + + let encodedPathString = baseTempURL.path + "/my%20file" + let urlFromString = URL(fileURLWithPath: encodedPathString) + + #expect(urlFromString.cleanPath == fileWithSpace.cleanPath) + #expect(urlFromString.cleanPath.hasSuffix("/my file")) + } +} diff --git a/Tests/ContainerBuildTests/GlobberTests.swift b/Tests/ContainerBuildTests/GlobberTests.swift new file mode 100644 index 0000000..fff0fc2 --- /dev/null +++ b/Tests/ContainerBuildTests/GlobberTests.swift @@ -0,0 +1,205 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerBuild + +struct TestCase { + let pattern: String + let fileName: String + let expectSuccess: Bool +} + +// test cases adapted from https://github.com/moby/patternmatcher/tree/main +let globTestCases = [ + TestCase(pattern: "*", fileName: "test.go", expectSuccess: true), + TestCase(pattern: "**", fileName: "test.go", expectSuccess: true), + TestCase(pattern: "**", fileName: "file", expectSuccess: true), + TestCase(pattern: "*.go", fileName: "test.go", expectSuccess: true), + TestCase(pattern: "a.|)$(}+{bc", fileName: "a.|)$(}+{bc", expectSuccess: true), + TestCase(pattern: "abc.def", fileName: "abcdef", expectSuccess: false), + TestCase(pattern: "abc.def", fileName: "abc.def", expectSuccess: true), + TestCase(pattern: "abc.def", fileName: "abcZdef", expectSuccess: false), + TestCase(pattern: "abc?def", fileName: "abcZdef", expectSuccess: true), + TestCase(pattern: "abc?def", fileName: "abcdef", expectSuccess: false), + TestCase(pattern: "a[b-d]e", fileName: "ae", expectSuccess: false), + TestCase(pattern: "a[b-d]e", fileName: "ace", expectSuccess: true), + TestCase(pattern: "a[b-d]e", fileName: "aae", expectSuccess: false), + TestCase(pattern: "a[^b-d]e", fileName: "aze", expectSuccess: true), + TestCase(pattern: "a[\\^b-d]e", fileName: "abe", expectSuccess: true), + TestCase(pattern: "a[\\^b-d]e", fileName: "aze", expectSuccess: false), +] + +let errorGlobTestCases = [ + TestCase(pattern: "[]a]", fileName: "]", expectSuccess: true), + TestCase(pattern: "[", fileName: "a", expectSuccess: true), + TestCase(pattern: "[^", fileName: "a", expectSuccess: true), + TestCase(pattern: "[^bc", fileName: "a", expectSuccess: true), + TestCase(pattern: "a[", fileName: "a", expectSuccess: true), + TestCase(pattern: "a[", fileName: "ab", expectSuccess: true), +] + +let testCases = [ + TestCase(pattern: "*", fileName: "test/test.go", expectSuccess: true), + TestCase(pattern: "**.go", fileName: "test/test.go", expectSuccess: true), + TestCase(pattern: "**file", fileName: "test/file", expectSuccess: true), + TestCase(pattern: "**/*", fileName: "test/test.go", expectSuccess: true), + TestCase(pattern: "**/", fileName: "file", expectSuccess: true), + TestCase(pattern: "**/", fileName: "file/", expectSuccess: true), + TestCase(pattern: "**", fileName: "file", expectSuccess: true), + TestCase(pattern: "**", fileName: "file/", expectSuccess: true), + TestCase(pattern: "**", fileName: "dir/file", expectSuccess: true), + TestCase(pattern: "**/", fileName: "dir/file", expectSuccess: true), + TestCase(pattern: "**", fileName: "dir/file/", expectSuccess: true), + TestCase(pattern: "**/", fileName: "dir/file/", expectSuccess: true), + TestCase(pattern: "**/**", fileName: "dir/file", expectSuccess: true), + TestCase(pattern: "**/**", fileName: "dir/file/", expectSuccess: true), + TestCase(pattern: "dir/**", fileName: "dir/file", expectSuccess: true), + TestCase(pattern: "dir/**", fileName: "dir/file/", expectSuccess: true), + TestCase(pattern: "dir/**", fileName: "dir/dir2/file", expectSuccess: true), + TestCase(pattern: "dir/**", fileName: "dir/dir2/file/", expectSuccess: true), + TestCase(pattern: "**/dir", fileName: "dir", expectSuccess: true), + TestCase(pattern: "**/dir", fileName: "dir/file", expectSuccess: true), + TestCase(pattern: "**/dir2/*", fileName: "dir/dir2/file", expectSuccess: true), + TestCase(pattern: "**/dir2/*", fileName: "dir/dir2/file/", expectSuccess: true), + TestCase(pattern: "**/dir2/**", fileName: "dir/dir2/dir3/file", expectSuccess: true), + TestCase(pattern: "**/dir2/**", fileName: "dir/dir2/dir3/file/", expectSuccess: true), + TestCase(pattern: "**file", fileName: "file", expectSuccess: true), + TestCase(pattern: "**file", fileName: "dir/file", expectSuccess: true), + TestCase(pattern: "**/file", fileName: "dir/file", expectSuccess: true), + TestCase(pattern: "**file", fileName: "dir/dir/file", expectSuccess: true), + TestCase(pattern: "**/file", fileName: "dir/dir/file", expectSuccess: true), + TestCase(pattern: "**/file*", fileName: "dir/dir/file", expectSuccess: true), + TestCase(pattern: "**/file*", fileName: "dir/dir/file.txt", expectSuccess: true), + TestCase(pattern: "**/file*txt", fileName: "dir/dir/file.txt", expectSuccess: true), + TestCase(pattern: "**/file*.txt", fileName: "dir/dir/file.txt", expectSuccess: true), + TestCase(pattern: "**/file*.txt*", fileName: "dir/dir/file.txt", expectSuccess: true), + TestCase(pattern: "**/**/*.txt", fileName: "dir/dir/file.txt", expectSuccess: true), + TestCase(pattern: "**/**/*.txt2", fileName: "dir/dir/file.txt", expectSuccess: false), + TestCase(pattern: "**/*.txt", fileName: "file.txt", expectSuccess: true), + TestCase(pattern: "**/**/*.txt", fileName: "file.txt", expectSuccess: true), + TestCase(pattern: "a**/*.txt", fileName: "a/file.txt", expectSuccess: true), + TestCase(pattern: "a**/*.txt", fileName: "a/dir/file.txt", expectSuccess: true), + TestCase(pattern: "a**/*.txt", fileName: "a/dir/dir/file.txt", expectSuccess: true), + TestCase(pattern: "a/*.txt", fileName: "a/dir/file.txt", expectSuccess: false), + TestCase(pattern: "a/*.txt", fileName: "a/file.txt", expectSuccess: true), + TestCase(pattern: "a/*.txt**", fileName: "a/file.txt", expectSuccess: true), + TestCase(pattern: ".*", fileName: ".foo", expectSuccess: true), + TestCase(pattern: ".*", fileName: "foo", expectSuccess: false), + TestCase(pattern: "abc.def", fileName: "abcdef", expectSuccess: false), + TestCase(pattern: "abc.def", fileName: "abc.def", expectSuccess: true), + TestCase(pattern: "abc.def", fileName: "abcZdef", expectSuccess: false), + TestCase(pattern: "abc?def", fileName: "abcZdef", expectSuccess: true), + TestCase(pattern: "abc?def", fileName: "abcdef", expectSuccess: false), + TestCase(pattern: "**/foo/bar", fileName: "foo/bar", expectSuccess: true), + TestCase(pattern: "**/foo/bar", fileName: "dir/foo/bar", expectSuccess: true), + TestCase(pattern: "**/foo/bar", fileName: "dir/dir2/foo/bar", expectSuccess: true), + TestCase(pattern: "abc/**", fileName: "abc/def", expectSuccess: true), + TestCase(pattern: "abc/**", fileName: "abc/def/ghi", expectSuccess: true), + TestCase(pattern: "**/.foo", fileName: ".foo", expectSuccess: true), + TestCase(pattern: "**/.foo", fileName: "bar.foo", expectSuccess: false), + TestCase(pattern: "./bar.*", fileName: "bar.foo", expectSuccess: true), + TestCase(pattern: "./bar.*/", fileName: "bar.foo", expectSuccess: true), + TestCase(pattern: "a(b)c/def", fileName: "a(b)c/def", expectSuccess: true), + TestCase(pattern: "a(b)c/def", fileName: "a(b)c/xyz", expectSuccess: false), + TestCase(pattern: "a.|)$(}+{bc", fileName: "a.|)$(}+{bc", expectSuccess: true), + TestCase(pattern: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", fileName: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", expectSuccess: true), + TestCase(pattern: "dist/*.whl", fileName: "dist/proxy.py-2.4.0rc3.dev36+g08acad9-py3-none-any.whl", expectSuccess: true), +] + +@Suite struct TestGlobber { + @Test("All glob patterns match", arguments: globTestCases) + func testGlobMatching(_ test: TestCase) throws { + let globber = Globber(URL(fileURLWithPath: "/")) + let found = try globber.glob(test.fileName, test.pattern) + #expect(found == test.expectSuccess, "expected found to be \(test.expectSuccess), instead got \(found)") + } + + @Test("Invalid computed regex patterns throw error", arguments: errorGlobTestCases) + func testInvalidGlob(_ test: TestCase) throws { + let globber = Globber(URL(fileURLWithPath: "/")) + #expect(throws: (any Error).self) { + try globber.glob(test.fileName, test.pattern) + } + } + + @Test("All expected patterns match", arguments: testCases) + func testExpectedPatterns(_ test: TestCase) throws { + let charactersToTrim = CharacterSet(charactersIn: "/") + let components = test.fileName + .trimmingCharacters(in: charactersToTrim) + .components(separatedBy: "/") + + // tempDir is the directory we're making the files or nested files in + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + var fileDir: URL = tempDir + + // testDir is the directory before the last component that we need to create + components.dropLast().forEach { component in + var d = fileDir + if component == ".." { + d = fileDir.deletingLastPathComponent() + } else if component != "." { + d = fileDir.appendingPathComponent(component) + } + #expect(throws: Never.self) { + try FileManager.default.createDirectory(at: d, withIntermediateDirectories: true) + } + fileDir = d + } + + #expect(throws: Never.self) { + try FileManager.default.createDirectory(at: fileDir, withIntermediateDirectories: true) + } + let testFile = fileDir.appendingPathComponent(components.last!) + #expect(throws: Never.self) { + try "".write(to: testFile, atomically: true, encoding: .utf8) + } + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let globber = Globber(tempDir) + #expect(throws: Never.self) { + try globber.match(test.pattern) + let found: Bool = !globber.results.isEmpty + #expect(found == test.expectSuccess, "expected match to be \(test.expectSuccess), instead got \(found) \(tempDir.childrenRecursive)") + } + } + + @Test("Test the base directory is not include in results") + func testBaseDirNotIncluded() throws { + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + let testDir = tempDir.appendingPathComponent("abc") + + #expect(throws: Never.self) { + try FileManager.default.createDirectory(at: testDir, withIntermediateDirectories: true) + } + + defer { + try? FileManager.default.removeItem(at: tempDir) + } + + let globber = Globber(testDir) + #expect(throws: Never.self) { + try globber.match("abc/**") + #expect(globber.results.isEmpty, "expected to find no matches, instead found \(globber.results)") + } + } +} diff --git a/Tests/ContainerCommandsTests/HelpCommandTests.swift b/Tests/ContainerCommandsTests/HelpCommandTests.swift new file mode 100644 index 0000000..0e5c1c5 --- /dev/null +++ b/Tests/ContainerCommandsTests/HelpCommandTests.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerCommands + +struct HelpCommandTests { + @Test + func everyStaticSubcommandReachableViaHelp() { + func walk(_ command: ParsableCommand.Type, path: [String]) { + let cfg = command.configuration + var children = cfg.subcommands + for group in cfg.groupedSubcommands { + children.append(contentsOf: group.subcommands) + } + for child in children { + guard let name = child.configuration.commandName else { continue } + let canonical = path + [name] + let canonicalResolved = HelpCommand.resolveSubcommand(path: canonical) != nil + #expect( + canonicalResolved, + "help should resolve '\(canonical.joined(separator: " "))' but returned nil" + ) + for alias in child.configuration.aliases { + let aliasPath = path + [alias] + let aliasResolved = HelpCommand.resolveSubcommand(path: aliasPath) != nil + #expect( + aliasResolved, + "help should resolve alias path '\(aliasPath.joined(separator: " "))' but returned nil" + ) + } + walk(child, path: canonical) + } + } + walk(Application.self, path: []) + } + + @Test + func unknownSubcommandReturnsNil() { + let unknownResolved = HelpCommand.resolveSubcommand(path: ["nonexistent"]) == nil + #expect(unknownResolved) + let nestedUnknownResolved = HelpCommand.resolveSubcommand(path: ["image", "nonexistent"]) == nil + #expect(nestedUnknownResolved) + } +} diff --git a/Tests/ContainerCommandsTests/ListFormattingTests.swift b/Tests/ContainerCommandsTests/ListFormattingTests.swift new file mode 100644 index 0000000..3c0c5be --- /dev/null +++ b/Tests/ContainerCommandsTests/ListFormattingTests.swift @@ -0,0 +1,287 @@ +//===----------------------------------------------------------------------===// +// 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 +import Testing + +@testable import ContainerCommands + +// MARK: - Test ListDisplayable conformer + +private struct TestItem: ListDisplayable, Codable { + let id: String + let name: String + + static var tableHeader: [String] { ["ID", "NAME"] } + var tableRow: [String] { [id, name] } + var quietValue: String { id } +} + +// MARK: - TableOutput tests + +struct TableOutputTests { + @Test + func emptyRowsProducesEmptyString() { + #expect(TableOutput(rows: []).format() == "") + } + + @Test + func headerOnlyRow() { + #expect(TableOutput(rows: [["ID", "NAME"]]).format() == "ID NAME") + } + + @Test + func columnsPaddedToMaxWidth() { + let output = TableOutput(rows: [ + ["ID", "NAME"], + ["1", "short"], + ["2", "a longer name"], + ]).format() + let lines = output.split(separator: "\n") + #expect(lines.count == 3) + #expect(lines[0].hasPrefix("ID ")) + #expect(lines[1].hasPrefix("1 ")) + #expect(lines[2].hasPrefix("2 ")) + } + + @Test + func customSpacing() { + let output = TableOutput(rows: [["A", "B"], ["1", "2"]], spacing: 4).format() + #expect(output.contains("A B")) + } + + @Test + func lastColumnNotPadded() { + let lines = TableOutput(rows: [["A", "B"], ["1", "2"]]).format().split(separator: "\n") + for line in lines { + #expect(!line.hasSuffix(" ")) + } + } + + @Test + func singleColumnNoPadding() { + let output = TableOutput(rows: [["DOMAIN"], ["example.com"], ["test.local"]]).format() + #expect(output == "DOMAIN\nexample.com\ntest.local") + } + + @Test + func outputLineCountMatchesInputRowCount() { + let rows = [["H1", "H2"], ["a", "b"], ["c", "d"], ["e", "f"]] + let lines = TableOutput(rows: rows).format().split(separator: "\n") + #expect(lines.count == rows.count) + } +} + +// MARK: - renderTable tests + +struct RenderTableTests { + @Test + func rendersHeaderAndRows() { + let items = [TestItem(id: "abc", name: "first"), TestItem(id: "def", name: "second")] + let output = Output.renderTable(items) + #expect(output.contains("ID")) + #expect(output.contains("NAME")) + #expect(output.contains("abc")) + #expect(output.contains("second")) + } + + @Test + func emptyListRendersHeaderOnly() { + let output = Output.renderTable([TestItem]()) + #expect(output.contains("ID")) + #expect(output.contains("NAME")) + #expect(!output.contains("\n")) + } + + @Test + func columnCountMatchesHeader() { + let items = [TestItem(id: "1", name: "test")] + let lines = Output.renderTable(items).split(separator: "\n") + let headerColumnCount = lines[0].split(separator: " ", omittingEmptySubsequences: true).count + let rowColumnCount = lines[1].split(separator: " ", omittingEmptySubsequences: true).count + #expect(headerColumnCount == rowColumnCount) + } +} + +// MARK: - renderList tests + +struct RenderListTests { + @Test + func tableMode() { + let items = [TestItem(id: "abc", name: "first")] + let output = Output.renderList(items, quiet: false) + #expect(output.contains("ID")) + #expect(output.contains("abc")) + #expect(output.contains("first")) + } + + @Test + func quietMode() { + let items = [TestItem(id: "abc", name: "first"), TestItem(id: "def", name: "second")] + let output = Output.renderList(items, quiet: true) + #expect(output == "abc\ndef") + } + + @Test + func quietModeEmptyList() { + let output = Output.renderList([TestItem](), quiet: true) + #expect(output == "") + } +} + +// MARK: - renderJSON tests + +struct RenderJSONTests { + @Test + func compactProducesValidJSON() throws { + let items = [TestItem(id: "a", name: "b")] + let json = try Output.renderJSON(items) + let decoded = try JSONDecoder().decode([TestItem].self, from: json.data(using: .utf8)!) + #expect(decoded.count == 1) + #expect(decoded[0].id == "a") + #expect(decoded[0].name == "b") + } + + @Test + func compactIsSingleLine() throws { + let items = [TestItem(id: "a", name: "b"), TestItem(id: "c", name: "d")] + let json = try Output.renderJSON(items) + #expect(!json.contains("\n")) + } + + @Test + func prettyIsMultiLine() throws { + let items = [TestItem(id: "a", name: "b")] + let json = try Output.renderJSON(items, options: .pretty) + #expect(json.contains("\n")) + } + + @Test + func prettyHasSortedKeys() throws { + let json = try Output.renderJSON(["z": 1, "a": 2], options: .pretty) + let aIndex = json.range(of: "\"a\"")!.lowerBound + let zIndex = json.range(of: "\"z\"")!.lowerBound + #expect(aIndex < zIndex) + } + + @Test + func customDateStrategy() throws { + struct Dated: Codable { let date: Date } + let item = Dated(date: Date(timeIntervalSince1970: 0)) + let options = JSONOptions( + outputFormatting: [.prettyPrinted, .sortedKeys], + dateEncodingStrategy: .iso8601 + ) + let json = try Output.renderJSON(item, options: options) + #expect(json.contains("1970-01-01")) + } + + @Test + func arrayEncodingMatchesOldJoinApproach() throws { + // Verify renderJSON(array) is structurally identical to the old + // jsonArray() approach (encode each element, join with commas). + let items = [TestItem(id: "x", name: "y"), TestItem(id: "a", name: "b")] + let wholeArray = try Output.renderJSON(items) + let perElement = try items.map { try Output.renderJSON($0) } + let joined = "[\(perElement.joined(separator: ","))]" + + let decoded1 = try JSONDecoder().decode([TestItem].self, from: wholeArray.data(using: .utf8)!) + let decoded2 = try JSONDecoder().decode([TestItem].self, from: joined.data(using: .utf8)!) + #expect(decoded1.count == decoded2.count) + #expect(decoded1[0].id == decoded2[0].id) + #expect(decoded1[1].id == decoded2[1].id) + } +} + +// MARK: - renderTOML tests + +struct RenderTOMLTests { + @Test + func topLevelArrayProducesNonEmptyTOML() throws { + let items = [TestItem(id: "a", name: "first"), TestItem(id: "c", name: "second")] + let toml = try Output.renderTOML(items) + #expect(!toml.isEmpty) + #expect(toml.contains("first")) + #expect(toml.contains("second")) + } + + @Test + func emptyArrayProducesNonEmptyTOML() throws { + let toml = try Output.renderTOML([TestItem]()) + #expect(!toml.isEmpty) + } + + @Test + func singleValueEncodesAsTopLevelTable() throws { + let toml = try Output.renderTOML(TestItem(id: "x", name: "y")) + #expect(toml.contains("id")) + #expect(toml.contains("x")) + } +} + +// MARK: - JSONOptions tests + +struct JSONOptionsTests { + @Test + func compactPresetHasSortedKeys() { + let opts = JSONOptions.compact + #expect(opts.outputFormatting == [.sortedKeys]) + #expect(!opts.outputFormatting.contains(.prettyPrinted)) + } + + @Test + func prettyPresetHasBothFlags() { + let opts = JSONOptions.pretty + #expect(opts.outputFormatting.contains(.prettyPrinted)) + #expect(opts.outputFormatting.contains(.sortedKeys)) + } +} + +// MARK: - ManagedContainer conformance tests + +struct ManagedContainerDisplayTests { + @Test + func tableHeaderHasNineColumns() { + #expect(ManagedContainer.tableHeader.count == 9) + #expect(ManagedContainer.tableHeader[0] == "ID") + #expect(ManagedContainer.tableHeader[4] == "STATE") + #expect(ManagedContainer.tableHeader[8] == "STARTED") + } +} + +// MARK: - NetworkResource ListDisplayable conformance tests + +struct NetworkResourceDisplayTests { + @Test + func tableHeaderHasTwoColumns() { + #expect(NetworkResource.tableHeader.count == 2) + #expect(NetworkResource.tableHeader == ["NETWORK", "SUBNET"]) + } +} + +// MARK: - ListFormat tests + +struct ListFormatTests { + @Test + func hasAllOutputFormatCases() { + #expect(ListFormat.allCases.count == 4) + #expect(ListFormat.json.rawValue == "json") + #expect(ListFormat.table.rawValue == "table") + #expect(ListFormat.yaml.rawValue == "yaml") + #expect(ListFormat.toml.rawValue == "toml") + } +} diff --git a/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift new file mode 100644 index 0000000..86ea3ef --- /dev/null +++ b/Tests/ContainerNetworkServerTests/AttachmentAllocatorTest.swift @@ -0,0 +1,158 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerNetworkServer + +struct AttachmentAllocatorTest { + @Test func testAllocateSingleHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address = try await allocator.allocate(hostname: "test-host") + + #expect(address >= 100) + #expect(address < 110) + } + + @Test func testAllocateSameHostnameTwice() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "test-host") + let address2 = try await allocator.allocate(hostname: "test-host") + + #expect(address1 == address2) + } + + @Test func testAllocateMultipleHostnames() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "host1") + let address2 = try await allocator.allocate(hostname: "host2") + let address3 = try await allocator.allocate(hostname: "host3") + + #expect(address1 != address2) + #expect(address2 != address3) + #expect(address1 != address3) + } + + @Test func testLookupAllocatedHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host") + let lookedUpAddress = try await allocator.lookup(hostname: "test-host") + + #expect(lookedUpAddress == allocatedAddress) + } + + @Test func testLookupNonExistentHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address = try await allocator.lookup(hostname: "non-existent") + + #expect(address == nil) + } + + @Test func testDeallocateAllocatedHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let allocatedAddress = try await allocator.allocate(hostname: "test-host") + let deallocatedAddress = try await allocator.deallocate(hostname: "test-host") + + #expect(deallocatedAddress == allocatedAddress) + + // After deallocation, lookup should return nil + let lookedUpAddress = try await allocator.lookup(hostname: "test-host") + #expect(lookedUpAddress == nil) + } + + @Test func testDeallocateNonExistentHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let deallocatedAddress = try await allocator.deallocate(hostname: "non-existent") + + #expect(deallocatedAddress == nil) + } + + @Test func testReallocateAfterDeallocation() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address1 = try await allocator.allocate(hostname: "test-host") + let released1 = try await allocator.deallocate(hostname: "test-host") + #expect(address1 == released1) + let address2 = try await allocator.allocate(hostname: "test-host") + + // After deallocation, allocating the same hostname should give a new address + #expect(address2 >= 100) + #expect(address2 < 110) + } + + @Test func testAllocateUntilFull() async throws { + let size = 5 + let allocator = try AttachmentAllocator(lower: 100, size: size) + + // Allocate up to the limit + for i in 0..= 100) + #expect(newAddress < 103) + + // The three remaining allocations should all be different + let finalAddress1 = try await allocator.lookup(hostname: "host1") + let finalAddress3 = try await allocator.lookup(hostname: "host3") + let finalAddress4 = try await allocator.lookup(hostname: "host4") + + #expect(finalAddress1 == address1) + #expect(finalAddress3 == address3) + #expect(finalAddress4 == newAddress) + } + + @Test func testMultipleDeallocationsOfSameHostname() async throws { + let allocator = try AttachmentAllocator(lower: 100, size: 10) + + let address = try await allocator.allocate(hostname: "test-host") + + let firstDeallocate = try await allocator.deallocate(hostname: "test-host") + #expect(firstDeallocate == address) + + // Second deallocation should return nil since it's already deallocated + let secondDeallocate = try await allocator.deallocate(hostname: "test-host") + #expect(secondDeallocate == nil) + } +} diff --git a/Tests/ContainerOSTests/DirectoryWatcherTest.swift b/Tests/ContainerOSTests/DirectoryWatcherTest.swift new file mode 100644 index 0000000..b64f1a5 --- /dev/null +++ b/Tests/ContainerOSTests/DirectoryWatcherTest.swift @@ -0,0 +1,174 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerOS +import ContainerizationError +import DNSServer +import Foundation +import SystemPackage +import Testing + +struct DirectoryWatcherTest { + let testUUID = UUID().uuidString + + private var testDir: FilePath { + let tempURL = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".clitests") + .appendingPathComponent(testUUID) + try! FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true) + return FilePath(tempURL.path) + } + + private func withTempDir(_ body: (FilePath) async throws -> T) async throws -> T { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempURL, withIntermediateDirectories: true) + let tempPath = FilePath(tempURL.path) + + defer { + try? FileManager.default.removeItem(at: tempURL) + } + + return try await body(tempPath) + } + + private actor CreatedPaths { + nonisolated(unsafe) public var paths: [FilePath] + + public init() { + self.paths = [] + } + } + + @Test func testWatchingExistingDirectory() async throws { + try await withTempDir { tempPath in + + let watcher = DirectoryWatcher(directoryPath: tempPath, log: nil) + let createdPaths = CreatedPaths() + let name = "newFile" + + await watcher.startWatching { [createdPaths] paths in + for path in paths where path.lastComponent?.string == name { + createdPaths.paths.append(path) + } + } + + try await Task.sleep(for: .milliseconds(100)) + let newFile = tempPath.appending(name) + FileManager.default.createFile(atPath: newFile.string, contents: nil) + try await Task.sleep(for: .milliseconds(500)) + + #expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect new file") + #expect(createdPaths.paths.first!.lastComponent?.string == name) + } + } + + @Test func testWatchingNonExistingDirectory() async throws { + try await withTempDir { tempPath in + let uuid = UUID().uuidString + let childPath = tempPath.appending(uuid) + + let watcher = DirectoryWatcher(directoryPath: childPath, log: nil) + let createdPaths = CreatedPaths() + let name = "newFile" + + await watcher.startWatching { [createdPaths] paths in + for path in paths where path.lastComponent?.string == name { + createdPaths.paths.append(path) + } + } + + try await Task.sleep(for: .milliseconds(100)) + try FileManager.default.createDirectory(atPath: childPath.string, withIntermediateDirectories: true) + + try await Task.sleep(for: DirectoryWatcher.watchPeriod) + let newFile = childPath.appending(name) + FileManager.default.createFile(atPath: newFile.string, contents: nil) + try await Task.sleep(for: .milliseconds(500)) + + #expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect parent directory") + #expect(createdPaths.paths.first!.lastComponent?.string == name) + } + } + + @Test func testWatchingNonExistingParent() async throws { + try await withTempDir { tempPath in + let parent = UUID().uuidString + let child = UUID().uuidString + let childPath = tempPath.appending(parent).appending(child) + + let watcher = DirectoryWatcher(directoryPath: childPath, log: nil) + let createdPaths = CreatedPaths() + let name = "newFile" + + await watcher.startWatching { paths in + for path in paths where path.lastComponent?.string == name { + createdPaths.paths.append(path) + } + } + + try await Task.sleep(for: .milliseconds(100)) + try FileManager.default.createDirectory(atPath: childPath.string, withIntermediateDirectories: true) + + try await Task.sleep(for: DirectoryWatcher.watchPeriod) + + let newFile = childPath.appending(name) + FileManager.default.createFile(atPath: newFile.string, contents: nil) + try await Task.sleep(for: .milliseconds(500)) + + #expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect parent directory") + #expect(createdPaths.paths.first!.lastComponent?.string == name) + } + } + + @Test func testWatchingRecreatedDirectory() async throws { + try await withTempDir { tempPath in + let dirPath = tempPath.appending(UUID().uuidString) + try FileManager.default.createDirectory(atPath: dirPath.string, withIntermediateDirectories: true) + + let watcher = DirectoryWatcher(directoryPath: dirPath, log: nil) + let createdPaths = CreatedPaths() + let beforeDelete = "beforeDelete" + let afterDelete = "afterDelete" + + await watcher.startWatching { [createdPaths] paths in + for path in paths + where path.lastComponent?.string == beforeDelete || path.lastComponent?.string == afterDelete { + createdPaths.paths.append(path) + } + } + + try await Task.sleep(for: .milliseconds(100)) + let file1 = dirPath.appending(beforeDelete) + FileManager.default.createFile(atPath: file1.string, contents: nil) + try await Task.sleep(for: .milliseconds(100)) + + try FileManager.default.removeItem(atPath: dirPath.string) + try await Task.sleep(for: .milliseconds(100)) + try FileManager.default.createDirectory(atPath: dirPath.string, withIntermediateDirectories: true) + try await Task.sleep(for: DirectoryWatcher.watchPeriod) + + let file2 = dirPath.appending(afterDelete) + FileManager.default.createFile(atPath: file2.string, contents: nil) + + try await Task.sleep(for: .milliseconds(500)) + + #expect(!createdPaths.paths.isEmpty, "directory watcher failed to detect new file") + #expect( + Set(createdPaths.paths.compactMap { $0.lastComponent?.string }) == Set([beforeDelete, afterDelete])) + } + + } +} diff --git a/Tests/ContainerPersistenceTests/ConfigSnapshotDecoderTests.swift b/Tests/ContainerPersistenceTests/ConfigSnapshotDecoderTests.swift new file mode 100644 index 0000000..dad298f --- /dev/null +++ b/Tests/ContainerPersistenceTests/ConfigSnapshotDecoderTests.swift @@ -0,0 +1,475 @@ +//===----------------------------------------------------------------------===// +// 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 Configuration +import ContainerPersistence +import Foundation +import Testing + +private func makeSnapshot(_ values: [AbsoluteConfigKey: ConfigValue] = [:]) -> ConfigSnapshotReader { + ConfigReader(provider: InMemoryProvider(name: "test", values: values)).snapshot() +} + +struct ConfigSnapshotDecoderTests { + + struct FlatConfig: Decodable, Equatable { + var host: String + var port: Int + var debug: Bool + var rate: Double + } + + @Test func decodeFlatStruct() throws { + let snapshot = makeSnapshot([ + "host": "localhost", + "port": 8080, + "debug": true, + "rate": 0.5, + ]) + let config = try ConfigSnapshotDecoder().decode(FlatConfig.self, from: snapshot) + #expect(config == FlatConfig(host: "localhost", port: 8080, debug: true, rate: 0.5)) + } + + // MARK: - Nested structs + + struct NestedConfig: Decodable, Equatable { + var database: DatabaseConfig + } + + struct DatabaseConfig: Decodable, Equatable { + var host: String + var port: Int + } + + @Test func decodeNestedStruct() throws { + let snapshot = makeSnapshot([ + "database.host": "db.example.com", + "database.port": 5432, + ]) + let config = try ConfigSnapshotDecoder().decode(NestedConfig.self, from: snapshot) + #expect(config == NestedConfig(database: DatabaseConfig(host: "db.example.com", port: 5432))) + } + + struct AppConfig: Decodable, Equatable { + var cluster: ClusterConfig + } + + struct ClusterConfig: Decodable, Equatable { + var primary: NodeConfig + } + + struct NodeConfig: Decodable, Equatable { + var host: String + var port: Int + } + + @Test func decodeDeeplyNestedStruct() throws { + let snapshot = makeSnapshot([ + "cluster.primary.host": "node1.example.com", + "cluster.primary.port": 9090, + ]) + let config = try ConfigSnapshotDecoder().decode(AppConfig.self, from: snapshot) + #expect( + config + == AppConfig(cluster: ClusterConfig(primary: NodeConfig(host: "node1.example.com", port: 9090))) + ) + } + + // MARK: - Optional properties + + struct OptionalPrimitivesConfig: Decodable, Equatable { + var name: String? + var count: Int? + var rate: Double? + var ratio: Float? + var flag: Bool? + } + + @Test func decodeOptionalsPresent() throws { + let snapshot = makeSnapshot([ + "name": "test", + "count": 3, + "rate": 0.75, + "ratio": 0.5, + "flag": true, + ]) + let config = try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot) + #expect( + config == OptionalPrimitivesConfig(name: "test", count: 3, rate: 0.75, ratio: 0.5, flag: true) + ) + } + + @Test func decodeOptionalsAbsent() throws { + let snapshot = makeSnapshot() + let config = try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot) + #expect(config == OptionalPrimitivesConfig()) + } + + // Each test below provides one wrong-typed value so a mistyped user config + // surfaces as DecodingError rather than silently falling back to nil. + // See ConfigSnapshotDecoderContainers `decodeIfPresent` overrides. + + @Test func decodeOptionalIntWithStringThrows() { + let snapshot = makeSnapshot(["count": "8"]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot) + } + } + + @Test func decodeOptionalBoolWithIntThrows() { + let snapshot = makeSnapshot(["flag": 1]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot) + } + } + + @Test func decodeOptionalStringWithBoolThrows() { + let snapshot = makeSnapshot(["name": true]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot) + } + } + + @Test func decodeOptionalDoubleWithStringThrows() { + let snapshot = makeSnapshot(["rate": "0.5"]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot) + } + } + + @Test func decodeOptionalFloatWithStringThrows() { + let snapshot = makeSnapshot(["ratio": "0.5"]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(OptionalPrimitivesConfig.self, from: snapshot) + } + } + + // Regression: see KeyedContainer.decodeNil(forKey:) docs. The flat snapshot + // stores `build.cpus` but no entry for `build` itself; an earlier version of + // decodeNil returned true for `build`, causing decodeIfPresent to return nil + // even though `build.*` keys existed. + + struct BuildConfig: Decodable, Equatable { + var cpus: Int + } + + struct ParentConfig: Decodable, Equatable { + var build: BuildConfig? + } + + @Test func decodeIfPresentNestedStruct() throws { + let snapshot = makeSnapshot(["build.cpus": 4]) + let config = try ConfigSnapshotDecoder().decode(ParentConfig.self, from: snapshot) + #expect(config == ParentConfig(build: BuildConfig(cpus: 4))) + } + + // MARK: - Arrays + + struct ArrayConfig: Decodable, Equatable { + var tags: [String] + var counts: [Int] + var rates: [Double] + var flags: [Bool] + } + + @Test func decodeArrays() throws { + let snapshot = makeSnapshot([ + "tags": ConfigValue(.stringArray(["swift", "config"]), isSecret: false), + "counts": ConfigValue(.intArray([1, 2, 3]), isSecret: false), + "rates": ConfigValue(.doubleArray([1.5, 2.5, 3.5]), isSecret: false), + "flags": ConfigValue(.boolArray([true, false, true]), isSecret: false), + ]) + let config = try ConfigSnapshotDecoder().decode(ArrayConfig.self, from: snapshot) + #expect( + config + == ArrayConfig( + tags: ["swift", "config"], + counts: [1, 2, 3], + rates: [1.5, 2.5, 3.5], + flags: [true, false, true] + ) + ) + } + + struct ArrayOfStructsConfig: Decodable { + var items: [DatabaseConfig] + } + + @Test func decodeArrayOfStructsThrows() { + let snapshot = makeSnapshot() + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(ArrayOfStructsConfig.self, from: snapshot) + } + } + + // MARK: - Custom CodingKeys + + struct CustomKeysConfig: Decodable, Equatable { + var serverHost: String + var serverPort: Int + + enum CodingKeys: String, CodingKey { + case serverHost = "server-host" + case serverPort = "server-port" + } + } + + @Test func decodeCustomCodingKeys() throws { + let snapshot = makeSnapshot([ + "server-host": "example.com", + "server-port": 443, + ]) + let config = try ConfigSnapshotDecoder().decode(CustomKeysConfig.self, from: snapshot) + #expect(config == CustomKeysConfig(serverHost: "example.com", serverPort: 443)) + } + + // MARK: - Enum with raw value + + enum Environment: String, Decodable { + case development + case staging + case production + } + + struct EnumConfig: Decodable, Equatable { + var env: Environment + } + + @Test func decodeEnum() throws { + let snapshot = makeSnapshot(["env": "production"]) + let config = try ConfigSnapshotDecoder().decode(EnumConfig.self, from: snapshot) + #expect(config == EnumConfig(env: .production)) + } + + // MARK: - Narrow integer types + + struct NarrowIntConfig: Decodable, Equatable { + var small: Int16 + var unsigned: UInt8 + } + + @Test func decodeNarrowIntegers() throws { + let snapshot = makeSnapshot([ + "small": 42, + "unsigned": 200, + ]) + let config = try ConfigSnapshotDecoder().decode(NarrowIntConfig.self, from: snapshot) + #expect(config == NarrowIntConfig(small: 42, unsigned: 200)) + } + + @Test func decodeIntegerOverflowThrows() { + let snapshot = makeSnapshot([ + "small": 42, + "unsigned": 300, + ]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(NarrowIntConfig.self, from: snapshot) + } + } + + // MARK: - Float decoding + + struct FloatConfig: Decodable, Equatable { + var temperature: Float + var ratio: Float + } + + @Test func decodeFloat() throws { + let snapshot = makeSnapshot([ + "temperature": 98.6, + "ratio": 0.333, + ]) + let config = try ConfigSnapshotDecoder().decode(FloatConfig.self, from: snapshot) + #expect(config == FloatConfig(temperature: Float(98.6), ratio: Float(0.333))) + } + + // MARK: - Scoped snapshot + + @Test func decodeScopedSnapshot() throws { + let snapshot = makeSnapshot([ + "app.host": "localhost", + "app.port": 3000, + "app.debug": false, + "app.rate": 1.0, + ]).scoped(to: "app") + let config = try ConfigSnapshotDecoder().decode(FlatConfig.self, from: snapshot) + #expect(config == FlatConfig(host: "localhost", port: 3000, debug: false, rate: 1.0)) + } + + @Test func decodeTopLevelPrimitive() throws { + let snapshot = makeSnapshot(["value": 42]).scoped(to: "value") + let value = try ConfigSnapshotDecoder().decode(Int.self, from: snapshot) + #expect(value == 42) + } + + // MARK: - Error cases + + struct RequiredConfig: Decodable { + var name: String + var age: Int + } + + @Test func decodeMissingRequiredKeyThrows() { + let snapshot = makeSnapshot(["name": "Alice"]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(RequiredConfig.self, from: snapshot) + } + } + + struct IntConfig: Decodable { + var count: Int + } + + @Test func decodeTypeMismatchThrows() { + let snapshot = makeSnapshot(["count": "not-a-number"]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(IntConfig.self, from: snapshot) + } + } + + // Regression: see UnsupportedDictionaryDecoding marker docs. Dictionaries + // would otherwise silently decode to [:] because the snapshot has no + // key-enumeration API. + + struct DictConfig: Decodable { + var tags: [String: String] + } + + @Test func decodeDictionaryPropertyThrows() { + let snapshot = makeSnapshot(["tags.env": "prod"]) + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode(DictConfig.self, from: snapshot) + } + } + + @Test func decodeTopLevelDictionaryThrows() { + let snapshot = makeSnapshot() + #expect(throws: DecodingError.self) { + try ConfigSnapshotDecoder().decode([String: Int].self, from: snapshot) + } + } + + // MARK: - URL string fallback + + struct URLConfig: Decodable, Equatable { + var endpoint: URL + } + + @Test func decodeURL() throws { + let snapshot = makeSnapshot(["endpoint": "https://example.com/api"]) + let config = try ConfigSnapshotDecoder().decode(URLConfig.self, from: snapshot) + #expect(config == URLConfig(endpoint: URL(string: "https://example.com/api")!)) + } + + // MARK: - Custom decoding strategies + + struct PrefixURLStrategy: ConfigDecodingStrategy { + let base: String + + func decode(from decoder: Decoder) throws -> URL { + let container = try decoder.singleValueContainer() + let path = try container.decode(String.self) + guard let url = URL(string: base + path) else { + throw DecodingError.dataCorrupted( + DecodingError.Context( + codingPath: decoder.codingPath, + debugDescription: "Invalid URL." + ) + ) + } + return url + } + } + + @Test func decodeURLWithCustomStrategy() throws { + let snapshot = makeSnapshot(["endpoint": "/api/v1"]) + let decoder = ConfigSnapshotDecoder( + decodingStrategies: [PrefixURLStrategy(base: "https://example.com")] + ) + let config = try decoder.decode(URLConfig.self, from: snapshot) + #expect(config == URLConfig(endpoint: URL(string: "https://example.com/api/v1")!)) + } + + @Test func decodeURLWithoutStrategyThrows() { + let snapshot = makeSnapshot(["endpoint": "https://example.com/api"]) + let decoder = ConfigSnapshotDecoder(decodingStrategies: []) + #expect(throws: DecodingError.self) { + try decoder.decode(URLConfig.self, from: snapshot) + } + } + + struct NestedURLConfig: Decodable, Equatable { + var service: ServiceConfig + } + + struct ServiceConfig: Decodable, Equatable { + var endpoint: URL + var name: String + } + + @Test func decodeNestedStructWithURLStrategy() throws { + let snapshot = makeSnapshot([ + "service.endpoint": "https://nested.example.com", + "service.name": "api", + ]) + let config = try ConfigSnapshotDecoder().decode(NestedURLConfig.self, from: snapshot) + #expect( + config + == NestedURLConfig( + service: ServiceConfig( + endpoint: URL(string: "https://nested.example.com")!, + name: "api" + ) + ) + ) + } + + struct OptionalURLConfig: Decodable, Equatable { + var endpoint: URL? + } + + @Test func decodeOptionalURLWithStrategy() throws { + let snapshot = makeSnapshot(["endpoint": "https://example.com"]) + let config = try ConfigSnapshotDecoder().decode(OptionalURLConfig.self, from: snapshot) + #expect(config == OptionalURLConfig(endpoint: URL(string: "https://example.com")!)) + } + + struct Seconds: Decodable, Equatable { + var value: Int + } + + struct SecondsStrategy: ConfigDecodingStrategy { + func decode(from decoder: Decoder) throws -> Seconds { + let container = try decoder.singleValueContainer() + let raw = try container.decode(Int.self) + return Seconds(value: raw) + } + } + + struct TimerConfig: Decodable, Equatable { + var timeout: Seconds + } + + @Test func decodeUserTypeWithCustomStrategy() throws { + let snapshot = makeSnapshot(["timeout": 30]) + let decoder = ConfigSnapshotDecoder(decodingStrategies: [ + URLConfigDecodingStrategy(), + SecondsStrategy(), + ]) + let config = try decoder.decode(TimerConfig.self, from: snapshot) + #expect(config == TimerConfig(timeout: Seconds(value: 30))) + } +} diff --git a/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift new file mode 100644 index 0000000..5f27694 --- /dev/null +++ b/Tests/ContainerPersistenceTests/ConfigurationLoaderTests.swift @@ -0,0 +1,492 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import ContainerizationExtras +import Foundation +import SystemPackage +import Testing + +@testable import ContainerPersistence + +struct ConfigurationLoaderTests { + private static func writeToml(_ contents: String, to path: FilePath) throws { + try contents.write( + to: URL(filePath: path.string), + atomically: true, + encoding: .utf8 + ) + } + + /// Lenient plugin config used by most `loadForPlugin` tests: missing `cpu` + /// decodes to the default of 99, so tests can assert "default" vs "loaded". + private struct TestPluginConfig: LoadablePluginConfiguration { + static let pluginId = "foo" + var cpu: Int + init() { self.cpu = 99 } + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.cpu = try container.decodeIfPresent(Int.self, forKey: .cpu) ?? 99 + } + } + + /// A structurally different plugin config — strict decode of `memory`. + /// Used by: + /// - `loadForPluginIsolatesFromHostileSibling`: paired with a sibling + /// `[plugin.*]` section that also defines `memory`, so a scoping leak + /// would surface as a wrong value rather than a thrown error. + /// - `loadForPluginMalformedSectionThrows`: exercises strict decode against + /// a malformed target section. + private struct TestPluginConfig2: LoadablePluginConfiguration { + static let pluginId = "mem" + var memory: String + init() { self.memory = "1g" } + init(from decoder: any Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + self.memory = try container.decode(String.self, forKey: .memory) + } + } + + /// Plugin config with an empty `pluginId` to exercise the empty-id guard. + private struct EmptyIdPluginConfig: LoadablePluginConfiguration { + static let pluginId = "" + init() {} + } + + @Test func layeredFilesPerKeyPrecedence() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let appRoot = tempDir.appending("appRoot.toml") + let installRoot = tempDir.appending("installRoot.toml") + try Self.writeToml("[build]\nrosetta = false\n", to: appRoot) + try Self.writeToml("[registry]\ndomain = \"foo.bar\"\n", to: installRoot) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [appRoot, installRoot] + ) + #expect(config.build.rosetta == false) + #expect(config.registry.domain == "foo.bar") + } + } + + @Test func defaultsWithNoFile() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let path = tempDir.appending("nonexistent.toml") + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [path]) + #expect(config.build.rosetta == true) + #expect(config.build.cpus == 2) + #expect(config.build.memory == BuildConfig.defaultMemory) + #expect(config.container.cpus == 4) + #expect(config.container.memory == ContainerConfig.defaultMemory) + #expect(config.dns.domain == nil) + #expect(!config.build.image.isEmpty) + #expect(!config.vminit.image.isEmpty) + #expect(!config.kernel.binaryPath.isEmpty) + #expect(!config.kernel.url.absoluteString.isEmpty) + #expect(config.network.subnet == nil) + #expect(config.network.subnetv6 == nil) + #expect(config.registry.domain == "docker.io") + } + } + + @Test func tomlOverrideAllKeys() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + rosetta = false + cpus = 8 + memory = "4096MB" + image = "custom-builder:latest" + + [container] + cpus = 16 + memory = "8g" + + [dns] + domain = "custom" + + [kernel] + binaryPath = "custom/path" + url = "https://example.com/kernel.tar" + + [network] + subnet = "10.0.0.1/16" + subnetv6 = "fd01::/48" + + [registry] + domain = "ghcr.io" + + [vminit] + image = "custom-init:latest" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + #expect(config.build.rosetta == false) + #expect(config.build.cpus == 8) + let expectedBuildMemory = try MemorySize("4096MB") + #expect(config.build.memory == expectedBuildMemory) + #expect(config.container.cpus == 16) + let expectedContainerMemory = try MemorySize("8g") + #expect(config.container.memory == expectedContainerMemory) + #expect(config.dns.domain == "custom") + #expect(config.build.image == "custom-builder:latest") + #expect(config.vminit.image == "custom-init:latest") + #expect(config.kernel.binaryPath == "custom/path") + #expect(config.kernel.url.absoluteString == "https://example.com/kernel.tar") + let expectedSubnet = try CIDRv4("10.0.0.1/16") + let expectedSubnetV6 = try CIDRv6("fd01::/48") + #expect(config.network.subnet == expectedSubnet) + #expect(config.network.subnetv6 == expectedSubnetV6) + #expect(config.registry.domain == "ghcr.io") + } + } + + @Test func partialToml() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + cpus = 16 + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + #expect(config.build.cpus == 16) + #expect(config.build.rosetta == true) + #expect(config.build.memory == BuildConfig.defaultMemory) + #expect(config.container.cpus == 4) + #expect(config.container.memory == ContainerConfig.defaultMemory) + } + } + + @Test func unknownKeysIgnored() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + cpus = 4 + unknownBuildKey = "ignored" + + [unknownSection] + foo = "bar" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + #expect(config.build.cpus == 4) + #expect(config.build.rosetta == true) + } + } + + @Test func invalidTomlThrows() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let tmpFile = tempDir.appending("test-invalid.toml") + try Self.writeToml("this is [not valid toml", to: tmpFile) + await #expect(throws: (any Error).self) { + let _: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + } + } + } + + @Test func emptyTomlDecodesToDefaults() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml("", to: tmpFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load(configurationFiles: [tmpFile]) + #expect(config.build.rosetta == true) + #expect(config.build.cpus == 2) + #expect(config.container.cpus == 4) + #expect(config.registry.domain == "docker.io") + } + } + + @Test func copyConfigToAppRoot() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let source = tempDir.appending("config.toml") + try Self.writeToml("[build]\ncpus = 8", to: source) + + let destBase = tempDir.appending("dest") + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + + let destFile = destBase.appending("config").appending("config.toml") + let copied = try String(contentsOf: URL(filePath: destFile.string), encoding: .utf8) + #expect(copied.contains("cpus = 8")) + + let attrs = try FileManager.default.attributesOfItem(atPath: destFile.string) + let perms = try #require(attrs[.posixPermissions] as? Int) + #expect(perms == 0o444) + } + } + + @Test func copyConfigOverwritesExistingReadOnlyDestination() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let source = tempDir.appending("config.toml") + let destBase = tempDir.appending("dest") + + try Self.writeToml("[build]\ncpus = 8", to: source) + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + + try Self.writeToml("[build]\ncpus = 16", to: source) + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + + let destFile = destBase.appending("config").appending("config.toml") + let copied = try String(contentsOf: URL(filePath: destFile.string), encoding: .utf8) + #expect(copied.contains("cpus = 16")) + + let attrs = try FileManager.default.attributesOfItem(atPath: destFile.string) + let perms = try #require(attrs[.posixPermissions] as? Int) + #expect(perms == 0o444) + } + } + + @Test func copyConfigNoOpsWhenSourceMissing() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let source = tempDir.appending("nonexistent.toml") + let destBase = tempDir.appending("dest") + try ConfigurationLoader.copyConfigurationToReadOnly(from: source, to: destBase) + let destFile = destBase.appending("config").appending("config.toml") + #expect(!FileManager.default.fileExists(atPath: destFile.string)) + } + } + + @Test func loadForPluginDecodesQualifiedSection() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + cpus = 8 + + [plugin.foo] + cpu = 4 + + [plugin.other] + unrelated = "value that would not decode as TestPluginConfig" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [tmpFile]) + #expect(foo.cpu == 4) + } + } + + @Test func loadForPluginMissingSectionReturnsDefault() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [build] + cpus = 8 + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [tmpFile]) + #expect(foo.cpu == 99) + } + } + + @Test func loadForPluginSubsectionMissingReturnsDefault() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [plugin.other] + cpu = 4 + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [tmpFile]) + #expect(foo.cpu == 99) + } + } + + @Test func loadForPluginFileMissingReturnsDefault() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let missing = tempDir.appending("nonexistent.toml") + let foo: TestPluginConfig = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [missing]) + #expect(foo.cpu == 99) + } + } + + @Test func loadForPluginIsolatesFromHostileSibling() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [plugin.mem] + memory = "2g" + + [plugin.other] + memory = "999g" + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + let mem: TestPluginConfig2 = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [tmpFile]) + #expect(mem.memory == "2g") + } + } + + @Test func loadForPluginMalformedSectionThrows() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let toml = """ + [plugin.mem] + memory = 42 + """ + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml(toml, to: tmpFile) + + await #expect(throws: (any Error).self) { + let _: TestPluginConfig2 = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [tmpFile]) + } + } + } + + @Test func loadForPluginEmptyIdThrows() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let tmpFile = tempDir.appending("test.toml") + try Self.writeToml("[plugin.foo]\ncpu = 4", to: tmpFile) + + await #expect(throws: (any Error).self) { + let _: EmptyIdPluginConfig = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [tmpFile]) + } + } + } + + @Test func layeredPrecedence() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let userFile = tempDir.appending("user.toml") + let systemFile = tempDir.appending("system.toml") + + try Self.writeToml( + """ + [build] + cpus = 8 + """, to: userFile) + + try Self.writeToml( + """ + [build] + cpus = 4 + memory = "4096MB" + """, to: systemFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [userFile, systemFile] + ) + #expect(config.build.cpus == 8) + let expectedMemory = try MemorySize("4096MB") + #expect(config.build.memory == expectedMemory) + } + } + + @Test func allFilesMissingReturnsDefaults() async throws { + let config: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [FilePath("/nonexistent/a.toml"), FilePath("/nonexistent/b.toml")] + ) + #expect(config.build.rosetta == true) + #expect(config.build.cpus == 2) + } + + @Test func partialOverlapMergesKeys() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let userFile = tempDir.appending("user.toml") + let systemFile = tempDir.appending("system.toml") + + try Self.writeToml( + """ + [dns] + domain = "user.local" + """, to: userFile) + + try Self.writeToml( + """ + [build] + cpus = 16 + """, to: systemFile) + + let config: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [userFile, systemFile] + ) + #expect(config.dns.domain == "user.local") + #expect(config.build.cpus == 16) + } + } + + @Test func pluginLayering() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let userFile = tempDir.appending("user.toml") + let systemFile = tempDir.appending("system.toml") + + try Self.writeToml( + """ + [plugin.foo] + cpu = 8 + """, to: userFile) + + try Self.writeToml( + """ + [plugin.foo] + cpu = 2 + """, to: systemFile) + + let config: TestPluginConfig = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [userFile, systemFile] + ) + #expect(config.cpu == 8) + } + } + + @Test func loadErrorIdentifiesMalformedLayerFile() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let userFile = tempDir.appending("user.toml") + let systemFile = tempDir.appending("system.toml") + + try Self.writeToml("[build]\ncpus = 8", to: userFile) + try Self.writeToml("this is [not valid toml", to: systemFile) + + let error = try #require( + await #expect(throws: (any Error).self) { + let _: ContainerSystemConfig = try await ConfigurationLoader.load( + configurationFiles: [userFile, systemFile]) + } + ) + #expect(String(describing: error).contains(systemFile.string)) + } + } + + @Test func loadForPluginErrorIdentifiesMalformedLayerFile() async throws { + try await TemporaryStorage.withTempDir { tempDir in + let userFile = tempDir.appending("user.toml") + let systemFile = tempDir.appending("system.toml") + + try Self.writeToml("this is [not valid toml", to: userFile) + try Self.writeToml("[plugin.foo]\ncpu = 2", to: systemFile) + + let error = try #require( + await #expect(throws: (any Error).self) { + let _: TestPluginConfig = try await ConfigurationLoader.loadForPlugin( + configurationFiles: [userFile, systemFile]) + } + ) + #expect(String(describing: error).contains(userFile.string)) + } + } +} diff --git a/Tests/ContainerPersistenceTests/FilePathSymlinksTests.swift b/Tests/ContainerPersistenceTests/FilePathSymlinksTests.swift new file mode 100644 index 0000000..26f307d --- /dev/null +++ b/Tests/ContainerPersistenceTests/FilePathSymlinksTests.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Foundation +import SystemPackage +import Testing + +@testable import ContainerPersistence + +struct FilePathSymlinksTests { + @Test func realPathReturnsAbsolutePath() throws { + let resolved = try FilePath("/tmp").resolvingSymlinks() + #expect(resolved.isAbsolute) + } + + @Test func symlinkResolvesToTarget() async throws { + try await TemporaryStorage.withTempDir { dir in + let target = dir.appending(FilePath.Component("target")) + let link = dir.appending(FilePath.Component("link")) + try "content".write(toFile: target.string, atomically: true, encoding: .utf8) + try FileManager.default.createSymbolicLink(atPath: link.string, withDestinationPath: target.string) + + #expect(try link.resolvingSymlinks() == target.resolvingSymlinks()) + } + } + + @Test func nonExistentPathThrows() { + #expect(throws: Errno.noSuchFileOrDirectory) { + try FilePath("/nonexistent/path/that/does/not/exist").resolvingSymlinks() + } + } + + @Test func relativePathResolvesToAbsolute() throws { + let resolved = try FilePath(".").resolvingSymlinks() + #expect(resolved.isAbsolute) + } +} diff --git a/Tests/ContainerPersistenceTests/FilesystemEntityStoreTests.swift b/Tests/ContainerPersistenceTests/FilesystemEntityStoreTests.swift new file mode 100644 index 0000000..03c136d --- /dev/null +++ b/Tests/ContainerPersistenceTests/FilesystemEntityStoreTests.swift @@ -0,0 +1,177 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerTestSupport +import Foundation +import Logging +import SystemPackage +import Testing + +@testable import ContainerPersistence + +private struct Item: Codable, Identifiable, Sendable, Equatable { + var id: String + var value: String +} + +struct FilesystemEntityStoreTests { + + @Test func testListEmpty() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + let items = try await store.list() + #expect(items.isEmpty) + } + } + + @Test func testCreateAndRetrieve() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.create(Item(id: "foo", value: "hello")) + let item = try await store.retrieve("foo") + #expect(item?.value == "hello") + } + } + + @Test func testRetrieveNonexistentReturnsNil() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + let result = try await store.retrieve("nope") + #expect(result == nil) + } + } + + @Test func testListAfterCreate() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.create(Item(id: "a", value: "1")) + try await store.create(Item(id: "b", value: "2")) + let items = try await store.list() + #expect(items.count == 2) + #expect(Set(items.map(\.id)) == ["a", "b"]) + } + } + + @Test func testCreateDuplicateThrows() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.create(Item(id: "dup", value: "x")) + await #expect(throws: Error.self) { + try await store.create(Item(id: "dup", value: "y")) + } + } + } + + @Test func testUpdate() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.create(Item(id: "x", value: "v1")) + try await store.update(Item(id: "x", value: "v2")) + let item = try await store.retrieve("x") + #expect(item?.value == "v2") + } + } + + @Test func testUpdateNonexistentThrows() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + await #expect(throws: Error.self) { + try await store.update(Item(id: "ghost", value: "x")) + } + } + } + + @Test func testDelete() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.create(Item(id: "del", value: "v")) + try await store.delete("del") + let item = try await store.retrieve("del") + #expect(item == nil) + } + } + + @Test func testDeleteRemovesDirectory() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.create(Item(id: "dir", value: "v")) + let entityDir = try store.entityPath("dir") + #expect(FileManager.default.fileExists(atPath: entityDir.string)) + try await store.delete("dir") + #expect(!FileManager.default.fileExists(atPath: entityDir.string)) + } + } + + @Test func testDeleteNonexistentThrows() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + await #expect(throws: Error.self) { + try await store.delete("none") + } + } + } + + @Test func testUpsertCreates() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.upsert(Item(id: "u", value: "new")) + let item = try await store.retrieve("u") + #expect(item?.value == "new") + } + } + + @Test func testUpsertUpdates() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + try await store.create(Item(id: "u", value: "old")) + try await store.upsert(Item(id: "u", value: "new")) + let item = try await store.retrieve("u") + #expect(item?.value == "new") + } + } + + @Test func testPersistenceAcrossReinit() async throws { + try await TemporaryStorage.withTempDir { path in + let store1 = try Self.makeStore(at: path) + try await store1.create(Item(id: "persist", value: "durable")) + + let store2 = try Self.makeStore(at: path) + let item = try await store2.retrieve("persist") + #expect(item?.value == "durable") + } + } + + @Test func testEntityPathIsIdUnderRoot() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + let entityPath = try store.entityPath("myentity") + #expect(entityPath == path.appending("myentity")) + } + } + + @Test func testEntityIdWithSlashThrows() async throws { + try await TemporaryStorage.withTempDir { path in + let store = try Self.makeStore(at: path) + await #expect(throws: Error.self) { + try await store.create(Item(id: "foo/bar", value: "x")) + } + } + } + + private static func makeStore(at path: FilePath) throws -> FilesystemEntityStore { + try FilesystemEntityStore(path: path, type: "item", log: Logger(label: "test")) + } +} diff --git a/Tests/ContainerPersistenceTests/MachineConfigTests.swift b/Tests/ContainerPersistenceTests/MachineConfigTests.swift new file mode 100644 index 0000000..8ea6e51 --- /dev/null +++ b/Tests/ContainerPersistenceTests/MachineConfigTests.swift @@ -0,0 +1,95 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerizationError +import Foundation +import SystemPackage +import Testing + +struct MachineConfigTests { + @Test func virtualizationDefaultsToFalse() throws { + let config = MachineConfig.default + #expect(config.virtualization == false) + #expect(config.kernelPath == nil) + } + + @Test func withSetsVirtualizationTrue() throws { + let updated = try MachineConfig.default.with(["virtualization": "true"]) + #expect(updated.virtualization == true) + } + + @Test func withSetsVirtualizationFalse() throws { + let enabled = try MachineConfig.default.with(["virtualization": "true"]) + let cleared = try enabled.with(["virtualization": "false"]) + #expect(cleared.virtualization == false) + } + + @Test func withRejectsBogusBoolean() { + #expect(throws: ContainerizationError.self) { + try MachineConfig.default.with(["virtualization": "yes"]) + } + } + + @Test func withSetsKernelPath() throws { + let updated = try MachineConfig.default.with(["kernel": "/opt/kernels/vmlinux"]) + #expect(updated.kernelPath == FilePath("/opt/kernels/vmlinux")) + } + + @Test func withEmptyKernelClearsKernelPath() throws { + let withCustom = try MachineConfig.default.with(["kernel": "/opt/kernels/vmlinux"]) + #expect(withCustom.kernelPath == FilePath("/opt/kernels/vmlinux")) + let cleared = try withCustom.with(["kernel": ""]) + #expect(cleared.kernelPath == nil) + } + + @Test func withPreservesUnchangedFields() throws { + let updated = try MachineConfig.default.with([ + "virtualization": "true", + "kernel": "/opt/kernels/vmlinux", + ]) + #expect(updated.cpus == MachineConfig.default.cpus) + #expect(updated.memory.toUInt64(unit: .bytes) == MachineConfig.default.memory.toUInt64(unit: .bytes)) + #expect(updated.homeMount == MachineConfig.default.homeMount) + } + + @Test func decodingMissingFieldsUsesDefaults() throws { + // Older boot-config.json files predate virtualization/kernel — they must still load. + let legacy = #"{"cpus":4,"memory":"1gb","homeMount":"rw"}"# + let data = Data(legacy.utf8) + let decoded = try JSONDecoder().decode(MachineConfig.self, from: data) + #expect(decoded.cpus == 4) + #expect(decoded.virtualization == false) + #expect(decoded.kernelPath == nil) + } + + @Test func roundTripJSON() throws { + let config = try MachineConfig.default.with([ + "virtualization": "true", + "kernel": "/some/vmlinux", + ]) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(MachineConfig.self, from: data) + #expect(decoded.virtualization == true) + #expect(decoded.kernelPath == FilePath("/some/vmlinux")) + } + + @Test func settableKeysIncludeNewFields() { + let keys = MachineConfig.settableKeys.map(\.key) + #expect(keys.contains("virtualization")) + #expect(keys.contains("kernel")) + } +} diff --git a/Tests/ContainerPersistenceTests/PathUtilsTests.swift b/Tests/ContainerPersistenceTests/PathUtilsTests.swift new file mode 100644 index 0000000..c38f58a --- /dev/null +++ b/Tests/ContainerPersistenceTests/PathUtilsTests.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerVersion +import Foundation +import SystemPackage +import Testing + +struct PathUtilsTests { + private static let homeFallback = FilePath(NSHomeDirectory() + "/.config").appending("container") + private static let appRootFallback: FilePath = { + let url = FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first!.appendingPathComponent("com.apple.container") + return FilePath(url.path(percentEncoded: false)) + }() + + @Test func testHomeUsesXdgConfigHomeWhenSet() { + let path = PathUtils.BaseConfigPath.home.basePath(env: ["XDG_CONFIG_HOME": "/tmp/xdg-test"]) + #expect(path == FilePath("/tmp/xdg-test/container")) + } + + @Test func testHomeFallsBackToHomeDirectoryWhenXdgUnset() { + let path = PathUtils.BaseConfigPath.home.basePath(env: [:]) + #expect(path == Self.homeFallback) + } + + @Test func testHomeTreatsEmptyXdgAsUnset() { + let path = PathUtils.BaseConfigPath.home.basePath(env: ["XDG_CONFIG_HOME": ""]) + #expect(path == Self.homeFallback) + } + + @Test func testAppRootUsesContainerAppRootWhenSet() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["CONTAINER_APP_ROOT": "/tmp/foo"]) + #expect(path == FilePath("/tmp/foo")) + } + + @Test func testAppRootFallsBackToApplicationSupportWhenUnset() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: [:]) + #expect(path == Self.appRootFallback) + } + + @Test func testAppRootTreatsEmptyEnvAsUnset() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["CONTAINER_APP_ROOT": ""]) + #expect(path == Self.appRootFallback) + } + + @Test func testAppRootIgnoresXdgConfigHome() { + let path = PathUtils.BaseConfigPath.appRoot.basePath(env: ["XDG_CONFIG_HOME": "/tmp/xdg-test"]) + #expect(path == Self.appRootFallback) + } + + @Test func testHomeIgnoresContainerAppRoot() { + let path = PathUtils.BaseConfigPath.home.basePath(env: ["CONTAINER_APP_ROOT": "/tmp/foo"]) + #expect(path == Self.homeFallback) + } + + @Test func testInstallRootFromEnvVar() { + let path = PathUtils.BaseConfigPath.installRoot.basePath(env: ["CONTAINER_INSTALL_ROOT": "/usr/local"]) + #expect(path == FilePath("/usr/local")) + } +} diff --git a/Tests/ContainerPluginTests/FilePath+ResolveTests.swift b/Tests/ContainerPluginTests/FilePath+ResolveTests.swift new file mode 100644 index 0000000..b9d9334 --- /dev/null +++ b/Tests/ContainerPluginTests/FilePath+ResolveTests.swift @@ -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 SystemPackage +import Testing + +@testable import ContainerPlugin + +private let cwd = FilePath("/current/dir") + +struct FilePathResolveTests { + @Test func nilWhenPathnameIsNil() { + #expect(cwd.resolve(nil) == nil) + } + + @Test func nilWhenPathnameIsEmpty() { + #expect(cwd.resolve("") == nil) + } + + @Test func absolutePathnameReturnedAsIs() { + #expect(cwd.resolve("/custom/root") == FilePath("/custom/root")) + } + + @Test func relativePathnamePrependsCurrentDirectory() { + #expect(cwd.resolve("data") == FilePath("/current/dir/data")) + } + + @Test func relativePathnameWithDotDotIsLexicallyNormalized() { + #expect(cwd.resolve("../sibling") == FilePath("/current/sibling")) + } + + @Test func relativePathnameWithDotIsLexicallyNormalized() { + #expect(cwd.resolve("./data") == FilePath("/current/dir/data")) + } + + @Test func absolutePathnameWithDotDotIsLexicallyNormalized() { + #expect(cwd.resolve("/custom/../root") == FilePath("/root")) + } + + @Test func absolutePathnameWithDotIsLexicallyNormalized() { + #expect(cwd.resolve("/custom/./root") == FilePath("/custom/root")) + } + + @Test func defaultPathUsedWhenPathnameIsNil() { + let fallback = FilePath("/fallback") + #expect(cwd.resolve(nil, defaultPath: fallback) == fallback) + } + + @Test func defaultPathUsedWhenPathnameIsEmpty() { + let fallback = FilePath("/fallback") + #expect(cwd.resolve("", defaultPath: fallback) == fallback) + } + + @Test func defaultPathIsLexicallyNormalized() { + #expect(cwd.resolve(nil, defaultPath: FilePath("/fallback/../normalized")) == FilePath("/normalized")) + } + + @Test func absolutePathnameOverridesDefaultPath() { + #expect(cwd.resolve("/custom", defaultPath: FilePath("/fallback")) == FilePath("/custom")) + } +} diff --git a/Tests/ContainerPluginTests/MockPluginFactory.swift b/Tests/ContainerPluginTests/MockPluginFactory.swift new file mode 100644 index 0000000..272af8f --- /dev/null +++ b/Tests/ContainerPluginTests/MockPluginFactory.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPlugin +import Foundation +import Testing + +struct MockPluginError: Error {} + +struct MockPluginFactory: PluginFactory { + public static let throwSuffix = "throw" + + private let plugins: [URL: Plugin] + + private let throwingURL: URL + + public init(tempURL: URL, plugins: [String: Plugin?]) throws { + let fm = FileManager.default + var prefixedPlugins: [URL: Plugin] = [:] + for (suffix, plugin) in plugins { + let url = tempURL.appending(path: suffix) + try fm.createDirectory(at: url, withIntermediateDirectories: true) + prefixedPlugins[url.standardizedFileURL] = plugin + } + self.plugins = prefixedPlugins + self.throwingURL = tempURL.appending(path: Self.throwSuffix).standardizedFileURL + } + + public func create(installURL: URL) throws -> Plugin? { + let url = installURL.standardizedFileURL + guard url != self.throwingURL else { + throw MockPluginError() + } + return plugins[url] + } + + public func create(parentURL: URL, name: String) throws -> Plugin? { + let url = parentURL.appendingPathComponent(name).standardizedFileURL + guard url != self.throwingURL else { + throw MockPluginError() + } + return plugins[url] + } + +} diff --git a/Tests/ContainerPluginTests/PluginConfigTest.swift b/Tests/ContainerPluginTests/PluginConfigTest.swift new file mode 100644 index 0000000..5880492 --- /dev/null +++ b/Tests/ContainerPluginTests/PluginConfigTest.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerPlugin + +struct PluginConfigTest { + @Test + func testCLIPluginConfigLoad() async throws { + let tempURL = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let configURL = tempURL.appending(path: "config.toml") + let configToml = """ + abstract = "Default network management service" + author = "Apple" + """ + try configToml.write(to: configURL, atomically: true, encoding: .utf8) + let config = try #require(try PluginConfig(configURL: configURL)) + + #expect(config.isCLI) + #expect(config.abstract == "Default network management service") + #expect(config.author == "Apple") + } + + @Test + func testServicePluginConfigLoad() async throws { + let tempURL = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let configURL = tempURL.appending(path: "config.toml") + let configToml = """ + abstract = "Default network management service" + author = "Apple" + version = 0.1 + + [servicesConfig] + loadAtBoot = true + runAtLoad = true + defaultArguments = ["start"] + + [[servicesConfig.services]] + type = "network" + description = "foo" + """ + try configToml.write(to: configURL, atomically: true, encoding: .utf8) + let config = try #require(try PluginConfig(configURL: configURL)) + + #expect(!config.isCLI) + #expect(config.abstract == "Default network management service") + #expect(config.author == "Apple") + + let servicesConfig = try #require(config.servicesConfig) + #expect(servicesConfig.loadAtBoot) + #expect(servicesConfig.runAtLoad) + #expect(servicesConfig.services.count == 1) + #expect(servicesConfig.services[0].type == .network) + #expect(servicesConfig.services[0].description == "foo") + #expect(servicesConfig.defaultArguments == ["start"]) + } + + @Test + func testMalformedTomlThrows() async throws { + let tempURL = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let configURL = tempURL.appending(path: "config.toml") + let malformedToml = """ + abstract = "unclosed string + [invalid + """ + try malformedToml.write(to: configURL, atomically: true, encoding: .utf8) + #expect(throws: (any Error).self) { + try PluginConfig(configURL: configURL) + } + } + + @Test + func testUnsupportedExtensionReturnsNil() async throws { + let tempURL = try FileManager.default.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let configURL = tempURL.appending(path: "config.yaml") + let content = """ + abstract: "YAML config" + author: "Apple" + """ + try content.write(to: configURL, atomically: true, encoding: .utf8) + let config = try PluginConfig(configURL: configURL) + #expect(config == nil) + } +} diff --git a/Tests/ContainerPluginTests/PluginFactoryTest.swift b/Tests/ContainerPluginTests/PluginFactoryTest.swift new file mode 100644 index 0000000..cc15456 --- /dev/null +++ b/Tests/ContainerPluginTests/PluginFactoryTest.swift @@ -0,0 +1,331 @@ +//===----------------------------------------------------------------------===// +// 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 +import Testing + +@testable import ContainerPlugin + +struct PluginFactoryTest { + @Test + func testDefaultFactory() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let name = tempURL.lastPathComponent + + // write config to {name}/config.toml + let configURL = tempURL.appending(path: "config.toml") + let configToml = """ + abstract = "Default network management service" + author = "Apple" + """ + try configToml.write(to: configURL, atomically: true, encoding: .utf8) + + // write binary to {name}/bin/{name} + let binaryDirURL = tempURL.appending(path: "bin") + try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) + let binaryURL = binaryDirURL.appending(path: name) + try "".write(to: binaryURL, atomically: true, encoding: .utf8) + + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + let plugin = try #require(try factory.create(installURL: tempURL)) + + #expect(plugin.name == name) + #expect(!plugin.shouldBoot) + #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") + #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") + #expect(plugin.getMachServices() == []) + #expect(plugin.getMachServices(instanceId: "1") == []) + #expect(plugin.getMachService(type: .runtime) == nil) + #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) + #expect(!plugin.hasType(.runtime)) + #expect(!plugin.hasType(.network)) + #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) + } + + @Test + func testDefaultFactoryByName() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let name = tempURL.lastPathComponent + + // write config to {name}/config.toml + let configURL = tempURL.appending(path: "config.toml") + let configToml = """ + abstract = "Default network management service" + author = "Apple" + """ + try configToml.write(to: configURL, atomically: true, encoding: .utf8) + + // write binary to {name}/bin/{name} + let binaryDirURL = tempURL.appending(path: "bin") + try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) + let binaryURL = binaryDirURL.appending(path: name) + try "".write(to: binaryURL, atomically: true, encoding: .utf8) + + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + let plugin = try #require(try factory.create(parentURL: tempURL.deletingLastPathComponent(), name: name)) + + #expect(plugin.name == name) + #expect(!plugin.shouldBoot) + #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") + #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") + #expect(plugin.getMachServices() == []) + #expect(plugin.getMachServices(instanceId: "1") == []) + #expect(plugin.getMachService(type: .runtime) == nil) + #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) + #expect(!plugin.hasType(.runtime)) + #expect(!plugin.hasType(.network)) + #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) + } + + @Test + func testDefaultFactoryMissingConfig() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let name = tempURL.lastPathComponent + + // write binary to {name}/bin/{name} + let binaryDirURL = tempURL.appending(path: "bin") + try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) + let binaryURL = binaryDirURL.appending(path: name) + try "".write(to: binaryURL, atomically: true, encoding: .utf8) + + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + let plugin = try factory.create(installURL: tempURL) + #expect(plugin == nil) + } + + @Test + func testDefaultFactoryMissingBinary() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + + // write config to {name}/config.toml + let configURL = tempURL.appending(path: "config.toml") + let configToml = """ + abstract = "Default network management service" + author = "Apple" + """ + try configToml.write(to: configURL, atomically: true, encoding: .utf8) + + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + let plugin = try factory.create(installURL: tempURL) + #expect(plugin == nil) + } + + @Test + func testAppBundleFactory() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let installURL = tempURL.appending(path: "test.app") + try fm.createDirectory(at: installURL, withIntermediateDirectories: true) + let name = String(installURL.lastPathComponent.dropLast(4)) + + // write config to {name}/config.toml + let configURL = + installURL + .appending(path: "Contents") + .appending(path: "Resources") + .appending(path: "config.toml") + let configToml = """ + abstract = "Default network management service" + author = "Apple" + """ + try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try configToml.write(to: configURL, atomically: true, encoding: .utf8) + + // write binary to {name}/bin/{name} + let binaryURL = + installURL + .appending(path: "Contents") + .appending(path: "MacOS") + .appending(path: name) + try fm.createDirectory(at: binaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try "".write(to: binaryURL, atomically: true, encoding: .utf8) + + let factory = AppBundlePluginFactory(logger: Logger(label: "test")) + let plugin = try #require(try factory.create(installURL: installURL)) + + #expect(plugin.name == name) + #expect(!plugin.shouldBoot) + #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") + #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") + #expect(plugin.getMachServices() == []) + #expect(plugin.getMachServices(instanceId: "1") == []) + #expect(plugin.getMachService(type: .runtime) == nil) + #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) + #expect(!plugin.hasType(.runtime)) + #expect(!plugin.hasType(.network)) + #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) + } + + @Test + func testAppBundleFactoryByName() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let installURL = tempURL.appending(path: "test.app") + try fm.createDirectory(at: installURL, withIntermediateDirectories: true) + let name = String(installURL.lastPathComponent.dropLast(4)) + + // write config to {name}/config.toml + let configURL = + installURL + .appending(path: "Contents") + .appending(path: "Resources") + .appending(path: "config.toml") + let configToml = """ + abstract = "Default network management service" + author = "Apple" + """ + try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try configToml.write(to: configURL, atomically: true, encoding: .utf8) + + // write binary to {name}/bin/{name} + let binaryURL = + installURL + .appending(path: "Contents") + .appending(path: "MacOS") + .appending(path: name) + try fm.createDirectory(at: binaryURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try "".write(to: binaryURL, atomically: true, encoding: .utf8) + + let factory = AppBundlePluginFactory(logger: Logger(label: "test")) + let plugin = try #require(try factory.create(parentURL: tempURL, name: name)) + + #expect(plugin.name == name) + #expect(!plugin.shouldBoot) + #expect(plugin.getLaunchdLabel() == "com.apple.container.\(name)") + #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.\(name).1") + #expect(plugin.getMachServices() == []) + #expect(plugin.getMachServices(instanceId: "1") == []) + #expect(plugin.getMachService(type: .runtime) == nil) + #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) + #expect(!plugin.hasType(.runtime)) + #expect(!plugin.hasType(.network)) + #expect(plugin.helpText(padding: 40).hasSuffix("Default network management service")) + } + + @Test + func testDefaultFactoryFallsBackToJson() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let name = tempURL.lastPathComponent + + // write config to {name}/config.json (no config.toml) + let configURL = tempURL.appending(path: "config.json") + let configJson = """ + {"abstract": "JSON fallback service", "author": "Apple"} + """ + try configJson.write(to: configURL, atomically: true, encoding: .utf8) + + // write binary to {name}/bin/{name} + let binaryDirURL = tempURL.appending(path: "bin") + try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) + let binaryURL = binaryDirURL.appending(path: name) + try "".write(to: binaryURL, atomically: true, encoding: .utf8) + + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + let plugin = try #require(try factory.create(installURL: tempURL)) + + #expect(plugin.name == name) + #expect(plugin.config.abstract == "JSON fallback service") + #expect(plugin.config.author == "Apple") + } + + @Test + func testDefaultFactoryPrefersTomlOverJson() async throws { + let fm = FileManager.default + let tempURL = try fm.url( + for: .itemReplacementDirectory, + in: .userDomainMask, + appropriateFor: .temporaryDirectory, + create: true + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + let name = tempURL.lastPathComponent + + // write config.toml + let tomlURL = tempURL.appending(path: "config.toml") + let configToml = """ + abstract = "TOML service" + author = "Apple" + """ + try configToml.write(to: tomlURL, atomically: true, encoding: .utf8) + + // write config.json with a different abstract + let jsonURL = tempURL.appending(path: "config.json") + let configJson = """ + {"abstract": "JSON service", "author": "Apple"} + """ + try configJson.write(to: jsonURL, atomically: true, encoding: .utf8) + + // write binary to {name}/bin/{name} + let binaryDirURL = tempURL.appending(path: "bin") + try fm.createDirectory(at: binaryDirURL, withIntermediateDirectories: true) + let binaryURL = binaryDirURL.appending(path: name) + try "".write(to: binaryURL, atomically: true, encoding: .utf8) + + let factory = DefaultPluginFactory(logger: Logger(label: "test")) + let plugin = try #require(try factory.create(installURL: tempURL)) + + #expect(plugin.name == name) + #expect(plugin.config.abstract == "TOML service") + } +} diff --git a/Tests/ContainerPluginTests/PluginLoaderTest.swift b/Tests/ContainerPluginTests/PluginLoaderTest.swift new file mode 100644 index 0000000..20a3b4e --- /dev/null +++ b/Tests/ContainerPluginTests/PluginLoaderTest.swift @@ -0,0 +1,292 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerPlugin + +struct PluginLoaderTest { + @Test + func testFindAll() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let factory = try setupMock(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + let plugins = loader.findPlugins() + + #expect(Set(plugins.map { $0.name }) == Set(["cli", "service"])) + } + + @Test + func testFindAllSymlink() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let factory = try setupMock(tempURL: tempURL) + + // move the CLI plugin elsewhere and symlink it + let otherTempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: otherTempURL) } + try FileManager.default.createDirectory(at: otherTempURL, withIntermediateDirectories: true) + let srcURL = tempURL.appendingPathComponent("cli") + let dstURL = otherTempURL.appendingPathComponent("cli") + try FileManager.default.moveItem( + at: srcURL, + to: dstURL + ) + try FileManager.default.createSymbolicLink( + at: srcURL, + withDestinationURL: dstURL + ) + + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + let plugins = loader.findPlugins() + + #expect(Set(plugins.map { $0.name }) == Set(["cli", "service"])) + } + + @Test + func testFindByName() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let factory = try setupMock(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + #expect(loader.findPlugin(name: "cli")?.name == "cli") + #expect(loader.findPlugin(name: "service")?.name == "service") + #expect(loader.findPlugin(name: "throw") == nil) + } + + @Test + func testFilterEnvironmentWithContainerPrefix() async throws { + let env = [ + "CONTAINER_FOO": "bar", + "CONTAINER_BAZ": "qux", + "OTHER_VAR": "value", + ] + let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: []) + + #expect(filtered == ["CONTAINER_FOO": "bar", "CONTAINER_BAZ": "qux"]) + } + + @Test + func testFilterEnvironmentWithProxyKeys() async throws { + let env = [ + "http_proxy": "http://proxy:8080", + "HTTP_PROXY": "http://proxy:8080", + "https_proxy": "https://proxy:8443", + "HTTPS_PROXY": "https://proxy:8443", + "no_proxy": "localhost,127.0.0.1", + "NO_PROXY": "localhost,127.0.0.1", + "OTHER_VAR": "value", + ] + let filtered = PluginLoader.filterEnvironment(env: env) + + #expect( + filtered == [ + "http_proxy": "http://proxy:8080", + "HTTP_PROXY": "http://proxy:8080", + "https_proxy": "https://proxy:8443", + "HTTPS_PROXY": "https://proxy:8443", + "no_proxy": "localhost,127.0.0.1", + "NO_PROXY": "localhost,127.0.0.1", + ]) + } + + @Test + func testFilterEnvironmentWithBothContainerAndProxy() async throws { + let env = [ + "CONTAINER_FOO": "bar", + "http_proxy": "http://proxy:8080", + "OTHER_VAR": "value", + "ANOTHER_VAR": "value2", + ] + let filtered = PluginLoader.filterEnvironment(env: env) + + #expect( + filtered == [ + "CONTAINER_FOO": "bar", + "http_proxy": "http://proxy:8080", + ]) + } + + @Test + func testFilterEnvironmentWithCustomAllowKeys() async throws { + let env = [ + "CONTAINER_FOO": "bar", + "CUSTOM_KEY": "custom_value", + "OTHER_VAR": "value", + ] + let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: ["CUSTOM_KEY"]) + + #expect( + filtered == [ + "CONTAINER_FOO": "bar", + "CUSTOM_KEY": "custom_value", + ]) + } + + #if CONTAINER_COVERAGE + @Test + func testFilterEnvironmentWithLLVMProfileFile() async throws { + let env = [ + "LLVM_PROFILE_FILE": "/tmp/coverage/%p-%m%c.profraw", + "OTHER_VAR": "value", + ] + let filtered = PluginLoader.filterEnvironment(env: env) + + #expect(filtered == ["LLVM_PROFILE_FILE": "/tmp/coverage/%p-%m%c.profraw"]) + } + #endif + + @Test + func testFilterEnvironmentEmpty() async throws { + let filtered = PluginLoader.filterEnvironment(env: [:]) + + #expect(filtered.isEmpty) + } + + @Test + func testFilterEnvironmentNoMatches() async throws { + let env = [ + "PATH": "/usr/bin", + "HOME": "/Users/test", + "USER": "testuser", + ] + let filtered = PluginLoader.filterEnvironment(env: env, additionalAllowKeys: []) + + #expect(filtered.isEmpty) + } + + @Test + func testRegisterWithLaunchdDebugTrue() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let factory = try setupMock(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + let plugin = loader.findPlugin(name: "service")! + let stateRoot = tempURL.appendingPathComponent("test-state") + try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot, debug: true) + + let plistURL = stateRoot.appendingPathComponent("service.plist") + #expect(FileManager.default.fileExists(atPath: plistURL.path)) + + let plistData = try Data(contentsOf: plistURL) + let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any] + let programArguments = plist["ProgramArguments"] as! [String] + + #expect(programArguments.contains("--debug")) + } + + @Test + func testRegisterWithLaunchdDebugFalse() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let factory = try setupMock(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + let plugin = loader.findPlugin(name: "service")! + let stateRoot = tempURL.appendingPathComponent("test-state") + try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot, debug: false) + + let plistURL = stateRoot.appendingPathComponent("service.plist") + #expect(FileManager.default.fileExists(atPath: plistURL.path)) + + let plistData = try Data(contentsOf: plistURL) + let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any] + let programArguments = plist["ProgramArguments"] as! [String] + + #expect(!programArguments.contains("--debug")) + } + + @Test + func testRegisterWithLaunchdDebugDefault() async throws { + let tempURL = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { try? FileManager.default.removeItem(at: tempURL) } + let factory = try setupMock(tempURL: tempURL) + let loader = try PluginLoader( + appRoot: tempURL, + installRoot: URL(filePath: "/usr/local/"), + logRoot: nil, + pluginDirectories: [tempURL], + pluginFactories: [factory] + ) + + let plugin = loader.findPlugin(name: "service")! + let stateRoot = tempURL.appendingPathComponent("test-state") + try loader.registerWithLaunchd(plugin: plugin, pluginStateRoot: stateRoot) + + let plistURL = stateRoot.appendingPathComponent("service.plist") + #expect(FileManager.default.fileExists(atPath: plistURL.path)) + + let plistData = try Data(contentsOf: plistURL) + let plist = try PropertyListSerialization.propertyList(from: plistData, format: nil) as! [String: Any] + let programArguments = plist["ProgramArguments"] as! [String] + + #expect(!programArguments.contains("--debug")) + } + + private func setupMock(tempURL: URL) throws -> MockPluginFactory { + let cliConfig = PluginConfig(abstract: "cli", author: "CLI", servicesConfig: nil) + let cliPlugin: Plugin = Plugin(binaryURL: URL(filePath: "/bin/cli"), config: cliConfig) + let serviceServicesConfig = PluginConfig.ServicesConfig( + loadAtBoot: false, + runAtLoad: false, + services: [PluginConfig.Service(type: .runtime, description: nil)], + defaultArguments: [] + ) + let serviceConfig = PluginConfig(abstract: "service", author: "SERVICE", servicesConfig: serviceServicesConfig) + let servicePlugin: Plugin = Plugin(binaryURL: URL(filePath: "/bin/service"), config: serviceConfig) + let mockPlugins = [ + "cli": cliPlugin, + MockPluginFactory.throwSuffix: nil, + "service": servicePlugin, + ] + + return try MockPluginFactory(tempURL: tempURL, plugins: mockPlugins) + } +} diff --git a/Tests/ContainerPluginTests/PluginTest.swift b/Tests/ContainerPluginTests/PluginTest.swift new file mode 100644 index 0000000..a69775b --- /dev/null +++ b/Tests/ContainerPluginTests/PluginTest.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerPlugin + +struct PluginTest { + @Test + func testCLIPlugin() async throws { + let config = PluginConfig( + abstract: "abstract", + author: "Ted Klondike", + servicesConfig: nil + ) + + let binaryPath = "/usr/local/libexec/container/plugin/bin/container-foo" + let plugin = Plugin( + binaryURL: URL(filePath: binaryPath), + config: config + ) + + #expect(plugin.name == "container-foo") + #expect(!plugin.shouldBoot) + #expect(plugin.getLaunchdLabel() == "com.apple.container.container-foo") + #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.container-foo.1") + #expect(plugin.getMachServices() == []) + #expect(plugin.getMachServices(instanceId: "1") == []) + #expect(plugin.getMachService(type: .runtime) == nil) + #expect(plugin.getMachService(instanceId: "1", type: .runtime) == nil) + #expect(!plugin.hasType(.runtime)) + #expect(!plugin.hasType(.network)) + #expect(plugin.helpText(padding: 20) == " container-foo abstract") + } + + @Test + func testServicePlugin() async throws { + let config = PluginConfig( + abstract: "abstract", + author: "Ted Klondike", + servicesConfig: .init( + loadAtBoot: false, + runAtLoad: false, + services: [ + .init(type: .runtime, description: "runtime service") + ], + defaultArguments: ["foo-bar"] + ) + ) + + let binaryPath = "/usr/local/libexec/container/plugin/linux-sandboxd/bin/linux-sandboxd" + let plugin = Plugin( + binaryURL: URL(filePath: binaryPath), + config: config + ) + + #expect(plugin.name == "linux-sandboxd") + #expect(!plugin.shouldBoot) + #expect(plugin.getLaunchdLabel() == "com.apple.container.linux-sandboxd") + #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.linux-sandboxd.1") + #expect( + plugin.getMachServices() == [ + "com.apple.container.runtime.linux-sandboxd" + ]) + #expect( + plugin.getMachServices(instanceId: "1") == [ + "com.apple.container.runtime.linux-sandboxd.1" + ]) + #expect(plugin.getMachService(type: .runtime) == "com.apple.container.runtime.linux-sandboxd") + #expect(plugin.getMachService(instanceId: "1", type: .runtime) == "com.apple.container.runtime.linux-sandboxd.1") + #expect(plugin.hasType(.runtime)) + #expect(!plugin.hasType(.network)) + #expect(plugin.config.servicesConfig!.defaultArguments == ["foo-bar"]) + } + + @Test + func testMultipleServicePlugin() async throws { + let config = PluginConfig( + abstract: "abstract", + author: "Ted Klondike", + servicesConfig: .init( + loadAtBoot: true, + runAtLoad: true, + services: [ + .init(type: .runtime, description: "runtime service"), + .init(type: .network, description: "network service"), + ], + defaultArguments: ["start", "with", "params"] + ) + ) + + let binaryPath = "/usr/local/libexec/container/plugin/hydra/bin/hydra" + let plugin = Plugin( + binaryURL: URL(filePath: binaryPath), + config: config + ) + + #expect(plugin.name == "hydra") + #expect(plugin.shouldBoot) + #expect(plugin.getLaunchdLabel() == "com.apple.container.hydra") + #expect(plugin.getLaunchdLabel(instanceId: "1") == "com.apple.container.hydra.1") + #expect( + plugin.getMachServices() == [ + "com.apple.container.runtime.hydra", + "com.apple.container.network.hydra", + ]) + #expect( + plugin.getMachServices(instanceId: "1") == [ + "com.apple.container.runtime.hydra.1", + "com.apple.container.network.hydra.1", + ]) + #expect(plugin.getMachService(type: .network) == "com.apple.container.network.hydra") + #expect(plugin.getMachService(instanceId: "1", type: .network) == "com.apple.container.network.hydra.1") + #expect(plugin.hasType(.runtime)) + #expect(plugin.hasType(.network)) + #expect(plugin.config.servicesConfig!.defaultArguments == ["start", "with", "params"]) + } +} diff --git a/Tests/ContainerPluginTests/RootPathTests.swift b/Tests/ContainerPluginTests/RootPathTests.swift new file mode 100644 index 0000000..8efcae9 --- /dev/null +++ b/Tests/ContainerPluginTests/RootPathTests.swift @@ -0,0 +1,47 @@ +//===----------------------------------------------------------------------===// +// 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 SystemPackage +import Testing + +@testable import ContainerPlugin + +struct ApplicationRootTests { + @Test func defaultPathIsAbsolute() { + #expect(ApplicationRoot.defaultPath.isAbsolute) + } + + @Test func defaultPathEndsWithContainerComponent() { + #expect(ApplicationRoot.defaultPath.lastComponent?.string == "com.apple.container") + } +} + +struct InstallRootTests { + @Test func defaultPathIsAbsolute() { + #expect(InstallRoot.defaultPath.isAbsolute) + } + + @Test func defaultPathIsGrandparentOfExecutable() { + #expect(InstallRoot.defaultPath == CommandLine.executablePath.removingLastComponent().removingLastComponent()) + } +} + +struct LogRootTests { + @Test func pathIsNilWhenEnvUnset() { + // CONTAINER_LOG_ROOT is not set in the unit test environment + #expect(LogRoot.path == nil) + } +} diff --git a/Tests/ContainerResourceTests/ContainerConfigurationTests.swift b/Tests/ContainerResourceTests/ContainerConfigurationTests.swift new file mode 100644 index 0000000..b1aafa0 --- /dev/null +++ b/Tests/ContainerResourceTests/ContainerConfigurationTests.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +// Shared fixture (reused by later tasks' tests). If an initializer is rejected, +// correct it against Sources/ContainerResource/Image/ImageDescription.swift and +// Sources/ContainerResource/Container/ProcessConfiguration.swift. +func makeTestConfiguration( + id: String = "test-ctr", + labels: [String: String] = [:], + creationDate: Date? = nil +) -> ContainerConfiguration { + let image = ImageDescription( + reference: "docker.io/library/alpine:latest", + descriptor: .init( + mediaType: "application/vnd.oci.image.manifest.v1+json", + digest: "sha256:" + String(repeating: "0", count: 64), + size: 0 + ) + ) + let process = ProcessConfiguration( + executable: "/bin/sh", + arguments: [], + environment: [], + workingDirectory: "/", + terminal: false, + user: .id(uid: 0, gid: 0), + supplementalGroups: [], + rlimits: [] + ) + var config = ContainerConfiguration(id: id, image: image, process: process) + config.labels = labels + if let creationDate { config.creationDate = creationDate } + return config +} + +struct ContainerConfigurationResourcesTests { + @Test func roundTripsCpuOverhead() throws { + var config = makeTestConfiguration() + config.resources.cpuOverhead = 2 + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: data) + #expect(decoded.resources.cpuOverhead == 2) + } + + @Test func decodesMissingCpuOverheadAsDefault() throws { + let config = makeTestConfiguration() + let data = try JSONEncoder().encode(config) + var obj = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + var resources = try #require(obj["resources"] as? [String: Any]) + resources.removeValue(forKey: "cpuOverhead") + obj["resources"] = resources + let stripped = try JSONSerialization.data(withJSONObject: obj) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: stripped) + #expect(decoded.resources.cpuOverhead == 1) + } +} + +struct ContainerConfigurationCreationDateTests { + @Test func roundTripsCreationDate() throws { + let when = Date(timeIntervalSince1970: 1_700_000_000) + let config = makeTestConfiguration(creationDate: when) + let data = try JSONEncoder().encode(config) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: data) + #expect(decoded.creationDate == when) + } + + @Test func decodesMissingCreationDateAsEpoch() throws { + let config = makeTestConfiguration(creationDate: Date(timeIntervalSince1970: 1_700_000_000)) + let data = try JSONEncoder().encode(config) + var obj = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + obj.removeValue(forKey: "creationDate") + let stripped = try JSONSerialization.data(withJSONObject: obj) + let decoded = try JSONDecoder().decode(ContainerConfiguration.self, from: stripped) + #expect(decoded.creationDate == Date(timeIntervalSince1970: 0)) + } +} diff --git a/Tests/ContainerResourceTests/ManagedContainerTests.swift b/Tests/ContainerResourceTests/ManagedContainerTests.swift new file mode 100644 index 0000000..44a5ee4 --- /dev/null +++ b/Tests/ContainerResourceTests/ManagedContainerTests.swift @@ -0,0 +1,77 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +struct ContainerStatusTests { + @Test func roundTrips() throws { + let status = ContainerStatus(state: .running, networks: [], startedDate: nil) + let data = try JSONEncoder().encode(status) + let decoded = try JSONDecoder().decode(ContainerStatus.self, from: data) + #expect(decoded.state == .running) + #expect(decoded.networks.isEmpty) + #expect(decoded.startedDate == nil) + } +} + +struct ManagedContainerTests { + @Test func encodesIdConfigurationStatusShape() throws { + let mc = ManagedContainer( + configuration: makeTestConfiguration(id: "abc", labels: ["k": "v"]), + status: ContainerStatus(state: .running, networks: [], startedDate: nil) + ) + let data = try JSONEncoder().encode(mc) + let obj = try #require(try JSONSerialization.jsonObject(with: data) as? [String: Any]) + #expect(Set(obj.keys) == ["id", "configuration", "status"]) + #expect(obj["id"] as? String == "abc") + } + + @Test func factoryMapsSnapshotFields() { + let config = makeTestConfiguration(id: "abc") + let snapshot = ContainerSnapshot( + configuration: config, status: .running, networks: [], startedDate: nil + ) + let mc = ManagedContainer(snapshot) + #expect(mc.id == "abc") + #expect(mc.name == "abc") + #expect(mc.status.state == .running) + } + + @Test func nameValidAcceptsContainerNames() { + #expect(ManagedContainer.nameValid("my-container_1.2")) + #expect(ManagedContainer.nameValid("ABC")) + #expect(!ManagedContainer.nameValid("-bad")) + #expect(!ManagedContainer.nameValid("a b")) + } + + @Test func generateIdIsLowercasedUUID() { + let id = ManagedContainer.generateId() + #expect(id == id.lowercased()) + #expect(id.contains("-")) + #expect(UUID(uuidString: id) != nil) + } + + @Test func labelsDeriveFromConfiguration() { + let mc = ManagedContainer( + configuration: makeTestConfiguration(labels: ["com.example.role": "x"]), + status: ContainerStatus(state: .stopped, networks: [], startedDate: nil) + ) + #expect(mc.labels["com.example.role"] == "x") + } +} diff --git a/Tests/ContainerResourceTests/ManagedResourceTests.swift b/Tests/ContainerResourceTests/ManagedResourceTests.swift new file mode 100644 index 0000000..f0ce285 --- /dev/null +++ b/Tests/ContainerResourceTests/ManagedResourceTests.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +struct ManagedResourceTests { + + // Mock implementation to test the randomId function + struct MockManagedResource: ManagedResource { + var id: String + var name: String + var creationDate: Date + var labels: ResourceLabels + + static func nameValid(_ name: String) -> Bool { + true + } + } + + @Test("randomId generates valid hex string SHA256 hash format") + func testRandomIdFormat() { + let id = MockManagedResource.generateId() + + // SHA256 hash is 64 hex characters (256 bits / 4 bits per hex char) + #expect(id.count == 64, "randomId should generate 64 character string") + + // Should only contain valid hexadecimal characters (0-9, a-f) + let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdef") + let idCharacterSet = CharacterSet(charactersIn: id) + #expect( + hexCharacterSet.isSuperset(of: idCharacterSet), + "randomId should only contain hexadecimal characters (0-9, a-f)") + } + + @Test("randomId generates unique values") + func testRandomIdUniqueness() { + // Generate multiple IDs and verify they're all different + let ids = (0..<100).map { _ in MockManagedResource.generateId() } + let uniqueIds = Set(ids) + + #expect(uniqueIds.count == 100, "All generated IDs should be unique") + } + + @Test("randomId uses lowercase hexadecimal") + func testRandomIdLowercase() { + let id = MockManagedResource.generateId() + + // Should not contain uppercase letters + let uppercaseLetters = CharacterSet.uppercaseLetters + let idCharacterSet = CharacterSet(charactersIn: id) + #expect( + uppercaseLetters.isDisjoint(with: idCharacterSet), + "randomId should use lowercase hexadecimal characters") + } +} diff --git a/Tests/ContainerResourceTests/NetworkConfigurationTest.swift b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift new file mode 100644 index 0000000..e3fd1fa --- /dev/null +++ b/Tests/ContainerResourceTests/NetworkConfigurationTest.swift @@ -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 ContainerizationError +import ContainerizationExtras +import Testing + +@testable import ContainerResource + +struct NetworkConfigurationTest { + @Test func testValidationOkDefaults() throws { + let id = "foo" + _ = try NetworkConfiguration( + name: id, + mode: .nat, + plugin: "container-network-vmnet" + ) + } + + @Test func testValidationGoodId() throws { + let ids = [ + String(repeating: "0", count: 63), + "0", + "0-_.1", + ] + for id in ids { + let ipv4Subnet = try CIDRv4("192.168.64.1/24") + let labels = try ResourceLabels([ + "foo": "bar", + "baz": String(repeating: "0", count: 4096 - "baz".count - "=".count), + ]) + _ = try NetworkConfiguration( + name: id, + mode: .nat, + ipv4Subnet: ipv4Subnet, + labels: labels, + plugin: "container-network-vmnet" + ) + } + } + + @Test func testValidationBadId() throws { + let ids = [ + String(repeating: "0", count: 64), + "-foo", + "foo_", + "Foo", + ] + for id in ids { + let ipv4Subnet = try CIDRv4("192.168.64.1/24") + let labels = try ResourceLabels([ + "foo": "bar", + "baz": String(repeating: "0", count: 4096 - "baz".count - "=".count), + ]) + #expect { + _ = try NetworkConfiguration( + name: id, + mode: .nat, + ipv4Subnet: ipv4Subnet, + labels: labels, + plugin: "container-network-vmnet" + ) + } throws: { error in + guard let err = error as? ContainerizationError else { return false } + #expect(err.code == .invalidArgument) + #expect(err.message.starts(with: "invalid network name")) + return true + } + } + } + +} diff --git a/Tests/ContainerResourceTests/PublishPortTests.swift b/Tests/ContainerResourceTests/PublishPortTests.swift new file mode 100644 index 0000000..8227adb --- /dev/null +++ b/Tests/ContainerResourceTests/PublishPortTests.swift @@ -0,0 +1,109 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Foundation +import Testing + +@testable import ContainerResource + +struct PublishPortTests { + @Test + func testPublishPortsNonOverlapping() throws { + let ports = [ + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 100), + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), + ] + #expect(!ports.hasOverlaps()) + } + + @Test + func testPublishPortsOverlapping() throws { + let ports = [ + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9000, containerPort: 8080, proto: .tcp, count: 101), + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 9100, containerPort: 8180, proto: .tcp, count: 100), + ] + #expect(ports.hasOverlaps()) + } + + @Test + func testPublishPortsSamePortDifferentProtocols() throws { + let ports = [ + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .tcp, count: 1), + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .udp, count: 1), + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .tcp, count: 1025), + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 1024, containerPort: 1024, proto: .udp, count: 1025), + ] + #expect(!ports.hasOverlaps()) + } + + @Test + func testPublishPortHostPortOverflowRejected() throws { + #expect(throws: (any Error).self) { + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 65535, containerPort: 8080, proto: .tcp, count: 2) + } + } + + @Test + func testPublishPortContainerPortOverflowRejected() throws { + #expect(throws: (any Error).self) { + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 65535, proto: .tcp, count: 2) + } + } + + @Test + func testPublishPortZeroCountRejected() throws { + #expect(throws: (any Error).self) { + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 8080, containerPort: 8080, proto: .tcp, count: 0) + } + } + + @Test + func testPublishPortRangeEndingAtMaxValid() throws { + // hostPort 65534 + count 2 → last port 65535, should be accepted + _ = try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 65534, containerPort: 8080, proto: .tcp, count: 2) + } + + @Test + func testPublishPortSingleMaxPortValid() throws { + _ = try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 65535, containerPort: 8080, proto: .tcp, count: 1) + } + + @Test + func testPublishPortDecodeRejectsHostPortOverflow() throws { + let json = Data(#"{"hostAddress":"0.0.0.0","hostPort":65535,"containerPort":8080,"proto":"tcp","count":2}"#.utf8) + #expect(throws: (any Error).self) { + try JSONDecoder().decode(PublishPort.self, from: json) + } + } + + @Test + func testPublishPortDecodeRejectsContainerPortOverflow() throws { + let json = Data(#"{"hostAddress":"0.0.0.0","hostPort":8080,"containerPort":65535,"proto":"tcp","count":2}"#.utf8) + #expect(throws: (any Error).self) { + try JSONDecoder().decode(PublishPort.self, from: json) + } + } + + @Test + func testHasOverlapsAtMaxPort() throws { + let ports = [ + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 65534, containerPort: 8080, proto: .tcp, count: 2), + try PublishPort(hostAddress: try IPAddress("0.0.0.0"), hostPort: 65534, containerPort: 9090, proto: .tcp, count: 1), + ] + #expect(ports.hasOverlaps()) + } +} diff --git a/Tests/ContainerResourceTests/PublishSocketTests.swift b/Tests/ContainerResourceTests/PublishSocketTests.swift new file mode 100644 index 0000000..6d40d2f --- /dev/null +++ b/Tests/ContainerResourceTests/PublishSocketTests.swift @@ -0,0 +1,241 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SystemPackage +import Testing + +@testable import ContainerResource + +/// Tests covering the custom `Codable` implementation and validating +/// initializer on ``PublishSocket``. +/// +/// `containerPath` and `hostPath` were migrated from `URL` to `FilePath`. The +/// wire format was simultaneously changed from `URL.absoluteString` +/// (e.g. `"file:///var/run/docker.sock"`) to the plain absolute path string +/// (e.g. `"/var/run/docker.sock"`). The decoder retains compatibility with +/// the legacy file-URL form so persisted bundles from earlier releases +/// continue to load. +struct PublishSocketTests { + // MARK: - init validation + + @Test + func testInitAcceptsAbsolutePaths() throws { + let socket = try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("/Users/me/docker.sock") + ) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/Users/me/docker.sock")) + #expect(socket.permissions == nil) + } + + @Test + func testInitRejectsRelativeContainerPath() { + #expect(throws: ContainerizationError.self) { + try PublishSocket( + containerPath: FilePath("relative/path.sock"), + hostPath: FilePath("/host.sock") + ) + } + } + + @Test + func testInitRejectsRelativeHostPath() { + #expect(throws: ContainerizationError.self) { + try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("relative/host.sock") + ) + } + } + + // MARK: - Encoding (plain absolute path) + + @Test + func testEncodeProducesPlainAbsolutePath() throws { + let socket = try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("/Users/me/docker.sock") + ) + let json = try JSONEncoder().encode(socket) + let decoded = try #require(try JSONSerialization.jsonObject(with: json) as? [String: Any]) + #expect(decoded["containerPath"] as? String == "/var/run/docker.sock") + #expect(decoded["hostPath"] as? String == "/Users/me/docker.sock") + #expect(decoded["permissions"] == nil) + } + + @Test + func testEncodeDoesNotPercentEncode() throws { + // Plain-path encoding preserves spaces and special characters verbatim + // (no URL percent-encoding layer). + let socket = try PublishSocket( + containerPath: FilePath("/tmp/a b.sock"), + hostPath: FilePath("/tmp/dir with spaces/sock") + ) + let json = try JSONEncoder().encode(socket) + let decoded = try #require(try JSONSerialization.jsonObject(with: json) as? [String: Any]) + #expect(decoded["containerPath"] as? String == "/tmp/a b.sock") + #expect(decoded["hostPath"] as? String == "/tmp/dir with spaces/sock") + } + + // MARK: - Decoding (canonical plain-path form) + + @Test + func testDecodePlainAbsolutePath() throws { + let json = """ + {"containerPath":"/var/run/docker.sock","hostPath":"/Users/me/docker.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/Users/me/docker.sock")) + } + + // MARK: - Decoding (legacy file-URL form, compat) + + @Test + func testDecodeLegacyFileURLForm() throws { + let json = """ + {"containerPath":"file:///var/run/docker.sock","hostPath":"file:///Users/me/docker.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/Users/me/docker.sock")) + #expect(socket.permissions == nil) + } + + @Test + func testDecodeLegacyFileURLResolvesPercentEncoding() throws { + // Persisted bundles created via `URL(fileURLWithPath:)` percent-encode + // spaces; decoding must yield the original literal path. + let json = """ + {"containerPath":"file:///tmp/a%20b.sock","hostPath":"file:///tmp/x%2Fy.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/tmp/a b.sock")) + // `%2F` decodes to a literal `/` inside the path component. + #expect(socket.hostPath == FilePath("/tmp/x/y.sock")) + } + + @Test + func testDecodeLegacyFileURLWithLocalhostHost() throws { + let json = """ + {"containerPath":"file://localhost/var/run/docker.sock","hostPath":"file:///host.sock"} + """.data(using: .utf8)! + let socket = try JSONDecoder().decode(PublishSocket.self, from: json) + #expect(socket.containerPath == FilePath("/var/run/docker.sock")) + #expect(socket.hostPath == FilePath("/host.sock")) + } + + // MARK: - Round-trip + + @Test + func testRoundTrip() throws { + let original = try PublishSocket( + containerPath: FilePath("/var/run/docker.sock"), + hostPath: FilePath("/tmp/socket with spaces.sock"), + permissions: FilePermissions(rawValue: 0o660) + ) + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let data = try encoder.encode(original) + let decoded = try decoder.decode(PublishSocket.self, from: data) + #expect(decoded.containerPath == original.containerPath) + #expect(decoded.hostPath == original.hostPath) + #expect(decoded.permissions == original.permissions) + } + + // MARK: - Decoding errors + + @Test + func testDecodeEmptyStringThrows() { + let json = """ + {"containerPath":"","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeFileColonOnlyThrows() { + // `"file:"` parses as a URL but yields an empty path; reject loudly + // rather than silently producing `FilePath("")`. + let json = """ + {"containerPath":"file:","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeFileSchemeNoPathThrows() { + let json = """ + {"containerPath":"file://","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeRelativePathThrows() { + // Reject non-absolute paths. `decodePath` validates absoluteness at the + // decode layer (and `init` enforces it by construction), surfacing the + // failure as a `DecodingError`. + let json = """ + {"containerPath":"relative/path.sock","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeRelativeHostPathThrows() { + // A relative `hostPath` is likewise rejected at the decode layer. + let json = """ + {"containerPath":"/var/run/docker.sock","hostPath":"relative/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeNonLocalHostFileURLThrows() { + // file URLs with a non-empty / non-localhost host are unsafe to + // interpret as a local path. + let json = """ + {"containerPath":"file://example.com/etc/passwd","hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } + + @Test + func testDecodeMissingRequiredKeyThrows() { + let json = """ + {"hostPath":"/host.sock"} + """.data(using: .utf8)! + #expect(throws: DecodingError.self) { + try JSONDecoder().decode(PublishSocket.self, from: json) + } + } +} diff --git a/Tests/ContainerResourceTests/RegistryResourceTests.swift b/Tests/ContainerResourceTests/RegistryResourceTests.swift new file mode 100644 index 0000000..718c66a --- /dev/null +++ b/Tests/ContainerResourceTests/RegistryResourceTests.swift @@ -0,0 +1,199 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource +@testable import ContainerizationOS + +struct RegistryResourceTests { + + func createRegistryInfo( + hostname: String = "docker.io", + username: String = "testuser" + ) -> RegistryInfo { + RegistryInfo( + hostname: hostname, + username: username, + modifiedDate: Date(timeIntervalSince1970: 1_700_000_000), + createdDate: Date(timeIntervalSince1970: 1_690_000_000) + ) + } + + @Test("RegistryResource id and name are both hostname") + func testRegistryResourceIdAndName() { + let hostname = "ghcr.io" + let registryInfo = createRegistryInfo(hostname: hostname, username: "myuser") + let resource = RegistryResource(from: registryInfo) + + #expect(resource.id == hostname, "id should be the hostname") + #expect(resource.name == hostname, "name should be the hostname") + #expect(resource.id == resource.name, "id and name should be identical") + } + + @Test("RegistryResource maps RegistryInfo correctly") + func testRegistryResourceMapping() { + let hostname = "registry.example.com:5000" + let username = "developer" + let registryInfo = createRegistryInfo(hostname: hostname, username: username) + + let resource = RegistryResource(from: registryInfo) + + #expect(resource.id == hostname) + #expect(resource.name == hostname) + #expect(resource.username == username) + #expect(resource.creationDate == registryInfo.createdDate) + #expect(resource.modificationDate == registryInfo.modifiedDate) + #expect(resource.labels.isEmpty, "default labels should be empty") + } + + @Test("RegistryResource implements ManagedResource") + func testManagedResourceConformance() { + let registryInfo = createRegistryInfo() + let resource = RegistryResource(from: registryInfo) + + // Test that it conforms to ManagedResource protocol + let managedResource: any ManagedResource = resource + #expect(managedResource.id == "docker.io") + #expect(managedResource.name == "docker.io") + #expect(managedResource.creationDate == registryInfo.createdDate) + #expect(managedResource.labels.isEmpty) + } + + @Test("RegistryResource is Codable - JSON encoding") + func testRegistryResourceJSONEncoding() throws { + let hostname = "docker.io" + let username = "testuser" + let registryInfo = createRegistryInfo(hostname: hostname, username: username) + let resource = RegistryResource(from: registryInfo) + + // Encode to JSON + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let jsonData = try encoder.encode(resource) + let jsonString = String(data: jsonData, encoding: .utf8)! + + // Verify JSON contains expected fields + #expect(jsonString.contains("\"id\""), "JSON should contain id field") + #expect(jsonString.contains("\"name\""), "JSON should contain name field") + #expect(jsonString.contains("\"username\""), "JSON should contain username field") + #expect(jsonString.contains("\"creationDate\""), "JSON should contain creationDate field") + #expect(jsonString.contains("\"modificationDate\""), "JSON should contain modificationDate field") + #expect(jsonString.contains(hostname), "JSON should contain the hostname") + #expect(jsonString.contains(username), "JSON should contain the username") + } + + @Test("RegistryResource is Codable - round trip") + func testRegistryResourceRoundTrip() throws { + let hostname = "ghcr.io" + let username = "developer" + let registryInfo = createRegistryInfo(hostname: hostname, username: username) + let original = RegistryResource(from: registryInfo) + + // Encode + let encoder = JSONEncoder() + let jsonData = try encoder.encode(original) + + // Decode + let decoder = JSONDecoder() + let decoded = try decoder.decode(RegistryResource.self, from: jsonData) + + // Verify + #expect(decoded.id == original.id) + #expect(decoded.name == original.name) + #expect(decoded.username == original.username) + #expect(decoded.creationDate.timeIntervalSince1970 == original.creationDate.timeIntervalSince1970) + #expect(decoded.modificationDate.timeIntervalSince1970 == original.modificationDate.timeIntervalSince1970) + #expect(decoded.labels == original.labels) + } + + @Test("RegistryResource nameValid validates hostnames") + func testRegistryResourceNameValidation() { + // Valid hostnames + #expect(RegistryResource.nameValid("docker.io"), "docker.io should be valid") + #expect(RegistryResource.nameValid("ghcr.io"), "ghcr.io should be valid") + #expect(RegistryResource.nameValid("registry.example.com"), "registry.example.com should be valid") + #expect(RegistryResource.nameValid("localhost:5000"), "localhost:5000 should be valid") + #expect(RegistryResource.nameValid("registry.k8s.io"), "registry.k8s.io should be valid") + + // Invalid hostnames + #expect(!RegistryResource.nameValid(""), "empty string should be invalid") + #expect(!RegistryResource.nameValid("-invalid.com"), "hostname starting with hyphen should be invalid") + #expect(!RegistryResource.nameValid("invalid-.com"), "hostname ending with hyphen should be invalid") + } + + @Test("RegistryResource can have labels") + func testRegistryResourceWithLabels() throws { + let hostname = "docker.io" + let username = "testuser" + let labels = [ + "environment": "production", + ResourceLabelKeys.role: "primary", + ] + + let resource = RegistryResource( + hostname: hostname, + username: username, + creationDate: Date(), + modificationDate: Date(), + labels: try .init(labels) + ) + + #expect(resource.labels.count == 2) + #expect(resource.labels["environment"] == "production") + #expect(resource.labels[ResourceLabelKeys.role] == "primary") + } + + @Test("RegistryResource handles hostname with port") + func testRegistryResourceWithPort() { + let hostname = "localhost:5000" + let registryInfo = createRegistryInfo(hostname: hostname, username: "admin") + let resource = RegistryResource(from: registryInfo) + + #expect(resource.id == hostname) + #expect(resource.name == hostname) + #expect(RegistryResource.nameValid(hostname)) + } + + @Test("Multiple RegistryResources can be encoded as array") + func testMultipleRegistryResourcesJSONEncoding() throws { + let registries = [ + RegistryResource(from: createRegistryInfo(hostname: "docker.io", username: "user1")), + RegistryResource(from: createRegistryInfo(hostname: "ghcr.io", username: "user2")), + RegistryResource(from: createRegistryInfo(hostname: "quay.io", username: "user3")), + ] + + let encoder = JSONEncoder() + encoder.outputFormatting = [.sortedKeys, .prettyPrinted] + let jsonData = try encoder.encode(registries) + let jsonString = String(data: jsonData, encoding: .utf8)! + + // Verify all hostnames are present + #expect(jsonString.contains("docker.io")) + #expect(jsonString.contains("ghcr.io")) + #expect(jsonString.contains("quay.io")) + + // Verify all usernames are present + #expect(jsonString.contains("user1")) + #expect(jsonString.contains("user2")) + #expect(jsonString.contains("user3")) + + // Print for manual verification + print("Encoded JSON:") + print(jsonString) + } +} diff --git a/Tests/ContainerResourceTests/ResourceLabelsTest.swift b/Tests/ContainerResourceTests/ResourceLabelsTest.swift new file mode 100644 index 0000000..799c9c5 --- /dev/null +++ b/Tests/ContainerResourceTests/ResourceLabelsTest.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import ContainerResource + +struct ResourceLabelsTest { + @Test func testValidationGoodLabels() throws { + let allLabels: [[String: String]] = [ + ["com.example.my-label": "bar"], + ["mycompany.com/my-label": "bar"], + ["foo": String(repeating: "0", count: 4096 - "foo".count - "=".count)], + [String(repeating: "0", count: 128): ""], + ] + for labels in allLabels { + _ = try ResourceLabels(labels) + } + } + + @Test func testValidationBadLabels() throws { + let allLabels: [[String: String]] = [ + [String(repeating: "0", count: 129): ""], + ["foo": String(repeating: "0", count: 4097 - "foo".count - "=".count)], + ["com..example.my-label": "bar"], + ["mycompany.com//my-label": "bar"], + ["": String(repeating: "0", count: 4096 - "foo".count - "=".count)], + ] + for labels in allLabels { + #expect { + _ = try ResourceLabels(labels) + } throws: { error in + error is ResourceLabels.LabelError + } + } + } +} diff --git a/Tests/ContainerResourceTests/VolumeConfigurationTests.swift b/Tests/ContainerResourceTests/VolumeConfigurationTests.swift new file mode 100644 index 0000000..cc0882d --- /dev/null +++ b/Tests/ContainerResourceTests/VolumeConfigurationTests.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +struct VolumeConfigurationTests { + + func makeConfiguration(name: String = "test-volume", creationDate: Date = Date()) -> VolumeConfiguration { + VolumeConfiguration( + name: name, + driver: "local", + format: "ext4", + source: "/volumes/\(name)/volume.img", + creationDate: creationDate, + labels: ["env": "test"], + options: ["size": "1GiB"], + sizeInBytes: 1_073_741_824 + ) + } + + @Test func testEncodesCreationDateKey() throws { + let config = makeConfiguration() + let encoder = JSONEncoder() + encoder.outputFormatting = .sortedKeys + let json = try String(data: encoder.encode(config), encoding: .utf8)! + + #expect(json.contains("\"creationDate\""), "encoded JSON should use creationDate key") + #expect(!json.contains("\"createdAt\""), "encoded JSON must not use deprecated createdAt key") + } + + @Test func testRoundTrip() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let original = makeConfiguration(creationDate: date) + + let encoder = JSONEncoder() + let decoder = JSONDecoder() + let decoded = try decoder.decode(VolumeConfiguration.self, from: encoder.encode(original)) + + #expect(decoded.name == original.name) + #expect(decoded.driver == original.driver) + #expect(decoded.format == original.format) + #expect(decoded.source == original.source) + #expect(decoded.creationDate.timeIntervalSince1970 == original.creationDate.timeIntervalSince1970) + #expect(decoded.labels == original.labels) + #expect(decoded.options == original.options) + #expect(decoded.sizeInBytes == original.sizeInBytes) + } + + @Test func testDecodesLegacyCreatedAtKey() throws { + let date = Date(timeIntervalSince1970: 1_700_000_000) + let legacyJSON = """ + { + "name": "test-volume", + "driver": "local", + "format": "ext4", + "source": "/volumes/test-volume/volume.img", + "createdAt": \(date.timeIntervalSinceReferenceDate), + "labels": {}, + "options": {} + } + """ + + let decoder = JSONDecoder() + let config = try decoder.decode(VolumeConfiguration.self, from: Data(legacyJSON.utf8)) + + #expect(config.name == "test-volume") + #expect(config.creationDate.timeIntervalSince1970 == date.timeIntervalSince1970) + } + + @Test func testMissingDateDefaultsToEpoch() throws { + let json = """ + { + "name": "test-volume", + "driver": "local", + "format": "ext4", + "source": "/volumes/test-volume/volume.img", + "labels": {}, + "options": {} + } + """ + + let config = try JSONDecoder().decode(VolumeConfiguration.self, from: Data(json.utf8)) + #expect(config.creationDate.timeIntervalSince1970 == 0) + } +} diff --git a/Tests/ContainerResourceTests/VolumeJournalConfigTests.swift b/Tests/ContainerResourceTests/VolumeJournalConfigTests.swift new file mode 100644 index 0000000..f239ecc --- /dev/null +++ b/Tests/ContainerResourceTests/VolumeJournalConfigTests.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerAPIService + +struct VolumeJournalConfigTests { + + // MARK: - Valid mode-only inputs + + @Test("Parse ordered mode without size") + func parseOrderedModeOnly() throws { + let config = try VolumesService.parseJournalConfig("ordered") + #expect(config.defaultMode == .ordered) + #expect(config.size == nil) + } + + @Test("Parse writeback mode without size") + func parseWritebackModeOnly() throws { + let config = try VolumesService.parseJournalConfig("writeback") + #expect(config.defaultMode == .writeback) + #expect(config.size == nil) + } + + @Test("Parse journal mode without size") + func parseJournalModeOnly() throws { + let config = try VolumesService.parseJournalConfig("journal") + #expect(config.defaultMode == .journal) + #expect(config.size == nil) + } + + // MARK: - Valid mode:size inputs + + @Test("Parse ordered mode with mebibyte size") + func parseOrderedWithMebibyteSize() throws { + let config = try VolumesService.parseJournalConfig("ordered:128m") + #expect(config.defaultMode == .ordered) + #expect(config.size == 128 * 1024 * 1024) + } + + @Test("Parse writeback mode with gibibyte size") + func parseWritebackWithGibibyteSize() throws { + let config = try VolumesService.parseJournalConfig("writeback:1g") + #expect(config.defaultMode == .writeback) + #expect(config.size == 1024 * 1024 * 1024) + } + + @Test("Parse journal mode with kibibyte size") + func parseJournalWithKibibyteSize() throws { + let config = try VolumesService.parseJournalConfig("journal:64m") + #expect(config.defaultMode == .journal) + #expect(config.size == 64 * 1024 * 1024) + } + + // MARK: - Invalid mode + + @Test("Invalid mode 'none' throws") + func parseNoneModeThrows() { + #expect(throws: (any Error).self) { + _ = try VolumesService.parseJournalConfig("none") + } + } + + @Test("Unrecognised mode throws") + func parseUnrecognisedModeThrows() { + #expect(throws: (any Error).self) { + _ = try VolumesService.parseJournalConfig("badmode") + } + } + + @Test("Empty string throws") + func parseEmptyStringThrows() { + #expect(throws: (any Error).self) { + _ = try VolumesService.parseJournalConfig("") + } + } + + // MARK: - Invalid size + + @Test("Non-numeric size throws") + func parseInvalidSizeThrows() { + #expect(throws: (any Error).self) { + _ = try VolumesService.parseJournalConfig("ordered:abc") + } + } + + @Test("Unknown size unit throws") + func parseUnknownSizeUnitThrows() { + #expect(throws: (any Error).self) { + _ = try VolumesService.parseJournalConfig("ordered:128x") + } + } +} diff --git a/Tests/ContainerResourceTests/VolumeValidationTests.swift b/Tests/ContainerResourceTests/VolumeValidationTests.swift new file mode 100644 index 0000000..0e86411 --- /dev/null +++ b/Tests/ContainerResourceTests/VolumeValidationTests.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@testable import ContainerResource + +struct VolumeValidationTests { + + @Test("Valid volume names should pass validation") + func testValidVolumeNames() { + let validNames = [ + "a", // Single alphanumeric + "1", // Single numeric + "volume1", // Alphanumeric + "my-volume", // With hyphen + "my_volume", // With underscore + "my.volume", // With period + "volume-1.2_test", // Mixed valid characters + "1volume", // Starting with number + "Avolume", // Starting with uppercase + "a" + String(repeating: "x", count: 254), // Max length (255) + ] + + for name in validNames { + #expect(VolumeStorage.isValidVolumeName(name), "'\(name)' should be valid") + } + } + + @Test("Invalid volume names should fail validation") + func testInvalidVolumeNames() { + let invalidNames = [ + "", // Empty string + ".volume", // Starting with period + "_volume", // Starting with underscore + "-volume", // Starting with hyphen + "volume@", // Contains invalid character (@) + "volume space", // Contains space + "volume/path", // Contains slash + "volume:tag", // Contains colon + "volume#hash", // Contains hash + "volume$", // Contains dollar sign + "volume!", // Contains exclamation + "volume%", // Contains percent + "volume*", // Contains asterisk + "volume+", // Contains plus + "volume=", // Contains equals + "volume[", // Contains bracket + "volume]", // Contains bracket + "volume{", // Contains brace + "volume}", // Contains brace + "volume|", // Contains pipe + "volume\\", // Contains backslash + "volume\"", // Contains quote + "volume'", // Contains single quote + "volume<", // Contains less than + "volume>", // Contains greater than + "volume?", // Contains question mark + "volume,", // Contains comma + "volume;", // Contains semicolon + "a" + String(repeating: "x", count: 255), // Too long (256 chars) + ] + + for name in invalidNames { + #expect(!VolumeStorage.isValidVolumeName(name), "'\(name)' should be invalid") + } + } + + @Test("Edge cases for volume name validation") + func testVolumeNameEdgeCases() { + // Test exact boundary conditions + #expect(VolumeStorage.isValidVolumeName("a"), "Single character should be valid") + #expect(!VolumeStorage.isValidVolumeName(""), "Empty string should be invalid") + + // Test maximum length boundary + let maxLengthName = String(repeating: "a", count: 255) + let tooLongName = String(repeating: "a", count: 256) + #expect(VolumeStorage.isValidVolumeName(maxLengthName), "255 character name should be valid") + #expect(!VolumeStorage.isValidVolumeName(tooLongName), "256 character name should be invalid") + + // Test other edge cases + #expect(VolumeStorage.isValidVolumeName("0volume"), "Name starting with digit should be valid") + #expect(VolumeStorage.isValidVolumeName("Volume"), "Name starting with uppercase should be valid") + #expect(!VolumeStorage.isValidVolumeName(".hidden"), "Name starting with period should be invalid") + #expect(!VolumeStorage.isValidVolumeName("_private"), "Name starting with underscore should be invalid") + #expect(!VolumeStorage.isValidVolumeName("-dash"), "Name starting with hyphen should be invalid") + } + + @Test("Unicode and special character handling") + func testUnicodeCharacters() { + let unicodeNames = [ + "volume-ñ", // Non-ASCII letter + "volume-中文", // Chinese characters + "volume-🍎", // Emoji + "volume-café", // Accented characters + "αβγ", // Greek letters + ] + + for name in unicodeNames { + #expect(!VolumeStorage.isValidVolumeName(name), "Unicode name '\(name)' should be invalid") + } + } + + @Test("Common Container volume name patterns") + func testCommonVolumeNames() { + let commonPatterns = [ + "myapp-data", // Common app data volume + "postgres_data", // Database volume + "nginx.conf", // Config volume + "logs-2024", // Log volume with year + "cache_redis_v1.2", // Version-tagged cache + "backup.daily", // Backup volume + "shared-storage", // Shared volume + ] + + for name in commonPatterns { + #expect(VolumeStorage.isValidVolumeName(name), "Common volume name pattern '\(name)' should be valid") + } + } +} diff --git a/Tests/ContainerVersionTests/Bundle+AppBundleTests.swift b/Tests/ContainerVersionTests/Bundle+AppBundleTests.swift new file mode 100644 index 0000000..2637898 --- /dev/null +++ b/Tests/ContainerVersionTests/Bundle+AppBundleTests.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import SystemPackage +import Testing + +@testable import ContainerVersion + +struct BundleAppBundleTests { + @Test func returnsNilForUnixInstallPath() { + // /usr/local/bin/container — no .app bundle in the hierarchy + let path = FilePath("/usr/local/bin/container") + #expect(Bundle.appBundle(executablePath: path) == nil) + } + + @Test func returnsBundleForAppBundleExecutable() throws { + // Build a minimal Foo.app bundle on disk — Bundle(url:) requires the directory to exist. + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("BundleTest-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + let bundleURL = tmp.appendingPathComponent("Foo.app", isDirectory: true) + let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true) + let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true) + try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true) + try Data("".utf8) + .write(to: contentsURL.appendingPathComponent("Info.plist")) + + let executablePath = FilePath(macOSURL.path(percentEncoded: false)) + .appending(FilePath.Component("Foo")) + let bundle = Bundle.appBundle(executablePath: executablePath) + #expect(bundle != nil) + #expect(bundle?.bundleURL.lastPathComponent == "Foo.app") + } + + @Test func returnsBundleForSymlinkedExecutable() throws { + let tmp = FileManager.default.temporaryDirectory + .appendingPathComponent("BundleTest-\(UUID().uuidString)", isDirectory: true) + defer { try? FileManager.default.removeItem(at: tmp) } + + let bundleURL = tmp.appendingPathComponent("Foo.app", isDirectory: true) + let contentsURL = bundleURL.appendingPathComponent("Contents", isDirectory: true) + let macOSURL = contentsURL.appendingPathComponent("MacOS", isDirectory: true) + try FileManager.default.createDirectory(at: macOSURL, withIntermediateDirectories: true) + try Data("".utf8) + .write(to: contentsURL.appendingPathComponent("Info.plist")) + let executableURL = macOSURL.appendingPathComponent("Foo") + try Data().write(to: executableURL) + + // Symlink outside the bundle pointing at the real executable + let symlinkURL = tmp.appendingPathComponent("foo-link") + try FileManager.default.createSymbolicLink(at: symlinkURL, withDestinationURL: executableURL) + + let bundle = Bundle.appBundle(executablePath: FilePath(symlinkURL.path(percentEncoded: false))) + #expect(bundle != nil) + #expect(bundle?.bundleURL.lastPathComponent == "Foo.app") + } + + @Test func returnsNilWhenTooShallow() { + // Only one component above executable — can't be a bundle + let path = FilePath("/Foo.app/binary") + #expect(Bundle.appBundle(executablePath: path) == nil) + } + + @Test func returnsNilWhenThirdAncestorLacksAppExtension() { + // Parent hierarchy exists but doesn't end in .app + let path = FilePath("/opt/tools/bin/helper") + #expect(Bundle.appBundle(executablePath: path) == nil) + } +} diff --git a/Tests/ContainerVersionTests/CommandLine+ExecutableTests.swift b/Tests/ContainerVersionTests/CommandLine+ExecutableTests.swift new file mode 100644 index 0000000..cdda4fe --- /dev/null +++ b/Tests/ContainerVersionTests/CommandLine+ExecutableTests.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// 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 SystemPackage +import Testing + +@testable import ContainerVersion + +struct CommandLineExecutableTests { + @Test func lastComponentIsTestBinary() { + #expect(CommandLine.executablePath.lastComponent?.string == "swiftpm-testing-helper") + } + + @Test func pathIsAbsolute() { + #expect(CommandLine.executablePath.isAbsolute) + } + + @Test func pathIsNonEmpty() { + #expect(!CommandLine.executablePath.string.isEmpty) + } + + @Test func removingLastComponentTwiceIsAbsolute() { + #expect(CommandLine.executablePath.removingLastComponent().removingLastComponent().isAbsolute) + } +} diff --git a/Tests/DNSServerTests/CompositeResolverTest.swift b/Tests/DNSServerTests/CompositeResolverTest.swift new file mode 100644 index 0000000..df9a4fe --- /dev/null +++ b/Tests/DNSServerTests/CompositeResolverTest.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Testing + +@testable import DNSServer + +struct CompositeResolverTest { + @Test func testCompositeResolver() async throws { + let foo = FooHandler() + let bar = BarHandler() + let resolver = CompositeResolver(handlers: [foo, bar]) + + let fooQuery = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "foo.", type: .host) + ]) + + let fooResponse = try await resolver.answer(query: fooQuery) + #expect(.noError == fooResponse?.returnCode) + #expect(1 == fooResponse?.id) + #expect(1 == fooResponse?.answers.count) + let fooAnswer = fooResponse?.answers[0] as? HostRecord + #expect(try IPv4Address("1.2.3.4") == fooAnswer?.ip) + + let barQuery = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "bar.", type: .host) + ]) + + let barResponse = try await resolver.answer(query: barQuery) + #expect(.noError == barResponse?.returnCode) + #expect(1 == barResponse?.id) + #expect(1 == barResponse?.answers.count) + let barAnswer = barResponse?.answers[0] as? HostRecord + #expect(try IPv4Address("5.6.7.8") == barAnswer?.ip) + + let otherQuery = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "other.", type: .host) + ]) + + let otherResponse = try await resolver.answer(query: otherQuery) + #expect(nil == otherResponse) + } +} diff --git a/Tests/DNSServerTests/HostTableResolverTest.swift b/Tests/DNSServerTests/HostTableResolverTest.swift new file mode 100644 index 0000000..96f980d --- /dev/null +++ b/Tests/DNSServerTests/HostTableResolverTest.swift @@ -0,0 +1,180 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Testing + +@testable import DNSServer + +struct HostTableResolverTest { + @Test func testEmptyQuestionsReturnsNil() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["foo.": ip]) + + let query = Message(id: UInt16(1), type: .query, questions: []) + + let response = try await handler.answer(query: query) + + #expect(nil == response) + } + + @Test func testUnsupportedQuestionType() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["foo.": ip]) + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "foo.", type: .mailExchange) + ]) + + let response = try await handler.answer(query: query) + + #expect(.notImplemented == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect(0 == response?.answers.count) + } + + @Test func testAAAAQueryReturnsNoDataWhenARecordExists() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["foo.": ip]) + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "foo.", type: .host6) + ]) + + let response = try await handler.answer(query: query) + + // AAAA queries should return NODATA (noError with empty answers) when A record exists + // to avoid musl libc issues where NXDOMAIN causes complete DNS resolution failure + #expect(.noError == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect(0 == response?.answers.count) + } + + @Test func testAAAAQueryReturnsNilWhenHostDoesNotExist() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["foo.": ip]) + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "bar.", type: .host6) + ]) + + let response = try await handler.answer(query: query) + + // AAAA queries for non-existent hosts should return nil (which becomes NXDOMAIN) + #expect(nil == response) + } + + @Test func testHostNotPresent() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["foo.": ip]) + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "bar.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(nil == response) + } + + @Test func testHostPresent() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["foo.": ip]) + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "foo.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(.noError == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect("foo." == response?.questions[0].name) + #expect(.host == response?.questions[0].type) + #expect(1 == response?.answers.count) + let answer = response?.answers[0] as? HostRecord + #expect(try IPv4Address("1.2.3.4") == answer?.ip) + } + + @Test func testHostPresentUppercaseTable() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["FOO.": ip]) + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "foo.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(.noError == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect("foo." == response?.questions[0].name) + #expect(.host == response?.questions[0].type) + #expect(1 == response?.answers.count) + let answer = response?.answers[0] as? HostRecord + #expect(try IPv4Address("1.2.3.4") == answer?.ip) + } + + @Test func testHostPresentUppercaseQuestion() async throws { + let ip = try IPv4Address("1.2.3.4") + let handler = try HostTableResolver(hosts4: ["foo.": ip]) + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "FOO.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(.noError == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect("FOO." == response?.questions[0].name) + #expect(.host == response?.questions[0].type) + #expect(1 == response?.answers.count) + let answer = response?.answers[0] as? HostRecord + #expect(try IPv4Address("1.2.3.4") == answer?.ip) + } +} diff --git a/Tests/DNSServerTests/MockHandlers.swift b/Tests/DNSServerTests/MockHandlers.swift new file mode 100644 index 0000000..b0a4740 --- /dev/null +++ b/Tests/DNSServerTests/MockHandlers.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Testing + +@testable import DNSServer + +struct FooHandler: DNSHandler { + public func answer(query: Message) async throws -> Message? { + if query.questions[0].name == "foo." { + let ip = try IPv4Address("1.2.3.4") + return Message( + id: query.id, + type: .response, + returnCode: .noError, + questions: query.questions, + answers: [HostRecord(name: query.questions[0].name, ttl: 0, ip: ip)] + ) + } + return nil + } +} + +struct BarHandler: DNSHandler { + public func answer(query: Message) async throws -> Message? { + let question = query.questions[0] + if question.name == "foo." || question.name == "bar." { + let ip = try IPv4Address("5.6.7.8") + return Message( + id: query.id, + type: .response, + returnCode: .noError, + questions: query.questions, + answers: [HostRecord(name: query.questions[0].name, ttl: 0, ip: ip)] + ) + } + return nil + } +} diff --git a/Tests/DNSServerTests/NxDomainResolverTest.swift b/Tests/DNSServerTests/NxDomainResolverTest.swift new file mode 100644 index 0000000..27264c1 --- /dev/null +++ b/Tests/DNSServerTests/NxDomainResolverTest.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@testable import DNSServer + +struct NxDomainResolverTest { + @Test func testUnsupportedQuestionType() async throws { + let handler: NxDomainResolver = NxDomainResolver() + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "foo.", type: .host6) + ]) + + let response = try await handler.answer(query: query) + + #expect(.notImplemented == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect(0 == response?.answers.count) + } + + @Test func testHostNotPresent() async throws { + let handler: NxDomainResolver = NxDomainResolver() + + let query = Message( + id: UInt16(1), + type: .query, + questions: [ + Question(name: "bar.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(.nonExistentDomain == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect(0 == response?.answers.count) + } +} diff --git a/Tests/DNSServerTests/RecordsTests.swift b/Tests/DNSServerTests/RecordsTests.swift new file mode 100644 index 0000000..d1fc0cc --- /dev/null +++ b/Tests/DNSServerTests/RecordsTests.swift @@ -0,0 +1,690 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Foundation +import Testing + +@testable import DNSServer + +@Suite("DNS Records Tests") +struct RecordsTests { + + // MARK: - DNSName Tests + + @Suite("DNSName") + struct DNSNameTests { + @Test("Create from string") + func createFromString() throws { + let name = try DNSName("example.com") + #expect(name.labels == ["example", "com"]) + } + + @Test("Create from string with trailing dot") + func createFromStringTrailingDot() throws { + let name = try DNSName("example.com.") + #expect(name.labels == ["example", "com"]) + } + + @Test("Description includes trailing dot") + func descriptionTrailingDot() throws { + let name = try DNSName("example.com") + #expect(name.description == "example.com.") + } + + @Test("DNS name with newline should throw") + func DNSNameWithNewLine() throws { + #expect(throws: DNSBindError.self) { + _ = try DNSName("foo.com\npwned") + } + } + + @Test("DNS name with newline should throw") + func DNSNameWithPathTraversal() throws { + #expect(throws: DNSBindError.self) { + _ = try DNSName("../pwned") + } + #expect(throws: DNSBindError.self) { + _ = try DNSName("myhost./../../pwned") + } + } + + @Test("Root domain") + func rootDomain() throws { + let name = try DNSName("") + #expect(name.labels == []) + #expect(name.description == ".") + } + + @Test("Size calculation") + func sizeCalculation() throws { + let name = try DNSName("example.com") + // [7]example[3]com[0] = 1 + 7 + 1 + 3 + 1 = 13 + #expect(name.size == 13) + } + + @Test("Serialize and deserialize") + func serializeDeserialize() throws { + let original = try DNSName("test.example.com") + var buffer = [UInt8](repeating: 0, count: 64) + + let endOffset = try original.appendBuffer(&buffer, offset: 0) + + var parsed = DNSName() + let readOffset = try parsed.bindBuffer(&buffer, offset: 0) + + // [4]test[7]example[3]com[0] = 5+8+4+1 = 18 + #expect(endOffset == 18) + #expect(readOffset == endOffset) + #expect(parsed.labels == original.labels) + } + + @Test("Serialize subdomain") + func serializeSubdomain() throws { + let name = try DNSName("a.b.c.d.example.com") + var buffer = [UInt8](repeating: 0, count: 64) + + let endOffset = try name.appendBuffer(&buffer, offset: 0) + + var parsed = DNSName() + let readOffset = try parsed.bindBuffer(&buffer, offset: 0) + + // [1]a[1]b[1]c[1]d[7]example[3]com[0] = 2+2+2+2+8+4+1 = 21 + #expect(endOffset == 21) + #expect(readOffset == endOffset) + #expect(parsed.labels == ["a", "b", "c", "d", "example", "com"]) + } + + @Test("Reject label too long") + func rejectLabelTooLong() { + let longLabel = String(repeating: "a", count: 64) + #expect(throws: DNSBindError.self) { + _ = try DNSName(longLabel + ".com") + } + } + + @Test("Reject embedded carriage return") + func rejectEmbeddedCarriageReturn() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("foo\r.com") + } + } + + @Test("Reject embedded newline") + func rejectEmbeddedNewline() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("foo\n.com") + } + } + + @Test("Reject embedded null byte") + func rejectEmbeddedNullByte() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("foo\0.com") + } + } + + @Test("Reject empty label") + func rejectEmptyLabel() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("foo..com") + } + } + + @Test("Reject name too long") + func rejectNameTooLong() { + // 9 labels * (1 + 30) bytes + 1 null = 280 bytes > 255 + let label = String(repeating: "a", count: 30) + let name = Array(repeating: label, count: 9).joined(separator: ".") + #expect(throws: DNSBindError.self) { + _ = try DNSName(name) + } + } + + @Test("Reject leading hyphen") + func rejectLeadingHyphen() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("-foo.com") + } + } + + @Test("Reject trailing hyphen") + func rejectTrailingHyphen() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("foo-.com") + } + } + + @Test("Reject leading underscore") + func rejectLeadingUnderscore() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("_foo.com") + } + } + + @Test("Reject trailing underscore") + func rejectTrailingUnderscore() { + #expect(throws: DNSBindError.self) { + _ = try DNSName("foo_.com") + } + } + + @Test("Accept service labels via init(labels:)") + func acceptServiceLabels() throws { + let name = try DNSName(labels: ["_dns-sd", "_udp", "local"]) + #expect(name.labels == ["_dns-sd", "_udp", "local"]) + } + + @Test("Lowercase labels on init") + func lowercaseLabelsOnInit() throws { + let name = try DNSName("EXAMPLE.COM") + #expect(name.labels == ["example", "com"]) + } + + @Test("Lowercase labels on init with trailing dot") + func lowercaseLabelsOnInitTrailingDot() throws { + let name = try DNSName("Example.Com.") + #expect(name.labels == ["example", "com"]) + } + + @Test("Lowercase labels from wire format") + func lowercaseLabelsFromWire() throws { + // Wire-encode "EXAMPLE.COM" with uppercase bytes, then decode + let upper = try DNSName(labels: ["EXAMPLE", "COM"]) + var buffer = [UInt8](repeating: 0, count: 64) + let endOffset = try upper.appendBuffer(&buffer, offset: 0) + + var parsed = DNSName() + let readOffset = try parsed.bindBuffer(&buffer, offset: 0) + + // [7]example[3]com[0] = 8+4+1 = 13 + #expect(endOffset == 13) + #expect(readOffset == endOffset) + #expect(parsed.labels == ["example", "com"]) + } + + @Test("Follow valid compression pointer") + func followCompressionPointer() throws { + // Build a buffer with two names: + // offset 0: "example.com." — [7]example[3]com[0] (13 bytes) + // offset 13: "test." — [4]test 0xC0 0x00 ( 7 bytes) + // The pointer 0xC0 0x00 points back to offset 0. + var buffer: [UInt8] = [ + 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, // [7]example + 0x03, 0x63, 0x6f, 0x6d, // [3]com + 0x00, // null terminator + 0x04, 0x74, 0x65, 0x73, 0x74, // [4]test + 0xC0, 0x00, // pointer to offset 0 + ] + + var name = DNSName() + let readOffset = try name.bindBuffer(&buffer, offset: 13) + + // Pointer bytes are at offset 18–19; returnOffset = 18 + 2 = 20 + #expect(readOffset == 20) + #expect(name.labels == ["test", "example", "com"]) + } + + @Test("Reject forward compression pointer") + func rejectForwardCompressionPointer() throws { + // Craft a packet with a forward compression pointer at offset 12 pointing to offset 20 + // Header (12 bytes) + pointer bytes + var buffer = [UInt8](repeating: 0, count: 32) + // At offset 0: compression pointer to offset 20 (forward) + buffer[0] = 0xC0 + buffer[1] = 0x14 // points to offset 20, which is > 0 + + #expect(throws: DNSBindError.self) { + var b = buffer + var name = DNSName() + _ = try name.bindBuffer(&b, offset: 0) + } + } + + @Test("Reject self-referential compression pointer") + func rejectSelfReferentialCompressionPointer() throws { + var buffer = [UInt8](repeating: 0, count: 16) + // At offset 0: compression pointer pointing back to offset 0 (same location) + buffer[0] = 0xC0 + buffer[1] = 0x00 // points to offset 0 == current offset, not prior + + #expect(throws: DNSBindError.self) { + var b = buffer + var name = DNSName() + _ = try name.bindBuffer(&b, offset: 0) + } + } + + @Test("Reject compression pointer hop limit exceeded") + func rejectCompressionPointerHopLimit() throws { + // Build a chain of backward pointers: + // offset 0: [1]a[0] — terminal name (3 bytes) + // offset 3: 0xC0 0x00 — pointer → 0 + // offset 5: 0xC0 0x03 — pointer → 3 + // ...each entry points to the one before it... + // offset 23: 0xC0 0x15 — pointer → 21 + // offset 25: 0xC0 0x17 — pointer → 23 + // + // Reading from offset 25 follows 11 hops (25→23→21→...→3→0), + // which exceeds the limit of 10. + var buffer: [UInt8] = [ + 0x01, 0x61, 0x00, // offset 0: [1]a[0] + 0xC0, 0x00, // offset 3: → 0 + 0xC0, 0x03, // offset 5: → 3 + 0xC0, 0x05, // offset 7: → 5 + 0xC0, 0x07, // offset 9: → 7 + 0xC0, 0x09, // offset 11: → 9 + 0xC0, 0x0B, // offset 13: → 11 + 0xC0, 0x0D, // offset 15: → 13 + 0xC0, 0x0F, // offset 17: → 15 + 0xC0, 0x11, // offset 19: → 17 + 0xC0, 0x13, // offset 21: → 19 + 0xC0, 0x15, // offset 23: → 21 + 0xC0, 0x17, // offset 25: → 23 + ] + + #expect(throws: DNSBindError.self) { + var name = DNSName() + _ = try name.bindBuffer(&buffer, offset: 25) + } + } + } + + // MARK: - Question Tests + + @Suite("Question") + struct QuestionTests { + @Test("Create question") + func create() { + let q = Question(name: "example.com.", type: .host, recordClass: .internet) + #expect(q.name == "example.com.") + #expect(q.type == .host) + #expect(q.recordClass == .internet) + } + + @Test("Serialize and deserialize A record question") + func serializeDeserializeA() throws { + let original = Question(name: "example.com.", type: .host, recordClass: .internet) + var buffer = [UInt8](repeating: 0, count: 64) + + let endOffset = try original.appendBuffer(&buffer, offset: 0) + + var parsed = Question(name: "") + let readOffset = try parsed.bindBuffer(&buffer, offset: 0) + + // name([7]example[3]com[0]=13) + type(2) + class(2) = 17 + #expect(endOffset == 17) + #expect(readOffset == endOffset) + #expect(parsed.type == .host) + #expect(parsed.recordClass == .internet) + } + + @Test("Serialize and deserialize AAAA record question") + func serializeDeserializeAAAA() throws { + let original = Question(name: "example.com.", type: .host6, recordClass: .internet) + var buffer = [UInt8](repeating: 0, count: 64) + + let endOffset = try original.appendBuffer(&buffer, offset: 0) + + var parsed = Question(name: "") + let readOffset = try parsed.bindBuffer(&buffer, offset: 0) + + // name([7]example[3]com[0]=13) + type(2) + class(2) = 17 + #expect(endOffset == 17) + #expect(readOffset == endOffset) + #expect(parsed.type == .host6) + } + } + + // MARK: - HostRecord Tests + + @Suite("HostRecord") + struct HostRecordTests { + @Test("Create A record") + func createARecord() throws { + let ip = try IPv4Address("192.168.1.1") + let record = HostRecord(name: "example.com.", ttl: 300, ip: ip) + + #expect(record.name == "example.com.") + #expect(record.type == .host) + #expect(record.ttl == 300) + #expect(record.ip == ip) + } + + @Test("Create AAAA record") + func createAAAARecord() throws { + let ip = try IPv6Address("::1") + let record = HostRecord(name: "example.com.", ttl: 600, ip: ip) + + #expect(record.name == "example.com.") + #expect(record.type == .host6) + #expect(record.ttl == 600) + } + + @Test("Serialize A record") + func serializeARecord() throws { + let ip = try IPv4Address("10.0.0.1") + let record = HostRecord(name: "test.com.", ttl: 300, ip: ip) + var buffer = [UInt8](repeating: 0, count: 64) + + let endOffset = try record.appendBuffer(&buffer, offset: 0) + + // name([4]test[3]com[0]=10) + type(2) + class(2) + ttl(4) + rdlen(2) + rdata(4) = 24 + #expect(endOffset == 24) + + // Verify IP bytes at the end + #expect(buffer[endOffset - 4] == 10) + #expect(buffer[endOffset - 3] == 0) + #expect(buffer[endOffset - 2] == 0) + #expect(buffer[endOffset - 1] == 1) + } + + @Test("Serialize AAAA record") + func serializeAAAARecord() throws { + let ip = try IPv6Address("::1") + let record = HostRecord(name: "test.com.", ttl: 300, ip: ip) + var buffer = [UInt8](repeating: 0, count: 64) + + let endOffset = try record.appendBuffer(&buffer, offset: 0) + + // name([4]test[3]com[0]=10) + type(2) + class(2) + ttl(4) + rdlen(2) + rdata(16) = 36 + #expect(endOffset == 36) + #expect(buffer[endOffset - 1] == 1) + } + } + + // MARK: - Message Tests + + @Suite("Message") + struct MessageTests { + @Test("Create query message") + func createQuery() { + let msg = Message( + id: 0x1234, + type: .query, + questions: [Question(name: "example.com.", type: .host)] + ) + + #expect(msg.id == 0x1234) + #expect(msg.type == .query) + #expect(msg.questions.count == 1) + } + + @Test("Create response message") + func createResponse() throws { + let ip = try IPv4Address("192.168.1.1") + let msg = Message( + id: 0x1234, + type: .response, + returnCode: .noError, + questions: [Question(name: "example.com.", type: .host)], + answers: [HostRecord(name: "example.com.", ttl: 300, ip: ip)] + ) + + #expect(msg.type == .response) + #expect(msg.returnCode == .noError) + #expect(msg.answers.count == 1) + } + + @Test("Serialize and deserialize query") + func serializeDeserializeQuery() throws { + let original = Message( + id: 0xABCD, + type: .query, + recursionDesired: true, + questions: [Question(name: "example.com.", type: .host)] + ) + + let data = try original.serialize() + let parsed = try Message(deserialize: data) + + #expect(parsed.id == 0xABCD) + #expect(parsed.type == .query) + #expect(parsed.recursionDesired == true) + #expect(parsed.questions.count == 1) + #expect(parsed.questions[0].type == .host) + } + + @Test("Serialize response with answer") + func serializeResponse() throws { + let ip = try IPv4Address("10.0.0.1") + let msg = Message( + id: 0x1234, + type: .response, + authoritativeAnswer: true, + returnCode: .noError, + questions: [Question(name: "test.com.", type: .host)], + answers: [HostRecord(name: "test.com.", ttl: 300, ip: ip)] + ) + + let data = try msg.serialize() + + // Verify we can at least parse the header back + let parsed = try Message(deserialize: data) + #expect(parsed.id == 0x1234) + #expect(parsed.type == .response) + #expect(parsed.authoritativeAnswer == true) + #expect(parsed.returnCode == .noError) + } + + @Test("Serialize NXDOMAIN response") + func serializeNxdomain() throws { + let msg = Message( + id: 0x1234, + type: .response, + returnCode: .nonExistentDomain, + questions: [Question(name: "unknown.com.", type: .host)], + answers: [] + ) + + let data = try msg.serialize() + let parsed = try Message(deserialize: data) + + #expect(parsed.returnCode == .nonExistentDomain) + #expect(parsed.answers.count == 0) + } + + @Test("Serialize NODATA response (empty answers with noError)") + func serializeNodata() throws { + let msg = Message( + id: 0x1234, + type: .response, + returnCode: .noError, + questions: [Question(name: "example.com.", type: .host6)], + answers: [] + ) + + let data = try msg.serialize() + let parsed = try Message(deserialize: data) + + #expect(parsed.returnCode == .noError) + #expect(parsed.answers.count == 0) + } + + @Test("Multiple questions") + func multipleQuestions() throws { + let msg = Message( + id: 0x1234, + type: .query, + questions: [ + Question(name: "a.com.", type: .host), + Question(name: "b.com.", type: .host6), + ] + ) + + let data = try msg.serialize() + let parsed = try Message(deserialize: data) + + #expect(parsed.questions.count == 2) + #expect(parsed.questions[0].type == .host) + #expect(parsed.questions[1].type == .host6) + } + + @Test("Reject too many questions") + func rejectTooManyQuestions() { + let questions = Array(repeating: Question(name: "a.com.", type: .host), count: Int(UInt16.max) + 1) + let msg = Message(id: 0, type: .query, questions: questions) + #expect(throws: DNSBindError.self) { + _ = try msg.serialize() + } + } + + @Test("Reject too many answers") + func rejectTooManyAnswers() throws { + let ip = try IPv4Address("1.2.3.4") + let answers = Array(repeating: HostRecord(name: "a.com.", ttl: 0, ip: ip), count: Int(UInt16.max) + 1) + let msg = Message(id: 0, type: .response, answers: answers) + #expect(throws: DNSBindError.self) { + _ = try msg.serialize() + } + } + } + + // MARK: - Wire Format Tests + + @Suite("Wire Format") + struct WireFormatTests { + @Test("Parse real DNS query bytes") + func parseRealQuery() throws { + // A minimal DNS query for "example.com" A record + // Header: ID=0x1234, QR=0, OPCODE=0, RD=1, QDCOUNT=1 + let queryBytes: [UInt8] = [ + 0x12, 0x34, // ID + 0x01, 0x00, // Flags: RD=1 + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + // Question: example.com A IN + 0x07, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, // "example" + 0x03, 0x63, 0x6f, 0x6d, // "com" + 0x00, // null terminator + 0x00, 0x01, // QTYPE=A + 0x00, 0x01, // QCLASS=IN + ] + + let msg = try Message(deserialize: Data(queryBytes)) + + #expect(msg.id == 0x1234) + #expect(msg.type == .query) + #expect(msg.recursionDesired == true) + #expect(msg.questions.count == 1) + #expect(msg.questions[0].type == .host) + #expect(msg.questions[0].recordClass == .internet) + } + + @Test("Roundtrip preserves data") + func roundtrip() throws { + let ip = try IPv4Address("1.2.3.4") + let original = Message( + id: 0xBEEF, + type: .response, + operationCode: .query, + authoritativeAnswer: true, + truncation: false, + recursionDesired: true, + recursionAvailable: true, + returnCode: .noError, + questions: [Question(name: "test.example.com.", type: .host)], + answers: [HostRecord(name: "test.example.com.", ttl: 3600, ip: ip)] + ) + + let data = try original.serialize() + let parsed = try Message(deserialize: data) + + #expect(parsed.id == original.id) + #expect(parsed.type == original.type) + #expect(parsed.authoritativeAnswer == original.authoritativeAnswer) + #expect(parsed.truncation == original.truncation) + #expect(parsed.recursionDesired == original.recursionDesired) + #expect(parsed.recursionAvailable == original.recursionAvailable) + #expect(parsed.returnCode == original.returnCode) + #expect(parsed.questions.count == original.questions.count) + } + + @Test("Reject unknown opcode") + func rejectUnknownOpcode() { + // Opcode occupies bits 14–11 of the flags word. Value 3 is reserved. + // Flags: 0x18 0x00 = QR=0, OPCODE=3, all other bits clear. + let bytes: [UInt8] = [ + 0x00, 0x01, // ID + 0x18, 0x00, // Flags: OPCODE=3 (reserved) + 0x00, 0x00, // QDCOUNT=0 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + ] + #expect(throws: DNSBindError.self) { + _ = try Message(deserialize: Data(bytes)) + } + } + + @Test("Reject unknown RCODE") + func rejectUnknownRcode() { + // RCODE occupies bits 3–0 of the flags word. Value 12 is reserved. + // Flags: 0x00 0x0C = QR=0, OPCODE=0, RCODE=12. + let bytes: [UInt8] = [ + 0x00, 0x01, // ID + 0x00, 0x0C, // Flags: RCODE=12 (reserved) + 0x00, 0x00, // QDCOUNT=0 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + ] + #expect(throws: DNSBindError.self) { + _ = try Message(deserialize: Data(bytes)) + } + } + + @Test("Reject unknown query type") + func rejectUnknownQueryType() { + // Type 54 is unassigned in the IANA DNS parameters registry. + let bytes: [UInt8] = [ + 0x00, 0x01, // ID + 0x00, 0x00, // Flags: standard query + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + 0x01, 0x61, 0x00, // name: [1]a[0] + 0x00, 0x36, // QTYPE=54 (unassigned) + 0x00, 0x01, // QCLASS=IN + ] + #expect(throws: DNSBindError.self) { + _ = try Message(deserialize: Data(bytes)) + } + } + + @Test("Reject unknown record class") + func rejectUnknownRecordClass() { + // Class 2 is unassigned in the IANA DNS parameters registry. + let bytes: [UInt8] = [ + 0x00, 0x01, // ID + 0x00, 0x00, // Flags: standard query + 0x00, 0x01, // QDCOUNT=1 + 0x00, 0x00, // ANCOUNT=0 + 0x00, 0x00, // NSCOUNT=0 + 0x00, 0x00, // ARCOUNT=0 + 0x01, 0x61, 0x00, // name: [1]a[0] + 0x00, 0x01, // QTYPE=A + 0x00, 0x02, // QCLASS=2 (unassigned) + ] + #expect(throws: DNSBindError.self) { + _ = try Message(deserialize: Data(bytes)) + } + } + } +} diff --git a/Tests/DNSServerTests/StandardQueryValidatorTest.swift b/Tests/DNSServerTests/StandardQueryValidatorTest.swift new file mode 100644 index 0000000..868344f --- /dev/null +++ b/Tests/DNSServerTests/StandardQueryValidatorTest.swift @@ -0,0 +1,130 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Testing + +@testable import DNSServer + +struct StandardQueryValidatorTest { + @Test func testRejectResponseAsQuery() async throws { + let fooHandler = FooHandler() + let handler = StandardQueryValidator(handler: fooHandler) + + let query = Message( + id: UInt16(1), + type: .response, + questions: [ + Question(name: "foo.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(.formatError == response?.returnCode) + #expect(1 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect("foo." == response?.questions[0].name) + #expect(.host == response?.questions[0].type) + #expect(0 == response?.answers.count) + } + + @Test func testRejectNonQueryOperation() async throws { + let fooHandler = FooHandler() + let handler = StandardQueryValidator(handler: fooHandler) + + let query = Message( + id: UInt16(2), + type: .query, + operationCode: .notify, + questions: [ + Question(name: "foo.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(.notImplemented == response?.returnCode) + #expect(2 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect("foo." == response?.questions[0].name) + #expect(.host == response?.questions[0].type) + #expect(0 == response?.answers.count) + } + + @Test func testRejectNoQuestions() async throws { + let fooHandler = FooHandler() + let handler = StandardQueryValidator(handler: fooHandler) + + let query = Message(id: UInt16(3), type: .query, questions: []) + + let response = try await handler.answer(query: query) + + #expect(.formatError == response?.returnCode) + #expect(3 == response?.id) + #expect(.response == response?.type) + #expect(0 == response?.answers.count) + } + + @Test func testRejectMultipleQuestions() async throws { + let fooHandler = FooHandler() + let handler = StandardQueryValidator(handler: fooHandler) + + let query = Message( + id: UInt16(2), + type: .query, + questions: [ + Question(name: "foo.", type: .host), + Question(name: "bar.", type: .host), + ]) + + let response = try await handler.answer(query: query) + + #expect(.formatError == response?.returnCode) + #expect(2 == response?.id) + #expect(.response == response?.type) + #expect(2 == response?.questions.count) + #expect("foo." == response?.questions[0].name) + #expect(.host == response?.questions[0].type) + #expect("bar." == response?.questions[1].name) + #expect(.host == response?.questions[1].type) + #expect(0 == response?.answers.count) + } + + @Test func testSuccessfulValidation() async throws { + let fooHandler = FooHandler() + let handler = StandardQueryValidator(handler: fooHandler) + + let query = Message( + id: UInt16(2), + type: .query, + questions: [ + Question(name: "foo.", type: .host) + ]) + + let response = try await handler.answer(query: query) + + #expect(.noError == response?.returnCode) + #expect(2 == response?.id) + #expect(.response == response?.type) + #expect(1 == response?.questions.count) + #expect("foo." == response?.questions[0].name) + #expect(.host == response?.questions[0].type) + #expect(1 == response?.answers.count) + let answer = response?.answers[0] as? HostRecord + #expect(try IPv4Address("1.2.3.4") == answer?.ip) + } +} diff --git a/Tests/IntegrationTests/Build/BuildFixture.swift b/Tests/IntegrationTests/Build/BuildFixture.swift new file mode 100644 index 0000000..c923c01 --- /dev/null +++ b/Tests/IntegrationTests/Build/BuildFixture.swift @@ -0,0 +1,355 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Darwin +import Foundation +import SystemPackage +import Testing + +// MARK: - Build context types + +extension ContainerFixture { + /// A file-system entry to materialize inside a build context directory. + enum FileSystemEntry { + case file( + _ path: String, + content: FileEntryContent, + permissions: FilePermissions = [.r, .w, .gr, .gw, .or, .ow], + uid: uid_t = 0, + gid: gid_t = 0 + ) + case directory( + _ path: String, + permissions: FilePermissions = [.r, .w, .x, .gr, .gw, .gx, .or, .ow, .ox], + uid: uid_t = 0, + gid: gid_t = 0 + ) + case symbolicLink(_ path: String, target: String, uid: uid_t = 0, gid: gid_t = 0) + } + + enum FileEntryContent { + case zeroFilled(size: Int64) + case data(Data) + } + + struct FilePermissions: OptionSet { + let rawValue: UInt16 + static let r = FilePermissions(rawValue: 0o400) + static let w = FilePermissions(rawValue: 0o200) + static let x = FilePermissions(rawValue: 0o100) + static let gr = FilePermissions(rawValue: 0o040) + static let gw = FilePermissions(rawValue: 0o020) + static let gx = FilePermissions(rawValue: 0o010) + static let or = FilePermissions(rawValue: 0o004) + static let ow = FilePermissions(rawValue: 0o002) + static let ox = FilePermissions(rawValue: 0o001) + } +} + +// MARK: - Builder lifecycle helpers + +extension ContainerFixture { + + /// Starts the buildkit builder container. + func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { + try run(["builder", "start", "-c", "\(cpus)", "-m", "\(memoryInGBs)GB"]).check() + } + + /// Stops the buildkit builder container. + func builderStop() throws { + try run(["builder", "stop"]).check() + } + + /// Deletes the buildkit builder container. + func builderDelete(force: Bool = false) throws { + var args = ["builder", "delete"] + if force { args.append("--force") } + try run(args).check() + } + + /// Polls until the buildkit container is running and the builder shim is ready. + func waitForBuilderRunning() async throws { + try await waitForContainerRunning("buildkit", attempts: 10) + for _ in 0..<3 { + let response = try? doExec("buildkit", cmd: ["pidof", "-s", "container-builder-shim"]) + if let r = response, !r.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + return + } + try await Task.sleep(for: .seconds(1)) + } + throw CommandError.executionFailed("timed out waiting for container-builder-shim on buildkit") + } + + /// Deletes any existing builder, starts a fresh one, runs `body`, then deletes the builder. + /// + /// Each build test gets an isolated builder to avoid inter-test contamination. + /// Acquires a process-wide lock so only one test holds the buildkit singleton at a time, + /// regardless of how many suites run concurrently in the global pass. + func withBuilder( + cpus: Int64 = 2, + memoryInGBs: Int64 = 2, + _ body: @Sendable (ContainerFixture) async throws -> Void + ) async throws { + try await withoutActuallyEscaping(body) { escapingBody in + try await Self.builderLock.withLock { _ in + _ = try? self.run(["builder", "delete", "--force"]) + try self.builderStart(cpus: cpus, memoryInGBs: memoryInGBs) + defer { _ = try? self.run(["builder", "delete", "--force"]) } + try await self.waitForBuilderRunning() + try await escapingBody(self) + } + } + } + + /// Acquires the process-wide builder lock without starting a builder. + /// + /// Use this in tests that manually manage the builder lifecycle (e.g. lifecycle + /// tests that call ``builderStart()``/``builderStop()`` directly) so they + /// serialise correctly with tests that use ``withBuilder(_:)``. + func withBuilderLock(_ body: @Sendable () async throws -> T) async throws -> T { + try await withoutActuallyEscaping(body) { escapingBody in + try await Self.builderLock.withLock { _ in + try await escapingBody() + } + } + } + + private static let builderLock = AsyncLock() +} + +// MARK: - Build context helpers + +extension ContainerFixture { + + /// Creates a new scratch directory under ``testDir`` and returns its path. + /// + /// The directory is removed automatically when the fixture scope exits. + func createTempDir() throws -> FilePath { + let dir = testDir.appending(UUID().uuidString) + try FileManager.default.createDirectory( + atPath: dir.string, withIntermediateDirectories: true, attributes: nil) + return dir + } + + /// Writes `contents` to a new file under ``testDir`` with the given suffix. + func createTempFile(suffix: String, contents: Data) throws -> FilePath { + let file = testDir.appending(UUID().uuidString + suffix) + try contents.write(to: URL(filePath: file.string), options: .atomic) + return file + } + + /// Writes a Dockerfile and optional context entries into `dir`. + /// + /// Creates `dir/Dockerfile` (if `dockerfile` is non-empty) and + /// `dir/context/` populated with `context` entries. + func createContext(dir: FilePath, dockerfile: String, context: [FileSystemEntry]? = nil) throws { + if !dockerfile.isEmpty { + try Data(dockerfile.utf8).write(to: URL(filePath: dir.appending("Dockerfile").string), options: .atomic) + } + let contextDir = dir.appending("context") + try FileManager.default.createDirectory( + atPath: contextDir.string, withIntermediateDirectories: true, attributes: nil) + for entry in context ?? [] { + try createEntry(entry, contextDir: contextDir) + } + } + + /// Materializes a ``FileSystemEntry`` inside `contextDir`. + func createEntry(_ entry: FileSystemEntry, contextDir: FilePath) throws { + switch entry { + case .file(let path, let content, let permissions, let uid, let gid): + let fullPath = appendingRelative(contextDir, path) + let parentDir = fullPath.string.components(separatedBy: "/").dropLast().joined(separator: "/") + try FileManager.default.createDirectory( + atPath: parentDir, withIntermediateDirectories: true, attributes: nil) + switch content { + case .data(let data): + try data.write(to: URL(filePath: fullPath.string), options: .atomic) + case .zeroFilled(let size): + let zeros = Data(count: Int(size)) + try zeros.write(to: URL(filePath: fullPath.string), options: .atomic) + } + // Set permissions explicitly so they match the requested mode regardless of umask. + try FileManager.default.setAttributes( + [.posixPermissions: Int(permissions.rawValue)], + ofItemAtPath: fullPath.string) + // Ownership change silently ignored when not running as root. + _ = lchown(fullPath.string, uid, gid) + + case .directory(let path, let permissions, let uid, let gid): + let fullPath = appendingRelative(contextDir, path) + try FileManager.default.createDirectory( + atPath: fullPath.string, + withIntermediateDirectories: true, + attributes: [ + .posixPermissions: Int(permissions.rawValue), + .ownerAccountID: uid, + .groupOwnerAccountID: gid, + ]) + + case .symbolicLink(let path, let target, let uid, let gid): + let fullPath = appendingRelative(contextDir, path) + let parentDir = fullPath.string.components(separatedBy: "/").dropLast().joined(separator: "/") + try FileManager.default.createDirectory( + atPath: parentDir, withIntermediateDirectories: true, attributes: nil) + let targetPath = appendingRelative(contextDir, target) + let relativeDest = relativePathFrom(targetPath, from: fullPath) + try FileManager.default.createSymbolicLink( + atPath: fullPath.string, withDestinationPath: relativeDest) + lchown(fullPath.string, uid, gid) + } + } + + /// Appends a multi-component relative path (e.g. `"a/b/c"`) to a `FilePath` base. + private func appendingRelative(_ base: FilePath, _ relative: String) -> FilePath { + relative.split(separator: "/", omittingEmptySubsequences: true) + .reduce(base) { $0.appending(String($1)) } + } + + /// Computes the relative path from `base` to `dest`. + /// + /// - FIXME: This duplicates logic in `ContainerBuild/URL+Extensions.swift`. + /// Both copies should be extracted to `ContainerizationOS/FilePathOps` + /// in the containerization package. + private func relativePathFrom(_ dest: FilePath, from base: FilePath) -> String { + let destParts = dest.string.components(separatedBy: "/").filter { !$0.isEmpty } + let baseParts = base.string.components(separatedBy: "/").filter { !$0.isEmpty } + let common = zip(destParts, baseParts).prefix { $0.0 == $0.1 }.count + guard common > 0 else { return dest.string } + let ups = Array(repeating: "..", count: baseParts.count - common) + let remainder = Array(destParts.dropFirst(common)) + return (ups + remainder).joined(separator: "/") + } +} + +// MARK: - Build invocation helpers + +extension ContainerFixture { + + /// Builds an image from `contextDir/Dockerfile` with context `contextDir/context/`. + @discardableResult + func build( + tag: String, + contextDir: FilePath = FilePath("."), + buildArgs: [String] = [], + otherArgs: [String] = [] + ) throws -> String { + try buildWithPaths(tags: [tag], contextDir: contextDir, buildArgs: buildArgs, otherArgs: otherArgs) + } + + /// Builds using a context directory and an optional explicit Dockerfile path. + /// + /// Mirrors `container build [-f dockerfilePath] [contextDir]`: + /// - `tags` defaults to `[]`; when empty the runtime auto-generates a UUID tag + /// and prints it to stdout (call `.trimmingCharacters(in: .whitespacesAndNewlines)` + /// on the return value to obtain it) + /// - `contextDir` defaults to the current directory (`.`) + /// - `dockerfilePath` defaults to `nil`, resolved to `contextDir/Dockerfile` at call time + @discardableResult + func buildWithPaths( + tags: [String] = [], + contextDir: FilePath = FilePath("."), + dockerfilePath: FilePath? = nil, + buildArgs: [String] = [], + otherArgs: [String] = [] + ) throws -> String { + let contextPath = contextDir.appending("context") + let resolvedDockerfile = dockerfilePath ?? contextDir.appending("Dockerfile") + var args = ["build", "-f", resolvedDockerfile.string] + for tag in tags { args += ["-t", tag] } + for arg in buildArgs { args += ["--build-arg", arg] } + args.append(contextPath.string) + args.append(contentsOf: otherArgs) + let result = try run(args) + guard result.status == 0 else { + throw CommandError.executionFailed( + "build failed: stdout=\(result.output) stderr=\(result.error)") + } + return result.output + } + + /// Builds with a Dockerfile read from stdin. + @discardableResult + func buildWithStdin( + tags: [String], + contextDir: FilePath, + dockerfileContents: String, + buildArgs: [String] = [], + otherArgs: [String] = [] + ) throws -> String { + let contextPath = contextDir.appending("context") + var args = ["build", "-f", "-"] + for tag in tags { args += ["-t", tag] } + for arg in buildArgs { args += ["--build-arg", arg] } + args.append(contextPath.string) + args.append(contentsOf: otherArgs) + let result = try run(args, stdin: Data(dockerfileContents.utf8)) + guard result.status == 0 else { + throw CommandError.executionFailed( + "build failed: stdout=\(result.output) stderr=\(result.error)") + } + return result.output + } + + /// Builds with `--output type=local,dest=`. + @discardableResult + func buildWithPathsAndLocalOutput( + tag: String, + contextDir: FilePath = FilePath("."), + dockerfilePath: FilePath? = nil, + outputDir: FilePath, + buildArgs: [String] = [] + ) throws -> String { + let contextPath = contextDir.appending("context") + let resolvedDockerfile = dockerfilePath ?? contextDir.appending("Dockerfile") + var args = [ + "build", + "-f", resolvedDockerfile.string, + "-t", tag, + "--output", "type=local,dest=\(outputDir.string)", + ] + for arg in buildArgs { args += ["--build-arg", arg] } + args.append(contextPath.string) + let result = try run(args) + guard result.status == 0 else { + throw CommandError.executionFailed( + "build failed: stdout=\(result.output) stderr=\(result.error)") + } + return result.output + } +} + +// MARK: - Container exec helpers + +extension ContainerFixture { + /// Returns true if `path` exists as a regular file inside `container`. + func containerHasFile(_ container: String, at path: String) throws -> Bool { + try run(["exec", container, "test", "-f", path]).status == 0 + } + + /// Asserts that `path` exists as a regular file inside `container`. + func assertContainerHasFile(_ container: String, at path: String, _ comment: String? = nil) throws { + let exists = try containerHasFile(container, at: path) + #expect(exists, "\(comment ?? path) should exist in container") + } + + /// Asserts that `path` does NOT exist inside `container`. + func assertContainerMissingFile(_ container: String, at path: String, _ comment: String? = nil) throws { + let exists = try containerHasFile(container, at: path) + #expect(!exists, "\(comment ?? path) should NOT exist in container") + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift new file mode 100644 index 0000000..fc3e1d5 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderEnvOnlySerial.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite(.serialized) +struct TestCLIBuilderEnvOnlySerial { + @Test func testBuildEnvironmentOnlyImageFromScratch() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + ARG BUILD_DATE + ARG VERSION=1.0.0 + ENV TERM=xterm \\ + BUILD_DATE=${BUILD_DATE} \\ + APP_VERSION=${VERSION} \\ + PATH=/usr/local/bin:/usr/bin:/bin + LABEL maintainer="test@example.com" version="${VERSION}" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-env-only:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir, buildArgs: ["BUILD_DATE=2025-01-01", "VERSION=2.0.0"]) + try f.assertImageBuilt(imageName) + } + } + } + + @Test func testBuildEnvironmentOnlyImageFromAlpine() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENV APP_NAME=myapp APP_VERSION=1.0.0 APP_ENV=production + LABEL maintainer="test@example.com" version="1.0.0" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-alpine-env:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir) + try f.assertImageBuilt(imageName) + } + } + } + + @Test func testMultiStageBuildWithEnvOnlyBase() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let baseDir = try f.createTempDir() + let baseDockerfile = + """ + FROM scratch + ARG JOBS=6 + ARG ARCH=amd64 + ENV MAKEOPTS="-j${JOBS}" ARCH="${ARCH}" PATH=/usr/local/bin:/usr/bin + """ + try f.createContext(dir: baseDir, dockerfile: baseDockerfile) + let baseImageName = "test-env-base:\(UUID().uuidString)" + try f.build(tag: baseImageName, contextDir: baseDir, buildArgs: ["JOBS=8", "ARCH=arm64"]) + try f.assertImageBuilt(baseImageName) + + let downstreamDir = try f.createTempDir() + let downstreamDockerfile = + """ + FROM \(baseImageName) + LABEL test="env-inherited" + """ + try f.createContext(dir: downstreamDir, dockerfile: downstreamDockerfile) + let downstreamImageName = "test-env-child:\(UUID().uuidString)" + try f.build(tag: downstreamImageName, contextDir: downstreamDir) + try f.assertImageBuilt(downstreamImageName) + } + } + } + + @Test func testComplexArgAndEnvCombinations() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + ARG JOBS=6 + ARG MAXLOAD=7.00 + ARG ARCH=amd64 + ARG PROFILE_PATH=23.0/split-usr/no-multilib + ARG CHOST=x86_64-pc-linux-gnu + ARG CFLAGS=-O2 -pipe + ENV JOBS="${JOBS}" MAXLOAD="${MAXLOAD}" \\ + GENTOO_PROFILE="default/linux/${ARCH}/${PROFILE_PATH}" \\ + CHOST="${CHOST}" MAKEOPTS="-j${JOBS}" \\ + CFLAGS="${CFLAGS}" CXXFLAGS="${CFLAGS}" + LABEL maintainer="test@example.com" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-complex-env:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir, buildArgs: ["JOBS=12", "ARCH=arm64"]) + try f.assertImageBuilt(imageName) + } + } + } + + @Test func testLabelOnlyDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = + """ + FROM scratch + LABEL maintainer="test@example.com" version="1.0.0" \\ + description="Test image with only labels" \\ + org.opencontainers.image.title="Test Image" + """ + try f.createContext(dir: dir, dockerfile: dockerfile) + let imageName = "test-label-only:\(UUID().uuidString)" + try f.build(tag: imageName, contextDir: dir) + try f.assertImageBuilt(imageName) + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift new file mode 100644 index 0000000..059cb38 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLifecycleSerial.swift @@ -0,0 +1,75 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation +import Testing + +/// Tests for `container builder start`, `stop`, and `delete` lifecycle commands. +/// +/// These tests manage the builder manually — they do not use ``withBuilder`` +/// because they are specifically testing the lifecycle commands themselves. +/// They acquire the shared builder lock via ``withBuilderLock`` to serialise +/// correctly with tests that use ``withBuilder(_:)``. +@Suite(.serialized) +struct TestCLIBuilderLifecycleSerial { + @Test func testBuilderStartStopCommand() async throws { + try await ContainerFixture.with { f in + try await f.withBuilderLock { + f.addCleanup { try? f.builderDelete(force: true) } + + try f.builderStart() + try await f.waitForBuilderRunning() + let status1 = try f.getContainerStatus("buildkit") + #expect(status1 == "running", "buildkit container should be running") + + try f.builderStop() + let status2 = try f.getContainerStatus("buildkit") + #expect(status2 == "stopped", "buildkit container should be stopped") + } + } + } + + @Test func testBuilderEnvironmentColors() async throws { + try await ContainerFixture.with { f in + try await f.withBuilderLock { + let originalColors = ProcessInfo.processInfo.environment["BUILDKIT_COLORS"] + let originalNoColor = ProcessInfo.processInfo.environment["NO_COLOR"] + f.addCleanup { + if let c = originalColors { setenv("BUILDKIT_COLORS", c, 1) } else { unsetenv("BUILDKIT_COLORS") } + if let n = originalNoColor { setenv("NO_COLOR", n, 1) } else { unsetenv("NO_COLOR") } + _ = try? f.builderDelete(force: true) + } + + _ = try? f.builderDelete(force: true) + setenv("BUILDKIT_COLORS", "run=green:warning=yellow:error=red:cancel=cyan", 1) + setenv("NO_COLOR", "true", 1) + + try f.run(["builder", "start"]).check() + try await f.waitForBuilderRunning() + + let container = try f.inspectContainer("buildkit") + let env = container.configuration.initProcess.environment + #expect( + env.contains("BUILDKIT_COLORS=run=green:warning=yellow:error=red:cancel=cyan"), + "BUILDKIT_COLORS should be forwarded to the buildkit container") + #expect( + env.contains("NO_COLOR=true"), + "NO_COLOR should be forwarded to the buildkit container") + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift new file mode 100644 index 0000000..834d8a9 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderLocalOutputSerial.swift @@ -0,0 +1,147 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite(.serialized) +struct TestCLIBuilderLocalOutputSerial { + @Test func testBuildLocalOutputHappyPath() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + // Comprehensive multi-stage build with context and build args. + let dir = try f.createTempDir() + let dockerfile = + """ + ARG MESSAGE=default + FROM scratch AS builder + ADD build.txt /build.txt + ADD testfile.txt /hello.txt + FROM scratch + COPY --from=builder /build.txt /final.txt + COPY --from=builder /hello.txt /app/hello.txt + ADD message.txt /message.txt + """ + let context: [ContainerFixture.FileSystemEntry] = [ + .file("build.txt", content: .data("Building stage\n".data(using: .utf8)!)), + .file("testfile.txt", content: .data("Hello from local build\n".data(using: .utf8)!)), + .file("message.txt", content: .data("Hello from build args\n".data(using: .utf8)!)), + ] + try f.createContext(dir: dir, dockerfile: dockerfile, context: context) + let outputDir = dir.appending("comprehensive-local-output") + let imageName = "local-comprehensive-test:\(UUID().uuidString)" + let response = try f.buildWithPathsAndLocalOutput( + tag: imageName, contextDir: dir, outputDir: outputDir, + buildArgs: ["MESSAGE=Hello from build args"]) + #expect(response.contains(outputDir.string), "output should reference the export path") + #expect(FileManager.default.fileExists(atPath: outputDir.string)) + let contents = try FileManager.default.contentsOfDirectory(atPath: outputDir.string) + #expect(!contents.isEmpty, "output directory should contain files") + + // Basic local output. + let basicDir = try f.createTempDir() + try f.createContext( + dir: basicDir, + dockerfile: "FROM scratch\nADD testfile.txt /hello.txt", + context: [.file("testfile.txt", content: .data("Hello from basic build\n".data(using: .utf8)!))]) + let basicOutputDir = basicDir.appending("basic-local-output") + let basicResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-basic-test:\(UUID().uuidString)", contextDir: basicDir, outputDir: basicOutputDir) + #expect(basicResponse.contains(basicOutputDir.string)) + #expect(FileManager.default.fileExists(atPath: basicOutputDir.string)) + + // Build with context (COPY instruction). + let ctxDir = try f.createTempDir() + try f.createContext( + dir: ctxDir, + dockerfile: "FROM scratch\nCOPY testfile.txt /app/testfile.txt", + context: [.file("testfile.txt", content: .data("Test content\n".data(using: .utf8)!))]) + let ctxOutputDir = ctxDir.appending("context-local-output") + let ctxResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-context-test:\(UUID().uuidString)", contextDir: ctxDir, outputDir: ctxOutputDir) + #expect(ctxResponse.contains(ctxOutputDir.string)) + #expect(FileManager.default.fileExists(atPath: ctxOutputDir.string)) + } + } + } + + @Test func testBuildLocalOutputEdgeCases() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + // Different paths for Dockerfile context and build context. + let dockerfileDir = try f.createTempDir() + try f.createContext( + dir: dockerfileDir, + dockerfile: "FROM scratch\nCOPY . /app", + context: [.file("dockerfile-context.txt", content: .data("Dockerfile context\n".data(using: .utf8)!))]) + + let buildContextDir = try f.createTempDir() + try f.createContext( + dir: buildContextDir, dockerfile: "", + context: [.file("build-context.txt", content: .data("Build context\n".data(using: .utf8)!))]) + + let outputDir = dockerfileDir.appending("diffpaths-local-output") + let response = try f.buildWithPathsAndLocalOutput( + tag: "local-diffpaths-test:\(UUID().uuidString)", + contextDir: buildContextDir, + dockerfilePath: dockerfileDir.appending("Dockerfile"), + outputDir: outputDir) + #expect(response.contains(outputDir.string)) + #expect(FileManager.default.fileExists(atPath: outputDir.string)) + + // Build into an existing output directory (should merge/overwrite). + let existingDir = try f.createTempDir() + try f.createContext( + dir: existingDir, + dockerfile: "FROM scratch\nADD newfile.txt /newfile.txt", + context: [.file("newfile.txt", content: .data("New content\n".data(using: .utf8)!))]) + let existingOutputDir = existingDir.appending("existing-output") + try FileManager.default.createDirectory( + atPath: existingOutputDir.string, withIntermediateDirectories: true, attributes: nil) + try "Existing content\n".data(using: .utf8)! + .write(to: URL(filePath: existingOutputDir.appending("existing.txt").string), options: .atomic) + let existingResponse = try f.buildWithPathsAndLocalOutput( + tag: "local-existing-test:\(UUID().uuidString)", + contextDir: existingDir, outputDir: existingOutputDir) + #expect(existingResponse.contains(existingOutputDir.string)) + let contents = try FileManager.default.contentsOfDirectory(atPath: existingOutputDir.string) + #expect(!contents.isEmpty) + } + } + } + + @Test func testBuildLocalOutputFailure() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD test.txt /test.txt", + context: [.file("test.txt", content: .data("test\n".data(using: .utf8)!))]) + + // An uncreateable path should cause the build to fail. + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-t", "local-invalid-test:\(UUID().uuidString)", + "--output", "type=local,dest=/nonexistent/invalid/path", + dir.appending("context").string, + ]) + #expect(result.status != 0, "build with invalid output path should fail") + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift new file mode 100644 index 0000000..1e75cb7 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderSerial.swift @@ -0,0 +1,1104 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation +import Testing + +// Convenience alias for the verbose entry type. +typealias FSEntry = ContainerFixture.FileSystemEntry + +@Suite(.serialized) +struct TestCLIBuilderSerial { + + // MARK: - Basic build tests + + @Test func testBuildDefaultParams() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext(dir: dir, dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20") + // No tags — runtime generates one and prints it to stdout. + let output = try f.buildWithPaths(contextDir: dir) + let generatedTag = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(!generatedTag.isEmpty, "build should print the generated image tag to stdout") + try f.assertImageBuilt(generatedTag) + } + } + } + + @Test func testBuildDotFileSucceeds() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "registry.local/dot-file:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildFromPreviousStage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 AS layer1 + RUN sh -c "echo 'layer1' > /layer1.txt" + FROM layer1 + CMD ["cat", "/layer1.txt"] + """) + let image = "registry.local/from-previous-layer:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildFromLocalImage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [ + .file("emptyFile", content: .zeroFilled(size: 0)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ]) + let image = "local-only:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + + let dir2 = try f.createTempDir() + try f.createContext( + dir: dir2, + dockerfile: "FROM \(image)", + context: []) + let image2 = "from-local:\(UUID().uuidString)" + try f.build(tag: image2, contextDir: dir2) + try f.assertImageBuilt(image2) + } + } + } + + @Test func testBuildAddFromSpecialDirs() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/scratch-add-special-dir:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildScratchAdd() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/scratch-add:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildAddAll() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/add-all:\(UUID().uuidString)" + let output = try f.build(tag: image, contextDir: dir) + #expect(output.contains(image)) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG TAG=unknown\nFROM ghcr.io/linuxcontainers/alpine:${TAG}") + let image = "registry.local/build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["TAG=3.20"]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildSecret() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=secret,id=ENV1 \\ + --mount=type=secret,id=env2 \\ + --mount=type=secret,id=env3 \\ + test xyyzzz = "`cat /run/secrets/ENV1 /run/secrets/env2 /run/secrets/env3`" + RUN --mount=type=secret,id=file \\ + awk 'BEGIN {for(i=0; i<17; i++) for(c=0; c<256; c++) printf("%c", c)}' > /tmp/foo && \\ + cmp /tmp/foo /run/secrets/file && \\ + rm /tmp/foo + RUN --mount=type=secret,id=empty \\ + ! test -e /run/secrets/file && \\ + test -e /run/secrets/empty && \\ + cmp /dev/null /run/secrets/empty + """) + + setenv("ENV1", "x", 1) + setenv("ENV_VAR", "yy", 1) + setenv("env3", "zzz", 1) + f.addCleanup { + unsetenv("ENV1") + unsetenv("ENV_VAR") + unsetenv("env3") + } + + let testData = Data((0..<17).flatMap { _ in Array(0...255) }) + let secretFile = try f.createTempFile(suffix: " _f,i=l.e+ ", contents: testData) + let emptyFile = try f.createTempFile(suffix: "file2", contents: Data()) + + let image = "registry.local/secrets:\(UUID().uuidString)" + try f.build( + tag: image, contextDir: dir, + otherArgs: [ + "--secret", "id=ENV1", + "--secret", "id=env2,env=ENV_VAR", + "--secret", "id=env3,env=env3", + "--secret", "id=file,src=\(secretFile.string)", + "--secret", "id=empty,src=\(emptyFile.string)", + ]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildNetworkAccess() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ARG HTTP_PROXY + ARG HTTPS_PROXY + ARG NO_PROXY + ARG http_proxy + ARG https_proxy + ARG no_proxy + RUN apk add --no-cache curl + """) + var buildArgs: [String] = [] + for key in ["HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY", "http_proxy", "https_proxy", "no_proxy"] { + if let v = ProcessInfo.processInfo.environment[key] { buildArgs.append("\(key)=\(v)") } + } + let image = "registry.local/build-network-access:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: buildArgs) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildDockerfileKeywords() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG TAG=3.20 + FROM ghcr.io/linuxcontainers/alpine:${TAG} + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN echo "Hello, World!" > /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + CMD ["echo", "Exec default"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + LABEL version="1.0" description="Test image" + FROM ghcr.io/linuxcontainers/alpine:3.20 + EXPOSE 8080 + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENV MY_ENV=hello + RUN echo $MY_ENV > /env.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD emptyFile / + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY toCopy /toCopy + FROM ghcr.io/linuxcontainers/alpine:3.20 + ENTRYPOINT ["echo", "entrypoint!"] + FROM ghcr.io/linuxcontainers/alpine:3.20 + VOLUME /data + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN adduser -D myuser + USER myuser + CMD whoami + FROM ghcr.io/linuxcontainers/alpine:3.20 + WORKDIR /app + RUN pwd > /pwd.out + FROM ghcr.io/linuxcontainers/alpine:3.20 + ARG MY_VAR=default + RUN echo $MY_VAR > /var.out + """, + context: [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file("toCopy", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/dockerfile-keywords:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildSymlink() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test1Source Test1Source + ADD Test1Source2 Test1Source2 + RUN cat Test1Source2/test.yaml + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test2Source Test2Source + ADD Test2Source2 Test2Source2 + RUN cat Test2Source2/Test/test.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD Test3Source Test3Source + ADD Test3Source2 Test3Source2 + RUN cat Test3Source2/Dest/test.txt + """ + let context: [FSEntry] = [ + .directory("Test1Source"), .directory("Test1Source2"), + .file("Test1Source/test.yaml", content: .zeroFilled(size: 200)), + .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), + .directory("Test2Source"), .directory("Test2Source2"), + .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 300)), + .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), + .directory("Test3Source/Source"), .directory("Test3Source2"), + .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), + .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), + ] + try f.createContext(dir: dir, dockerfile: dockerfile, context: context) + let image = "registry.local/build-symlinks:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildAndRun() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"foobar\" > /file") + let image = "\(f.testID)-build-and-run:latest" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + try await f.withContainer(image: image) { name in + let output = try f.doExec(name, cmd: ["cat", "/file"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "foobar") + } + } + } + } + + @Test func testBuildDifferentPaths() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN ls ./ + COPY . /root + RUN cat /root/Test/test.txt + """, + context: [ + .directory(".git"), + .file(".git/FETCH", content: .zeroFilled(size: 1)), + .directory("Test"), + .file("Test/test.txt", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/build-diff-context:\(UUID().uuidString)" + try f.buildWithPaths(tags: [image], contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildMultiArch() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + ADD . . + RUN cat emptyFile + RUN cat Test/testempty + """, + context: [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ]) + let image = "registry.local/multi-arch:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, otherArgs: ["--arch", "amd64,arm64"]) + try f.assertImageBuilt(image) + + let output = try f.doInspectImages(image) + #expect(output.count == 1, "expected single inspect result") + let archs = Set(output[0].variants.map { $0.platform.architecture }) + #expect(archs == Set(["amd64", "arm64"]), "expected amd64 and arm64 variants") + } + } + } + + @Test func testBuildMultipleTags() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let uuid = UUID().uuidString + let tag1 = "registry.local/multi-tag-test:\(uuid)" + let tag2 = "registry.local/multi-tag-test:latest" + let tag3 = "registry.local/multi-tag-test:v1.0.0" + let output = try f.buildWithPaths(tags: [tag1, tag2, tag3], contextDir: dir) + #expect(output.contains(tag1)) + #expect(output.contains(tag2)) + #expect(output.contains(tag3)) + try f.assertImageBuilt(tag1) + try f.assertImageBuilt(tag2) + try f.assertImageBuilt(tag3) + } + } + } + + @Test func testBuildAfterContextChange() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let initialContent = "initial".data(using: .utf8)! + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY foo /foo\nCOPY bar /bar", + context: [ + .file("foo", content: .data(Data((0..<4 * 1024 * 1024).map { UInt8($0 % 256) }))), + .file("bar", content: .data(initialContent)), + ]) + + let image1 = "\(f.testID)-build-context-change:v1" + try f.build(tag: image1, contextDir: dir) + try await f.withContainer(image: image1) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "initial") + } + + let contextBar = dir.appending("context").appending("bar") + try "updated".data(using: .utf8)!.write(to: URL(filePath: contextBar.string), options: .atomic) + + let image2 = "\(f.testID)-build-context-change:v2" + try f.build(tag: image2, contextDir: dir) + try await f.withContainer(image: image2) { name in + let out = try f.doExec(name, cmd: ["cat", "/bar"]) + #expect(out == "updated") + } + } + } + } + + @Test func testBuildWithDockerfileFromStdin() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM scratch\nADD emptyFile /" + try f.createContext( + dir: dir, dockerfile: "", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/stdin-file:\(UUID().uuidString)" + try f.buildWithStdin(tags: [image], contextDir: dir, dockerfileContents: dockerfile) + try f.assertImageBuilt(image) + } + } + } + + @Test func testLowercaseDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let files: [(String, String, String)] = [ + ("COPY . /app", "copy-uppercase", "COPY"), + ("copy . /app", "copy-lowercase", "copy"), + ("ADD . /app", "add-uppercase", "ADD"), + ("add . /app", "add-lowercase", "add"), + ] + for (instruction, name, _) in files { + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + \(instruction) + RUN test -f /app/testfile.txt + """, + context: [.file("testfile.txt", content: .data("test".data(using: .utf8)!))]) + let image = "registry.local/\(name):\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + } + + @Test func testRunWithBindMount() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + RUN --mount=type=bind,source=.,target=/mnt/context \\ + set -e; \\ + if [ ! -f /mnt/context/app.py ]; then echo "ERROR: app.py missing"; exit 1; fi; \\ + if [ ! -f /mnt/context/config.yaml ]; then echo "ERROR: config.yaml missing"; exit 1; fi; \\ + cp /mnt/context/app.py /app.py + RUN cat /app.py + """, + context: [ + .file("app.py", content: .data("print('Hello from bind mount')".data(using: .utf8)!)), + .file("config.yaml", content: .data("key: value".data(using: .utf8)!)), + ]) + let image = "registry.local/bind-mount-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + // MARK: - .dockerignore tests + + @Test func testBuildDockerIgnore() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerignore = """ + secret.txt + *.log + **/*.log + !important.log + *.tmp + **/*.tmp + temp/ + node_modules/ + """ + try f.createContext( + dir: dir, + dockerfile: """ + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY . /app + RUN set -e; [ ! -f /app/secret.txt ] || exit 1 + RUN set -e; [ ! -f /app/debug.log ] || exit 1 + RUN set -e; [ -f /app/important.log ] || exit 1 + RUN set -e; find /app -name "*.tmp" | grep . && exit 1; true + RUN set -e; [ ! -d /app/temp ] || exit 1 + RUN set -e; [ ! -d /app/node_modules ] || exit 1 + RUN set -e; [ -f /app/main.go ] && [ -f /app/README.md ] && [ -f /app/src/app.go ] + """, + context: [ + .file(".dockerignore", content: .data(dockerignore.data(using: .utf8)!)), + .file("secret.txt", content: .data("secret".data(using: .utf8)!)), + .file("debug.log", content: .data("debug".data(using: .utf8)!)), + .file("important.log", content: .data("important".data(using: .utf8)!)), + .file("cache.tmp", content: .data("cache".data(using: .utf8)!)), + .file("main.go", content: .data("package main".data(using: .utf8)!)), + .file("README.md", content: .data("# README".data(using: .utf8)!)), + .directory("temp"), + .file("logs/app.log", content: .data("app log".data(using: .utf8)!)), + .directory("node_modules"), + .directory("src"), + .file("src/app.go", content: .data("package src".data(using: .utf8)!)), + ]) + let image = "registry.local/dockerignore-test:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testDockerIgnoreBasic() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, + dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("ignored.txt", content: .data("ignored\n".data(using: .utf8)!)), + .file(".dockerignore", content: .data("ignored.txt\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-basic:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]) + try result.check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/ignored.txt") + } + } + } + } + + @Test func testDockerIgnoreDockerfileSpecific() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("specific.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-specific:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt", "specific.txt should be ignored by Dockerfile.dockerignore") + try f.assertContainerHasFile(name, at: "/app/general.txt", "general.txt should be present (Dockerfile.dockerignore takes precedence)") + } + } + } + } + + @Test func testDockerIgnoreOutsideContext() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("general.txt\n".data(using: .utf8)!)), + .file("general.txt", content: .data("general\n".data(using: .utf8)!)), + .file("specific.txt", content: .data("specific\n".data(using: .utf8)!)), + ]) + try "specific.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + let image = "registry.local/dockerignore-outside:\(UUID().uuidString)" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/specific.txt") + try f.assertContainerHasFile(name, at: "/app/general.txt") + } + } + } + } + + @Test func testDockerIgnoreIgnoredDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file(".dockerignore", content: .data("Dockerfile\n.dockerignore\n".data(using: .utf8)!)), + .file("test.txt", content: .data("test\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-ignored-dockerfile:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/Dockerfile") + try f.assertContainerMissingFile(name, at: "/app/.dockerignore") + try f.assertContainerHasFile(name, at: "/app/test.txt") + } + } + } + } + + @Test func testDockerIgnoreSubdirDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file(".dockerignore", content: .data("included.txt\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + .file("nested/secret.txt", content: .data("nested secret\n".data(using: .utf8)!)), + .file("nested/project/Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/Dockerfile.dockerignore", content: .data("secret.txt\n**/secret.txt\n".data(using: .utf8)!)), + .file("nested/project/config.txt", content: .data("config\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("Dockerfile") + let image = "registry.local/dockerignore-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + try f.assertContainerMissingFile(name, at: "/app/nested/secret.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.txt") + } + } + } + } + + @Test func testDockerIgnoreCustomDockerfileName() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", // no top-level Dockerfile + context: [ + .file(".dockerignore", content: .data("generic.txt\n".data(using: .utf8)!)), + .file("app1.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("app1.Dockerfile.dockerignore", content: .data("app1-specific.txt\n".data(using: .utf8)!)), + .file("app1-specific.txt", content: .data("app1 specific\n".data(using: .utf8)!)), + .file("generic.txt", content: .data("generic\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-custom-name:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app1.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app1-specific.txt") + try f.assertContainerHasFile(name, at: "/app/generic.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + } + + @Test func testDockerIgnoreCustomNameSubdir() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .file(".dockerignore", content: .data("from-root-ignore.txt\n".data(using: .utf8)!)), + .file("from-root-ignore.txt", content: .data("root ignore\n".data(using: .utf8)!)), + .file("from-app2-ignore.txt", content: .data("app2 ignore\n".data(using: .utf8)!)), + .file("always-included.txt", content: .data("always\n".data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile", content: .data(dockerfile.data(using: .utf8)!)), + .file("nested/project/app2.Dockerfile.dockerignore", content: .data("from-app2-ignore.txt\n".data(using: .utf8)!)), + .file("nested/project/config.yaml", content: .data("config\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let nestedDockerfile = contextDir.appending("nested").appending("project").appending("app2.Dockerfile") + let image = "registry.local/dockerignore-custom-subdir:\(UUID().uuidString)" + try f.run([ + "build", "-f", nestedDockerfile.string, "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/from-app2-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/from-root-ignore.txt") + try f.assertContainerHasFile(name, at: "/app/always-included.txt") + try f.assertContainerHasFile(name, at: "/app/nested/project/config.yaml") + } + } + } + } + + @Test func testDockerIgnoreCoexistingDockerfiles() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let appDockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: "", + context: [ + .file("Dockerfile", content: .data("FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . .\n".data(using: .utf8)!)), + .file("Dockerfile.dockerignore", content: .data("dockerfile-specific.txt\n".data(using: .utf8)!)), + .file("app.Dockerfile", content: .data(appDockerfile.data(using: .utf8)!)), + .file("app.Dockerfile.dockerignore", content: .data("app-specific.txt\n".data(using: .utf8)!)), + .file("dockerfile-specific.txt", content: .data("df specific\n".data(using: .utf8)!)), + .file("app-specific.txt", content: .data("app specific\n".data(using: .utf8)!)), + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + ]) + let contextDir = dir.appending("context") + let image = "registry.local/dockerignore-coexisting:\(UUID().uuidString)" + try f.run([ + "build", "-f", contextDir.appending("app.Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerMissingFile(name, at: "/app/app-specific.txt") + try f.assertContainerHasFile(name, at: "/app/dockerfile-specific.txt") + try f.assertContainerHasFile(name, at: "/app/included.txt") + } + } + } + } + + @Test func testDockerIgnoreReadonlyContext() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let dockerfile = "FROM ghcr.io/linuxcontainers/alpine:3.20\nWORKDIR /app\nCOPY . ." + try f.createContext( + dir: dir, dockerfile: dockerfile, + context: [ + .file("included.txt", content: .data("included\n".data(using: .utf8)!)), + .file("secret.txt", content: .data("secret\n".data(using: .utf8)!)), + ]) + try "secret.txt\n".data(using: .utf8)!.write(to: URL(filePath: dir.appending("Dockerfile.dockerignore").string), options: .atomic) + + let contextDir = dir.appending("context") + // Make the context read-only, then restore before cleanup. + try FileManager.default.setAttributes( + [.posixPermissions: 0o555], ofItemAtPath: contextDir.string) + f.addCleanup { + try? FileManager.default.setAttributes( + [.posixPermissions: 0o755], ofItemAtPath: contextDir.string) + } + + let image = "registry.local/dockerignore-readonly:\(UUID().uuidString.prefix(6))" + try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, contextDir.string, + ]).check() + try await f.withContainer(image: image, tag: "c") { name in + try f.assertContainerHasFile(name, at: "/app/included.txt") + try f.assertContainerMissingFile(name, at: "/app/secret.txt") + } + } + } + } + + @Test func testNonExistingDockerfile() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + let image = "registry.local/non-existing-dockerfile:\(UUID().uuidString)" + let r1 = try f.run(["build", "-f", "non-existing-path", "-t", image, dir.string]) + #expect(r1.status != 0) + let r2 = try f.run(["build", "-t", image, dir.string]) + #expect(r2.status != 0) + } + } + } + + @Test func testBuildNoCachePullLatestImage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM \(ContainerFixture.warmupImages[0])\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + let image = "registry.local/no-cache-pull:\(UUID().uuidString)" + try f.buildWithPaths(tags: [image], contextDir: dir, otherArgs: ["--pull", "--no-cache"]) + try f.assertImageBuilt(image) + } + } + } + + // MARK: - Dockerfile ARG quoting + + @Test func testBuildQuotedImageDockerfileArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE=\"ghcr.io/linuxcontainers/alpine:3.20\"\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildQuotedStringDockerfileArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING='\"Hello, world!\"'\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildForwardReferencedDockerfileArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE="ghcr.io/linuxcontainers/alpine" + ARG IMAGE="${ALPINE}:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-dockerfile-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildQuotedImageBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "ARG IMAGE\nFROM $IMAGE\nRUN test -f /etc/alpine-release") + let image = "registry.local/quoted-image-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["IMAGE=ghcr.io/linuxcontainers/alpine:3.20"]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildQuotedStringBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nARG MYSTRING\nRUN test \"$MYSTRING\" = '\"Hello, world!\"'") + let image = "registry.local/quoted-string-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["MYSTRING=\"Hello, world!\""]) + try f.assertImageBuilt(image) + } + } + } + + @Test func testBuildForwardReferencedBuildArg() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + ARG ALPINE + ARG IMAGE="$ALPINE:3.20" + FROM $IMAGE + RUN test -f /etc/alpine-release + """) + let image = "registry.local/forward-referenced-build-arg:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir, buildArgs: ["ALPINE=ghcr.io/linuxcontainers/alpine"]) + try f.assertImageBuilt(image) + } + } + } + + // MARK: - COPY --from tests + + @Test func testCopyFromLocalImage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let baseDir = try f.createTempDir() + let baseName = "local-base:\(UUID().uuidString)" + try f.createContext( + dir: baseDir, + dockerfile: "FROM scratch\nADD hello.txt /hello.txt", + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + try f.build(tag: baseName, contextDir: baseDir) + try f.assertImageBuilt(baseName) + + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=\(baseName) /hello.txt /copied.txt\nRUN cat /copied.txt") + let image = "registry.local/copy-from-local:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testCopyFromBuildStage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /copied.txt + RUN cat /copied.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-from-stage:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testCopyRenameFromStage() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + ADD hello.txt /hello.txt + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /hello.txt /renamed.txt + RUN cat /renamed.txt + """, + context: [.file("hello.txt", content: .data("hello\n".data(using: .utf8)!))]) + let image = "registry.local/copy-rename:\(UUID().uuidString)" + try f.build(tag: image, contextDir: dir) + try f.assertImageBuilt(image) + } + } + } + + @Test func testCopyMissingFileFails() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: """ + FROM scratch AS builder + FROM ghcr.io/linuxcontainers/alpine:3.20 + COPY --from=builder /does-not-exist.txt /copied.txt + """) + let image = "registry.local/copy-missing:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source file is missing") + } + } + } + + @Test func testCopyInvalidStageFails() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=not_a_stage /hello.txt /copied.txt") + let image = "registry.local/copy-invalid-stage:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail with invalid stage name") + } + } + } + + @Test func testCopyFromNonexistentImageFails() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nCOPY --from=doesnotexist:latest /hello.txt /copied.txt") + let image = "registry.local/copy-bad-image:\(UUID().uuidString)" + let result = try f.run([ + "build", "-f", dir.appending("Dockerfile").string, + "-t", image, dir.appending("context").string, + ]) + #expect(result.status != 0, "build should fail when source image does not exist") + } + } + } +} diff --git a/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift new file mode 100644 index 0000000..543afe3 --- /dev/null +++ b/Tests/IntegrationTests/Build/TestCLIBuilderTarExportSerial.swift @@ -0,0 +1,125 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite(.serialized) +struct TestCLIBuilderTarExportSerial { + @Test func testBuildExportTar() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD emptyFile /", + context: [.file("emptyFile", content: .zeroFilled(size: 1))]) + + let exportPath = dir.appending("export.tar") + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportPath.string)", + dir.appending("context").string, + ]) + #expect(result.status == 0, "build with tar export should succeed") + #expect(FileManager.default.fileExists(atPath: exportPath.string), "tar file should exist") + #expect(result.output.contains(exportPath.string), "output should reference export path") + let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) + #expect((attrs[.size] as? Int ?? 0) > 0, "exported tar should not be empty") + } + } + } + + @Test func testBuildExportTarToDirectory() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM ghcr.io/linuxcontainers/alpine:3.20\nRUN echo \"test\" > /test.txt") + + let exportDir = dir.appending("exports") + try FileManager.default.createDirectory( + atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) + + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportDir.string)", + dir.appending("context").string, + ]) + #expect(result.status == 0, "build with tar export to directory should succeed") + let expectedTar = exportDir.appending("out.tar") + #expect( + FileManager.default.fileExists(atPath: expectedTar.string), + "tar file should exist at out.tar") + #expect(result.output.contains(expectedTar.string), "output should reference out.tar") + } + } + } + + @Test func testBuildExportTarMultipleRuns() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext( + dir: dir, + dockerfile: "FROM scratch\nADD testFile /", + context: [.file("testFile", content: .data("test data".data(using: .utf8)!))]) + + let exportDir = dir.appending("exports") + try FileManager.default.createDirectory( + atPath: exportDir.string, withIntermediateDirectories: true, attributes: nil) + + let buildArgs = [ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar,dest=\(exportDir.string)", + dir.appending("context").string, + ] + + let r1 = try f.run(buildArgs) + #expect(r1.status == 0, "first build should succeed") + #expect(FileManager.default.fileExists(atPath: exportDir.appending("out.tar").string)) + + let r2 = try f.run(buildArgs) + #expect(r2.status == 0, "second build should succeed") + #expect( + FileManager.default.fileExists(atPath: exportDir.appending("out.tar.1").string), + "second tar should exist at out.tar.1") + } + } + } + + @Test func testBuildExportTarInvalidDest() async throws { + try await ContainerFixture.with { f in + try await f.withBuilder { f in + let dir = try f.createTempDir() + try f.createContext(dir: dir, dockerfile: "FROM scratch") + + let result = try f.run([ + "build", + "-f", dir.appending("Dockerfile").string, + "-o", "type=tar", // missing dest + dir.appending("context").string, + ]) + #expect(result.status != 0, "build without dest should fail") + #expect(result.error.contains("dest field is required")) + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLICopyCommand.swift b/Tests/IntegrationTests/Containers/TestCLICopyCommand.swift new file mode 100644 index 0000000..dcbd18c --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLICopyCommand.swift @@ -0,0 +1,546 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite +struct TestCLICopyCommand { + + // MARK: - Basic host/container copy + + @Test func testCopyHostToContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let src = f.testDir.appending("testfile.txt") + let content = "hello from host" + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.run(["copy", src.string, "\(name):/tmp/"]).check() + let cat = try f.doExec(name, cmd: ["cat", "/tmp/testfile.txt"]) + #expect(cat.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyContainerToHost() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "hello from container" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/containerfile.txt"]) + let dest = f.testDir.appending("containerfile.txt") + try f.run(["copy", "\(name):/tmp/containerfile.txt", dest.string]).check() + let hostContent = try String(contentsOfFile: dest.string, encoding: .utf8) + #expect(hostContent == content) + } + } + } + + @Test func testCopyUsingCpAlias() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let src = f.testDir.appending("aliasfile.txt") + let content = "testing cp alias" + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.run(["cp", src.string, "\(name):/tmp/"]).check() + let cat = try f.doExec(name, cmd: ["cat", "/tmp/aliasfile.txt"]) + #expect(cat.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyLocalToLocalFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["copy", "/tmp/source.txt", "/tmp/dest.txt"]) + #expect(result.status != 0, "expected local-to-local copy to fail") + } + } + + @Test func testCopyContainerToContainerFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try f.doRemoveIfExists(name, ignoreFailure: true) } + let result = try f.run(["copy", "\(name):/tmp/file.txt", "\(name):/tmp/file2.txt"]) + #expect(result.status != 0, "expected container-to-container copy to fail") + } + } + + @Test func testCopyToNonRunningContainerFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try f.doRemoveIfExists(name, ignoreFailure: true) } + let src = f.testDir.appending("norun.txt") + try "test".write(toFile: src.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", src.string, "\(name):/tmp/"]) + #expect(result.status != 0, "expected copy to non-running container to fail") + } + } + + @Test func testCopyDirectoryHostToContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("hostdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "file1 content".write(toFile: srcDir.appending("file1.txt").string, atomically: true, encoding: .utf8) + try "file2 content".write(toFile: srcDir.appending("file2.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string, "\(name):/tmp/"]).check() + let cat1 = try f.doExec(name, cmd: ["cat", "/tmp/hostdir/file1.txt"]) + #expect(cat1.trimmingCharacters(in: .whitespacesAndNewlines) == "file1 content") + let cat2 = try f.doExec(name, cmd: ["cat", "/tmp/hostdir/file2.txt"]) + #expect(cat2.trimmingCharacters(in: .whitespacesAndNewlines) == "file2 content") + } + } + } + + @Test func testCopyDirectoryContainerToHost() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/guestdir && echo -n 'aaa' > /tmp/guestdir/a.txt && echo -n 'bbb' > /tmp/guestdir/b.txt"]) + let dest = f.testDir.appending("guestdir") + try f.run(["copy", "\(name):/tmp/guestdir", dest.string]).check() + let a = try String(contentsOfFile: dest.appending("a.txt").string, encoding: .utf8) + #expect(a == "aaa") + let b = try String(contentsOfFile: dest.appending("b.txt").string, encoding: .utf8) + #expect(b == "bbb") + } + } + } + + @Test func testCopyNestedDirectoryHostToContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("nested") + let subDir = srcDir.appending("sub") + try FileManager.default.createDirectory(atPath: subDir.string, withIntermediateDirectories: true, attributes: nil) + try "root file".write(toFile: srcDir.appending("root.txt").string, atomically: true, encoding: .utf8) + try "nested file".write(toFile: subDir.appending("deep.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string, "\(name):/tmp/"]).check() + let catRoot = try f.doExec(name, cmd: ["cat", "/tmp/nested/root.txt"]) + #expect(catRoot.trimmingCharacters(in: .whitespacesAndNewlines) == "root file") + let catDeep = try f.doExec(name, cmd: ["cat", "/tmp/nested/sub/deep.txt"]) + #expect(catDeep.trimmingCharacters(in: .whitespacesAndNewlines) == "nested file") + } + } + } + + @Test func testCopyNestedDirectoryContainerToHost() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/nested/sub && echo -n 'root file' > /tmp/nested/root.txt && echo -n 'nested file' > /tmp/nested/sub/deep.txt"]) + let dest = f.testDir.appending("nested") + try f.run(["copy", "\(name):/tmp/nested", dest.string]).check() + let root = try String(contentsOfFile: dest.appending("root.txt").string, encoding: .utf8) + #expect(root == "root file") + let deep = try String(contentsOfFile: dest.appending("sub").appending("deep.txt").string, encoding: .utf8) + #expect(deep == "nested file") + } + } + } + + // MARK: - CopyOut S1: no trailing slash + + @Test func testCopyOutFileToExistingFile() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "container content" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) + let dest = f.testDir.appending("existing.txt") + try "old content".write(toFile: dest.string, atomically: true, encoding: .utf8) + try f.run(["copy", "\(name):/tmp/source.txt", dest.string]).check() + let result = try String(contentsOfFile: dest.string, encoding: .utf8) + #expect(result == content) + } + } + } + + @Test func testCopyOutDirectoryToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) + let dest = f.testDir.appending("existing.txt") + try "x".write(toFile: dest.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", "\(name):/tmp/srcdir", dest.string]) + #expect(result.status != 0, "expected directory-to-existing-file to fail") + } + } + } + + @Test func testCopyOutFileToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "container content" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/source.txt", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("source.txt").string, encoding: .utf8) + #expect(result == content) + } + } + } + + @Test func testCopyOutDirectoryToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyOut S2: trailing slash on dst + + @Test func testCopyOutFileToNonExistingTrailingSlashFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/source.txt"]) + let dest = f.testDir.appending("nonexistent").string + "/" + let result = try f.run(["copy", "\(name):/tmp/source.txt", dest]) + #expect(result.status != 0, "expected file-to-nonexisting/ to fail") + } + } + } + + @Test func testCopyOutDirectoryToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("newdir") + try f.run(["copy", "\(name):/tmp/srcdir", destDir.string + "/"]).check() + var isDir: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: destDir.string, isDirectory: &isDir) && isDir.boolValue) + let result = try String(contentsOfFile: destDir.appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + @Test func testCopyOutFileToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "container content" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/source.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/source.txt", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("source.txt").string, encoding: .utf8) + #expect(result == content) + } + } + } + + @Test func testCopyOutDirectoryToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyOut S3: trailing slash on src + + @Test func testCopyOutDirectoryContentsToNonExisting() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir/sub && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("newdir") + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + var isDir: ObjCBool = false + #expect(FileManager.default.fileExists(atPath: destDir.appending("sub").string, isDirectory: &isDir) && isDir.boolValue) + } + } + } + + @Test func testCopyOutDirectoryContentsToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'x' > /tmp/srcdir/file.txt"]) + let dest = f.testDir.appending("existing.txt") + try "x".write(toFile: dest.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", "\(name):/tmp/srcdir/", dest.string]) + #expect(result.status != 0, "expected directory/-to-existing-file to fail") + } + } + } + + @Test func testCopyOutDirectoryContentsToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyOut S4: trailing slash on both src and dst + + @Test func testCopyOutDirectoryContentsToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("newdir") + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + @Test func testCopyOutDirectoryContentsToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /tmp/srcdir && echo -n 'hello' > /tmp/srcdir/file.txt"]) + let destDir = f.testDir.appending("dstdir") + try FileManager.default.createDirectory(atPath: destDir.string, withIntermediateDirectories: true, attributes: nil) + try f.run(["copy", "\(name):/tmp/srcdir/", destDir.string + "/"]).check() + let result = try String(contentsOfFile: destDir.appending("srcdir").appending("file.txt").string, encoding: .utf8) + #expect(result == "hello") + } + } + } + + // MARK: - CopyIn S1: no trailing slash + + @Test func testCopyInFileToExistingFile() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "new content" + let src = f.testDir.appending("source.txt") + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'old content' > /tmp/existing.txt"]) + try f.run(["copy", src.string, "\(name):/tmp/existing.txt"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/existing.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyInDirectoryToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "x".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/existing.txt"]) + let result = try f.run(["copy", srcDir.string, "\(name):/tmp/existing.txt"]) + #expect(result.status != 0, "expected directory-to-existing-file to fail") + } + } + } + + @Test func testCopyInFileToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "host content" + let src = f.testDir.appending("source.txt") + try content.write(toFile: src.string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", src.string, "\(name):/tmp/dstdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/source.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyInDirectoryToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", srcDir.string, "\(name):/tmp/dstdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - CopyIn S2: trailing slash on dst + + @Test func testCopyInFileToNonExistingTrailingSlashFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let src = f.testDir.appending("source.txt") + try "x".write(toFile: src.string, atomically: true, encoding: .utf8) + let result = try f.run(["copy", src.string, "\(name):/tmp/nonexistent/"]) + #expect(result.status != 0, "expected file-to-nonexisting/ to fail") + } + } + } + + @Test func testCopyInDirectoryToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string, "\(name):/tmp/newdir/"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/newdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - CopyIn S3: trailing slash on src + + @Test func testCopyInDirectoryContentsToNonExisting() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + let subDir = srcDir.appending("sub") + try FileManager.default.createDirectory(atPath: subDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/newdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/newdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + @Test func testCopyInDirectoryContentsToExistingFileFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "x".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["sh", "-c", "echo -n 'x' > /tmp/existing.txt"]) + let result = try f.run(["copy", srcDir.string + "/", "\(name):/tmp/existing.txt"]) + #expect(result.status != 0, "expected directory/-to-existing-file to fail") + } + } + } + + @Test func testCopyInDirectoryContentsToExistingDirectory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/dstdir"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - CopyIn S4: trailing slash on both src and dst + + @Test func testCopyInDirectoryContentsToNonExistingTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/newdir/"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/newdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + @Test func testCopyInDirectoryContentsToExistingDirectoryTrailingSlash() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let srcDir = f.testDir.appending("srcdir") + try FileManager.default.createDirectory(atPath: srcDir.string, withIntermediateDirectories: true, attributes: nil) + try "hello".write(toFile: srcDir.appending("file.txt").string, atomically: true, encoding: .utf8) + try f.doExec(name, cmd: ["mkdir", "-p", "/tmp/dstdir"]) + try f.run(["copy", srcDir.string + "/", "\(name):/tmp/dstdir/"]).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/dstdir/srcdir/file.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + } + + // MARK: - Relative path resolution + + @Test func testCopyInRelativeSourcePath() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "relative source" + try content.write(toFile: f.testDir.appending("relfile.txt").string, atomically: true, encoding: .utf8) + try f.run(["copy", "./relfile.txt", "\(name):/tmp/"], currentDirectory: f.testDir).check() + let result = try f.doExec(name, cmd: ["cat", "/tmp/relfile.txt"]) + #expect(result.trimmingCharacters(in: .whitespacesAndNewlines) == content) + } + } + } + + @Test func testCopyOutRelativeDestinationPath() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let content = "relative dest" + try f.doExec(name, cmd: ["sh", "-c", "echo -n '\(content)' > /tmp/relfile.txt"]) + try f.run(["copy", "\(name):/tmp/relfile.txt", "./"], currentDirectory: f.testDir).check() + let result = try String(contentsOfFile: f.testDir.appending("relfile.txt").string, encoding: .utf8) + #expect(result == content) + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLICreateCommand.swift b/Tests/IntegrationTests/Containers/TestCLICreateCommand.swift new file mode 100644 index 0000000..897cb2d --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLICreateCommand.swift @@ -0,0 +1,108 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationExtras +import Foundation +import Testing + +@Suite +struct TestCLICreateCommand { + @Test func testCreateArgsPassthrough() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image, args: ["echo", "-n", "hello", "world"]) + try f.doRemove(name) + } + } + + @Test func testCreateWithMACAddress() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + let expectedMAC = try MACAddress("02:42:ac:11:00:03") + + try f.doCreate(name: name, image: image, networks: ["default,mac=\(expectedMAC)"]) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let inspect = try f.inspectContainer(name) + #expect(inspect.networks.count > 0, "expected at least one network attachment") + let actualMAC = inspect.networks[0].macAddress?.description ?? "nil" + #expect( + actualMAC == expectedMAC.description, + "expected MAC address \(expectedMAC), got \(actualMAC)") + } + } + + @Test func testPublishPortParserMaxPorts() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + var args: [String] = ["create", "--name", name] + for i in 0..<64 { + args += ["--publish", "127.0.0.1:\(8000 + i):\(9000 + i)"] + } + args += [image, "echo", "\"hello world\""] + + let result = try f.run(args) + f.addCleanup { try? f.doRemove(name) } + #expect(result.status == 0, "expected create with 64 ports to succeed, stderr: \(result.error)") + } + } + + @Test func testPublishPortParserTooManyPorts() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + var args: [String] = ["create", "--name", name] + for i in 0..<65 { + args += ["--publish", "127.0.0.1:\(8000 + i):\(9000 + i)"] + } + args += [image, "echo", "\"hello world\""] + + let result = try f.run(args) + f.addCleanup { try? f.doRemove(name) } + #expect(result.status != 0, "expected create with 65 ports to fail") + } + } + + @Test func testCreateWithFQDNName() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + // Prefix with testID to avoid name collisions; hostname is the first FQDN component. + let name = "\(f.testID).example.com" + let expectedHostname = f.testID + + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let inspect = try f.inspectContainer(name) + let attachmentHostname = inspect.networks.first?.hostname ?? "" + let gotHostname = + attachmentHostname + .split(separator: ".", maxSplits: 1, omittingEmptySubsequences: true) + .first + .map { String($0) } ?? attachmentHostname + #expect( + gotHostname == expectedHostname, + "expected hostname '\(expectedHostname)' from FQDN '\(name)', got '\(gotHostname)'") + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIExecCommand.swift b/Tests/IntegrationTests/Containers/TestCLIExecCommand.swift new file mode 100644 index 0000000..d3cbec5 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIExecCommand.swift @@ -0,0 +1,115 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@Suite +struct TestCLIExecCommand { + @Test func testCreateExecCommand() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + let uname = try f.doExec(name, cmd: ["uname"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uname == "Linux", "expected OS to be Linux, got \(uname)") + try f.doStop(name) + } + } + + @Test func testExecDetach() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let output = try f.doExec(name, cmd: ["sh", "-c", "touch /tmp/detach_test_marker"], detach: true) + try #require( + output.trimmingCharacters(in: .whitespacesAndNewlines) == name, + "exec --detach should print the container name") + + let ls = try f.doExec(name, cmd: ["ls", "/"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + try #require(ls.contains("tmp"), "container should still be running after detached exec") + + // Retry until the detached process creates the marker file. + var markerFound = false + for _ in 0..<3 { + let result = try f.run(["exec", name, "test", "-f", "/tmp/detach_test_marker"]) + if result.status == 0 { + markerFound = true + break + } + try await Task.sleep(for: .seconds(1)) + } + try #require(markerFound, "marker file should be created by detached process within 3 seconds") + + try f.doStop(name) + } + } + + @Test func testExecDetachProcessRunning() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try? f.doStop(name) } + try f.doStart(name) + try await f.waitForContainerRunning(name) + + let output = try f.doExec(name, cmd: ["sleep", "10"], detach: true) + try #require( + output.trimmingCharacters(in: .whitespacesAndNewlines) == name, + "exec --detach should print the container name") + + let ps = try f.doExec(name, cmd: ["ps", "aux"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + try #require(ps.contains("sleep 10"), "detached 'sleep 10' should appear in ps output") + + try f.doStop(name) + } + } + + @Test func testExecOnExitingContainer() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + // sh exits immediately in detached mode with no stdin; container stops on its own. + try f.doLongRun(name: name, image: image, containerArgs: ["sh"], autoRemove: false) + f.addCleanup { try? f.doRemove(name) } + try await Task.sleep(for: .seconds(1)) + + try f.doStart(name) + let execResult = try f.run(["exec", name, "sleep", "infinity"]) + if execResult.status != 0 { + #expect( + execResult.error.contains("is not running") + || execResult.error.contains("failed to create process"), + "expected 'not running' error, got: \(execResult.error)") + } + + try await Task.sleep(for: .seconds(1)) + // Container should still exist even if exec failed. + _ = try f.getContainerStatus(name) + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift new file mode 100644 index 0000000..b3c3ec2 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIExportCommand.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationArchive +import Foundation +import Testing + +@Suite +struct TestCLIExportCommand { + @Test func testExportCommand() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + let mustBeInImage = "must-be-in-image" + try f.doExec(name, cmd: ["sh", "-c", "echo \(mustBeInImage) > /foo"]) + try f.doExec(name, cmd: ["sh", "-c", "mkdir -p /parent/child"]) + let hardlinkMustRemain = "hardlink-must-remain" + try f.doExec(name, cmd: ["sh", "-c", "echo \(hardlinkMustRemain) > /parent/child/bar"]) + try f.doExec(name, cmd: ["sh", "-c", "ln /parent/child/bar /bar"]) + let symlinkMustRemain = "symlink-must-remain" + try f.doExec(name, cmd: ["sh", "-c", "echo \(symlinkMustRemain) > /parent/child/baz"]) + try f.doExec(name, cmd: ["sh", "-c", "ln /parent/child/baz /baz"]) + + try f.doStop(name) + + let exportPath = f.testDir.appending("export.tar") + try f.doExport(name, to: exportPath) + + let exportURL = URL(filePath: exportPath.string) + let attrs = try FileManager.default.attributesOfItem(atPath: exportPath.string) + let fileSize = attrs[.size] as! UInt64 + #expect(fileSize > 0) + + // TODO: verify foo bar baz are in tar file. + let reader = try ArchiveReader(file: exportURL) + let (foo, fooData) = try reader.extractFile(path: "/foo") + #expect(foo.fileType == .regular) + #expect(String(data: fooData, encoding: .utf8)?.starts(with: mustBeInImage) ?? false) + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLINotFound.swift b/Tests/IntegrationTests/Containers/TestCLINotFound.swift new file mode 100644 index 0000000..6602ed6 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLINotFound.swift @@ -0,0 +1,44 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests that stop, kill, and delete return errors for non-existent containers. +@Suite +struct TestCLINotFound { + + @Test func testStopNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["stop", "does-not-exist"]) + #expect(result.status != 0, "stop should fail for a non-existent container") + } + } + + @Test func testKillNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["kill", "does-not-exist"]) + #expect(result.status != 0, "kill should fail for a non-existent container") + } + } + + @Test func testDeleteNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["delete", "does-not-exist"]) + #expect(result.status != 0, "delete should fail for a non-existent container") + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift b/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift new file mode 100644 index 0000000..c92349d --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIPruneCommandSerial.swift @@ -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 Foundation +import Testing + +/// Serial prune tests — `container prune` affects all stopped containers regardless of name. +@Suite(.serialized) +struct TestCLIPruneCommandSerial { + @Test func testContainerPruneNoContainers() async throws { + try await ContainerFixture.with { f in + // Establish empty state — the serial global pass runs after the concurrent + // pass, and any test that failed mid-cleanup could leave stopped containers + // that would break the "reclaimed zero" assertion. Machine-backing containers + // are excluded from `container delete --all` by design so this doesn't + // interfere with the machine plugin. + _ = try? f.run(["delete", "--all", "--force"]) + + let result = try f.run(["prune"]).check() + #expect(result.error.contains("Reclaimed Zero KB in disk space"), "should show no containers message") + } + } + + @Test func testContainerPruneStoppedContainers() async throws { + try await ContainerFixture.with { f in + let image = ContainerFixture.warmupImages[0] + if try !f.isImagePresent(image) { try f.doPull(image) } + + // One running container that must survive the prune. + try await f.withContainer(image: image, tag: "running", containerArgs: ["sleep", "3600"]) { npcName in + // Two containers to stop and prune. + try await f.withContainer( + image: image, tag: "prune0", containerArgs: ["sleep", "3600"], autoRemove: false + ) { pc0Name in + try await f.withContainer( + image: image, tag: "prune1", containerArgs: ["sleep", "3600"], autoRemove: false + ) { pc1Name in + let pc0Id = try f.getContainerId(pc0Name) + let pc1Id = try f.getContainerId(pc1Name) + + try f.doStop(pc0Name) + try f.doStop(pc1Name) + + // Poll until both containers reach stopped state. + let deadline = Date().addingTimeInterval(30) + while true { + let s0 = try f.getContainerStatus(pc0Name) + let s1 = try f.getContainerStatus(pc1Name) + if s0 == "stopped" && s1 == "stopped" { break } + guard Date() < deadline else { + throw CommandError.executionFailed( + "Timeout waiting for containers to stop: pc0=\(s0), pc1=\(s1)") + } + try await Task.sleep(for: .milliseconds(200)) + } + + let result = try f.run(["prune"]).check() + #expect( + result.output.contains(pc0Id) && result.output.contains(pc1Id), + "prune output should list stopped container IDs") + #expect( + !result.error.contains("Reclaimed Zero KB in disk space"), + "reclaimed space should not be zero") + + let npcStatus = try f.getContainerStatus(npcName) + #expect(npcStatus == "running", "running container should not be pruned") + } + } + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIRemove.swift b/Tests/IntegrationTests/Containers/TestCLIRemove.swift new file mode 100644 index 0000000..9d8f6a8 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIRemove.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Concurrent removal tests — all use testID-scoped names. +@Suite +struct TestCLIRemove { + @Test func testDeleteStopped() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + // create without --rm so the container persists after being stopped + try f.doCreate(name: name, image: image) + try f.doRemove(name) + let result = try f.run(["inspect", name]) + #expect(result.status != 0, "container should not exist after delete") + } + } + + @Test func testDeleteAlias() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + try f.run(["rm", name]).check("rm alias failed") + let result = try f.run(["inspect", name]) + #expect(result.status != 0, "container should not exist after rm") + } + } + + @Test func testDeleteForceRunning() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + try f.doRemove(name, force: true) + let result = try f.run(["inspect", name]) + #expect(result.status != 0, "container should not exist after force delete") + } + } + } + + @Test func testDeleteNoArgs() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["delete"]) + #expect(result.status != 0, "delete with no args should fail") + } + } + + @Test func testDeleteExplicitIdsConflictWithAll() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["delete", "--all", "some-container"]) + #expect(result.status != 0, "delete --all with explicit ID should fail") + #expect(result.error.contains("conflict")) + } + } + + @Test func testDeleteDuplicateIds() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + try f.doCreate(name: name, image: image) + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + let result = try f.run(["delete", name, name]) + #expect(result.status == 0, "delete with duplicate IDs should succeed, stderr: \(result.error)") + let lines = result.output.split(separator: "\n").filter { $0.contains(name) } + #expect(lines.count == 1, "container should be deleted exactly once, got \(lines.count) lines") + } + } + + @Test func testInspectMissingContainerFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["inspect", "definitely-missing-container"]) + #expect(result.status != 0, "inspect of missing container should fail") + #expect(result.error.contains("container not found")) + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIRemoveSerial.swift b/Tests/IntegrationTests/Containers/TestCLIRemoveSerial.swift new file mode 100644 index 0000000..4f72324 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIRemoveSerial.swift @@ -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 Foundation +import Testing + +/// Serial removal tests that use `delete --all` and affect global container state. +@Suite(.serialized) +struct TestCLIRemoveSerial { + @Test func testDeleteAllStopped() async throws { + try await ContainerFixture.with { f in + let image = ContainerFixture.warmupImages[0] + if try !f.isImagePresent(image) { try f.doPull(image) } + let name1 = "\(f.testID)-c1" + let name2 = "\(f.testID)-c2" + try f.doCreate(name: name1, image: image) + f.addCleanup { try f.doRemoveIfExists(name1, ignoreFailure: true) } + try f.doCreate(name: name2, image: image) + f.addCleanup { try f.doRemoveIfExists(name2, ignoreFailure: true) } + + try f.run(["delete", "--all"]).check() + + #expect(try f.run(["inspect", name1]).status != 0, "name1 should be deleted") + #expect(try f.run(["inspect", name2]).status != 0, "name2 should be deleted") + } + } + + @Test func testDeleteAllSkipsRunning() async throws { + try await ContainerFixture.with { f in + let image = ContainerFixture.warmupImages[0] + if try !f.isImagePresent(image) { try f.doPull(image) } + let runningName = "\(f.testID)-running" + let stoppedName = "\(f.testID)-stopped" + + try f.doLongRun(name: runningName, image: image, autoRemove: false) + f.addCleanup { + try? f.doStop(runningName) + try? f.doRemove(runningName) + } + try f.doCreate(name: stoppedName, image: image) + f.addCleanup { try f.doRemoveIfExists(stoppedName, ignoreFailure: true) } + + try f.run(["delete", "--all"]).check() + + #expect(try f.getContainerStatus(runningName) == "running", "running container should survive delete --all") + #expect(try f.run(["inspect", stoppedName]).status != 0, "stopped container should be deleted") + } + } + + @Test func testDeleteAllForce() async throws { + try await ContainerFixture.with { f in + let image = ContainerFixture.warmupImages[0] + if try !f.isImagePresent(image) { try f.doPull(image) } + let name = "\(f.testID)-c" + try f.doLongRun(name: name, image: image, autoRemove: false) + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + + try f.run(["delete", "--all", "--force"]).check() + + #expect(try f.run(["inspect", name]).status != 0, "container should be deleted by --force") + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift new file mode 100644 index 0000000..d7b7562 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIRmRaceCondition.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@Suite +struct TestCLIRmRaceCondition { + @Test func testStopRmRace() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-c" + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + + try f.doCreate(name: name) + try f.doStart(name) + try await f.waitForContainerRunning(name) + try f.doStop(name) + + // Immediately attempt removal — both outcomes are valid: + // 1. Success: race condition prevention working perfectly + // 2. "not yet stopped" error: race detected and controlled + var raceConditionPrevented = false + var raceConditionDetected = false + + do { + try f.doRemove(name) + raceConditionPrevented = true + } catch CommandError.nonZeroExit(_, let message) { + if message.contains("is not yet stopped and can not be deleted") { + raceConditionDetected = true + } else if message.contains("not found") + || message.contains("failed to delete one or more containers") + { + raceConditionPrevented = true + } else { + Issue.record("Unexpected error message: \(message)") + return + } + } catch { + Issue.record("Unexpected error type: \(error)") + return + } + + #expect( + raceConditionPrevented || raceConditionDetected, + "Expected either immediate success (race prevented) or controlled failure (race detected)" + ) + + if raceConditionPrevented { return } + + // Race detected — wait for background cleanup then retry with backoff. + try await Task.sleep(for: .seconds(2)) + + var attempts = 0 + let maxAttempts = 5 + while attempts < maxAttempts { + guard (try? f.getContainerStatus(name)) != nil else { break } + do { + try f.doRemove(name) + break + } catch CommandError.nonZeroExit(_, let message) { + if message.contains("not found") { break } + guard attempts < maxAttempts - 1 else { + throw CommandError.executionFailed( + "Failed to remove container after \(maxAttempts) attempts: \(message)") + } + let delay = 1 << attempts + try await Task.sleep(for: .seconds(delay)) + attempts += 1 + } catch { + guard attempts < maxAttempts - 1 else { throw error } + let delay = 1 << attempts + try await Task.sleep(for: .seconds(delay)) + attempts += 1 + } + } + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift b/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift new file mode 100644 index 0000000..3aea626 --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIStatsCommand.swift @@ -0,0 +1,112 @@ +//===----------------------------------------------------------------------===// +// 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 +import Testing + +@Suite +struct TestCLIStatsCommand { + @Test func testStatsNoStreamJSONFormat() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["stats", "--format", "json", "--no-stream", name]).check() + let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData) + #expect(stats.count == 1, "expected stats for one container") + #expect(stats[0].id == name, "container ID should match") + let memoryUsageBytes = try #require(stats[0].memoryUsageBytes) + let numProcesses = try #require(stats[0].numProcesses) + #expect(memoryUsageBytes > 0, "memory usage should be non-zero") + #expect(numProcesses >= 1, "should have at least one process") + } + } + } + + @Test func testStatsIdleCPUPercentage() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, containerArgs: ["sleep", "3600"]) { name in + let result = try f.run(["stats", "--no-stream", name]).check() + let lines = result.output.components(separatedBy: .newlines) + #expect(lines.count >= 2, "should have at least header and one data row") + let dataLine = try #require(lines.first { $0.contains(name) }, "should find container data row") + let columns = dataLine.split(separator: " ").filter { !$0.isEmpty } + #expect(columns.count >= 2, "should have at least 2 columns") + let cpuString = String(columns[1]) + #expect(cpuString.hasSuffix("%"), "CPU column should end with %") + let cpuValue = try #require(Double(cpuString.dropLast()), "should parse CPU percentage") + #expect(cpuValue < 5.0, "idle container CPU should be < 5%, got \(cpuValue)%") + } + } + } + + @Test func testStatsHighCPUPercentage() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, containerArgs: ["sh", "-c", "while true; do :; done"]) { name in + let result = try f.run(["stats", "--no-stream", name]).check() + let lines = result.output.components(separatedBy: .newlines) + #expect(lines.count >= 2, "should have at least header and one data row") + let dataLine = try #require(lines.first { $0.contains(name) }, "should find container data row") + let columns = dataLine.split(separator: " ").filter { !$0.isEmpty } + #expect(columns.count >= 2, "should have at least 2 columns") + let cpuString = String(columns[1]) + #expect(cpuString.hasSuffix("%"), "CPU column should end with %") + let cpuValue = try #require(Double(cpuString.dropLast()), "should parse CPU percentage") + #expect(cpuValue > 50.0, "busy container CPU should be > 50%, got \(cpuValue)%") + #expect(cpuValue < 150.0, "single busy loop should not exceed 150%, got \(cpuValue)%") + } + } + } + + @Test func testStatsTableFormat() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["stats", "--no-stream", name]).check() + #expect(result.output.contains("Container ID"), "output should contain table header") + #expect(result.output.contains("Cpu %"), "output should contain CPU column") + #expect(result.output.contains("Memory Usage"), "output should contain Memory column") + #expect(result.output.contains(name), "output should contain container name") + } + } + } + + @Test func testStatsAllContainers() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + // Run two containers simultaneously so both appear in the global stats snapshot. + try await f.withContainer(image: image, tag: "c1") { name1 in + try await f.withContainer(image: image, tag: "c2") { name2 in + let result = try f.run(["stats", "--format", "json", "--no-stream"]).check() + let stats = try JSONDecoder().decode([ContainerStats].self, from: result.outputData) + try #require(stats.count >= 2, "should have stats for at least 2 containers") + let ids = stats.map { $0.id } + #expect(ids.contains(name1), "should include first container") + #expect(ids.contains(name2), "should include second container") + } + } + } + } + + @Test func testStatsNonExistentContainer() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["stats", "--no-stream", "nonexistent-container-xyz"]) + #expect(result.status != 0, "stats should fail for non-existent container") + } + } +} diff --git a/Tests/IntegrationTests/Containers/TestCLIStop.swift b/Tests/IntegrationTests/Containers/TestCLIStop.swift new file mode 100644 index 0000000..786589c --- /dev/null +++ b/Tests/IntegrationTests/Containers/TestCLIStop.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@Suite +struct TestCLIStop { + @Test func testStopWithExplicitSignal() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + try f.doStop(name, signal: "SIGTERM") + #expect(try f.getContainerStatus(name) == "stopped") + } + } + } + + @Test func testStopWithoutSignal() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + try f.doStop(name, signal: nil) + #expect(try f.getContainerStatus(name) == "stopped") + } + } + } + + @Test func testStopSignalInInspect() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + let inspect = try f.inspectContainer(name) + // Alpine doesn't set a STOPSIGNAL, so this should be nil. + #expect(inspect.configuration.stopSignal == nil) + } + } + } + + @Test func testStopIdempotent() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image, autoRemove: false) { name in + try f.doStop(name, signal: "SIGKILL") + #expect(try f.getContainerStatus(name) == "stopped") + // Stopping an already-stopped container should not fail. + try f.doStop(name, signal: "SIGKILL") + } + } + } +} diff --git a/Tests/IntegrationTests/Images/TestCLIImagePruneSerial.swift b/Tests/IntegrationTests/Images/TestCLIImagePruneSerial.swift new file mode 100644 index 0000000..a962562 --- /dev/null +++ b/Tests/IntegrationTests/Images/TestCLIImagePruneSerial.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +private let alpine = ContainerFixture.warmupImages[0] +private let busybox = ContainerFixture.warmupImages[2] + +/// Serial tests for `image prune` and `--max-concurrent-downloads`. +/// These use `image rm --all` which affects global state. +@Suite(.serialized) +struct TestCLIImagePruneSerial { + + @Test func testImageSingleConcurrentDownload() async throws { + try await ContainerFixture.with { f in + _ = try? f.run(["image", "rm", alpine]) + f.addCleanup { try? f.doRemoveImages() } + try f.doPull(alpine, args: ["--max-concurrent-downloads", "1"]) + let present = try f.isImagePresent(alpine) + #expect(present, "expected image to be pulled with --max-concurrent-downloads 1") + } + } + + @Test func testImageManyConcurrentDownloads() async throws { + try await ContainerFixture.with { f in + _ = try? f.run(["image", "rm", alpine]) + f.addCleanup { try? f.doRemoveImages() } + try f.doPull(alpine, args: ["--max-concurrent-downloads", "64"]) + let present = try f.isImagePresent(alpine) + #expect(present, "expected image to be pulled with --max-concurrent-downloads 64") + } + } + + @Test func testImagePruneNoImages() async throws { + try await ContainerFixture.with { f in + try? f.doRemoveImages() + let result = try f.run(["image", "prune"]).check() + #expect(result.error.contains("Zero KB"), "should show no space reclaimed") + } + } + + @Test func testImagePruneUnusedImages() async throws { + try await ContainerFixture.with { f in + _ = try? f.run(["delete", "--all", "--force"]) + try? f.doRemoveImages() + f.addCleanup { try? f.doRemoveImages() } + + try f.doPull(alpine) + try f.doPull(busybox) + #expect(try f.isImagePresent(alpine), "expected \(alpine) to be pulled") + #expect(try f.isImagePresent(busybox), "expected \(busybox) to be pulled") + + let result = try f.run(["image", "prune", "-a"]).check() + #expect(result.output.contains(alpine), "should prune alpine image") + #expect(result.output.contains(busybox), "should prune busybox image") + + #expect(try !f.isImagePresent(alpine), "expected \(alpine) to be removed") + #expect(try !f.isImagePresent(busybox), "expected \(busybox) to be removed") + } + } + + @Test func testImagePruneDanglingImages() async throws { + try await ContainerFixture.with { f in + let containerName = "\(f.testID)-c" + _ = try? f.run(["delete", "--all", "--force"]) + try? f.doRemoveImages() + f.addCleanup { + try? f.doStop(containerName) + try? f.doRemove(containerName) + try? f.doRemoveImages() + } + + try f.doPull(alpine) + try f.doPull(busybox) + #expect(try f.isImagePresent(alpine), "expected \(alpine) to be pulled") + #expect(try f.isImagePresent(busybox), "expected \(busybox) to be pulled") + + // Keep alpine in use via a running container. + try f.doLongRun(name: containerName, image: alpine, autoRemove: false) + try await f.waitForContainerRunning(containerName) + + let result = try f.run(["image", "prune", "-a"]).check() + #expect(result.output.contains(busybox), "should prune busybox image") + #expect(try !f.isImagePresent(busybox), "expected \(busybox) to be removed") + #expect(try f.isImagePresent(alpine), "expected \(alpine) to remain (in use)") + } + } +} diff --git a/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift b/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift new file mode 100644 index 0000000..862705e --- /dev/null +++ b/Tests/IntegrationTests/Images/TestCLIImagesCommand.swift @@ -0,0 +1,338 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerizationArchive +import ContainerizationOCI +import Foundation +import Testing + +@Suite +struct TestCLIImagesCommand { + private let alpine = ContainerFixture.warmupImages[0] // ghcr.io/linuxcontainers/alpine:3.20 + private let alpine318 = ContainerFixture.warmupImages[1] // ghcr.io/linuxcontainers/alpine:3.18 + private let busybox = ContainerFixture.warmupImages[2] // ghcr.io/containerd/busybox:1.36 + + /// Host architecture string for platform tests. + private var hostArchitecture: String { + #if arch(arm64) + return "arm64" + #else + return "amd64" + #endif + } + + @Test func testPull() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + #expect(try f.isImagePresent(alpine), "expected \(alpine) to be present") + } + } + + @Test func testPullMulti() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + try f.doPull(busybox) + #expect(try f.isImagePresent(alpine), "expected \(alpine) to be present") + #expect(try f.isImagePresent(busybox), "expected \(busybox) to be present") + } + } + + @Test func testPullPlatform() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = "amd64" + try f.doPull(alpine, args: ["--platform", "\(os)/\(arch)"]) + let output = try f.doInspectImages(alpine) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch) in \(output[0])") + } + } + + @Test func testPullOsArch() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = "amd64" + try f.doPull(alpine318, args: ["--os", os, "--arch", arch]) + let output = try f.doInspectImages(alpine318) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch)") + } + } + + @Test func testPullOs() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = hostArchitecture + try f.doPull(alpine318, args: ["--os", os]) + let output = try f.doInspectImages(alpine318) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch)") + } + } + + @Test func testPullArch() async throws { + try await ContainerFixture.with { f in + let os = "linux" + let arch = "amd64" + try f.doPull(alpine318, args: ["--arch", arch]) + let output = try f.doInspectImages(alpine318) + #expect(output.count == 1) + #expect( + output[0].variants.contains { $0.platform.os == os && $0.platform.architecture == arch }, + "expected variant for \(os)/\(arch)") + } + } + + @Test func testPullRemoveSingle() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + #expect(try f.isImagePresent(alpine)) + let tagged = "\((try Reference.parse(alpine)).name):testPullRemoveSingle" + try f.doImageTag(alpine, newName: tagged) + #expect(try f.isImagePresent(tagged)) + try f.doRemoveImages([tagged]) + #expect(!(try f.isImagePresent(tagged)), "expected \(tagged) to be removed") + } + } + + @Test func testImageTag() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageTag" + try f.doImageTag(alpine, newName: tagged) + #expect(try f.isImagePresent(tagged)) + } + } + + @Test func testImageSaveAndLoad() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + try f.doPull(busybox) + + let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoad" + let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoad" + try f.doImageTag(alpine, newName: alpineTagged) + try f.doImageTag(busybox, newName: busyboxTagged) + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + + let tempFile = f.testDir.appending("save-\(UUID().uuidString).tar") + try f.run(["image", "save", alpineTagged, busyboxTagged, "--output", tempFile.string]).check() + + try f.doRemoveImages([alpineTagged, busyboxTagged]) + #expect(!(try f.isImagePresent(alpineTagged))) + #expect(!(try f.isImagePresent(busyboxTagged))) + + try f.run(["image", "load", "-i", tempFile.string]).check() + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + } + } + + @Test func testImageSaveToStdoutProducesCleanArchive() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageSaveToStdout" + try f.doImageTag(alpine, newName: tagged) + f.addCleanup { try? f.doRemoveImages([tagged]) } + + let result = try f.run(["image", "save", tagged]) + try result.check("save to stdout failed") + + #expect(result.outputData.count >= 1024, "stdout archive too small to contain tar EOF marker") + let trailer = result.outputData.suffix(1024) + #expect(trailer.allSatisfy { $0 == 0 }, "stdout archive has trailing non-archive bytes after tar EOF marker") + #expect(result.error.contains(tagged), "expected saved image reference on stderr") + } + } + + @Test func testImageSaveMissingPlatform() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageSaveMissingPlatform" + try f.doImageTag(alpine, newName: tagged) + f.addCleanup { try? f.doRemoveImages([tagged]) } + + let tempFile = f.testDir.appending("save-missing.tar") + let result = try f.run([ + "image", "save", tagged, + "--platform", "linux/arm/v5", + "--output", tempFile.string, + ]) + #expect(result.status != 0, "expected save to fail for missing platform") + #expect(result.error.contains("has no content for platform")) + #expect(result.error.contains("available platforms:")) + } + } + + @Test func testMaxConcurrentDownloadsValidation() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--max-concurrent-downloads", "0", "alpine:latest"]) + #expect(result.status != 0) + #expect(result.error.contains("maximum number of concurrent downloads must be greater than 0")) + } + } + + @Test func testImageLoadRejectsInvalidMembersWithoutForce() async throws { + try await ContainerFixture.with { f in + let maliciousFilename = "pwned-\(UUID().uuidString).txt" + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageLoadRejectsInvalidMembers" + try f.doImageTag(alpine, newName: tagged) + #expect(try f.isImagePresent(tagged)) + + let tempFile = f.testDir.appending("save.tar") + try f.run(["image", "save", tagged, "--output", tempFile.string]).check() + try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename) + + try f.doRemoveImages([tagged]) + #expect(!(try f.isImagePresent(tagged))) + + let loadResult = try f.run(["image", "load", "-i", tempFile.string]) + #expect(loadResult.status != 0, "expected load to fail without force flag") + #expect(loadResult.error.contains("rejected paths") || loadResult.error.contains(maliciousFilename)) + #expect( + !FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"), + "malicious file should not have been created") + } + } + + @Test func testImageLoadAcceptsInvalidMembersWithForce() async throws { + try await ContainerFixture.with { f in + let maliciousFilename = "pwned-\(UUID().uuidString).txt" + try f.doPull(alpine) + let tagged = "\((try Reference.parse(alpine)).name):testImageLoadAcceptsInvalidMembers" + try f.doImageTag(alpine, newName: tagged) + f.addCleanup { try? f.doRemoveImages([tagged]) } + + let tempFile = f.testDir.appending("save.tar") + try f.run(["image", "save", tagged, "--output", tempFile.string]).check() + try addInvalidMemberToTar(tarPath: tempFile.string, maliciousFilename: maliciousFilename) + + try f.doRemoveImages([tagged]) + let loadResult = try f.run(["image", "load", "-i", tempFile.string, "--force"]) + #expect(loadResult.status == 0, "expected load to succeed with force flag") + #expect(loadResult.error.contains("invalid members") || loadResult.error.contains(maliciousFilename)) + #expect(try f.isImagePresent(tagged)) + #expect( + !FileManager.default.fileExists(atPath: "/tmp/\(maliciousFilename)"), + "malicious file should not have been created") + } + } + + @Test func testImageSaveAndLoadStdinStdout() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + try f.doPull(busybox) + + let alpineTagged = "\((try Reference.parse(alpine)).name):testImageSaveAndLoadStdinStdout" + let busyboxTagged = "\((try Reference.parse(busybox)).name):testImageSaveAndLoadStdinStdout" + try f.doImageTag(alpine, newName: alpineTagged) + try f.doImageTag(busybox, newName: busyboxTagged) + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + + let saveResult = try f.run(["image", "save", alpineTagged, busyboxTagged]).check() + try f.doRemoveImages([alpineTagged, busyboxTagged]) + #expect(!(try f.isImagePresent(alpineTagged))) + #expect(!(try f.isImagePresent(busyboxTagged))) + + try f.run(["image", "load"], stdin: saveResult.outputData).check() + #expect(try f.isImagePresent(alpineTagged)) + #expect(try f.isImagePresent(busyboxTagged)) + } + } + + @Test func testImageVariantSizeFieldExists() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let result = try f.run(["image", "ls", "--format", "json"]).check() + guard let json = try JSONSerialization.jsonObject(with: result.outputData) as? [[String: Any]], + let image = json.first + else { + Issue.record("failed to parse image list JSON or no images found") + return + } + let variants = image["variants"] as? [[String: Any]] ?? [] + #expect(!variants.isEmpty, "expected at least one variant") + #expect( + variants.contains { ($0["size"] as? Int ?? 0) > 0 }, + "expected at least one variant with non-zero size") + } + } + + @Test func testImageListTableFormat() async throws { + try await ContainerFixture.with { f in + try f.doPull(alpine) + let result = try f.run(["image", "ls"]).check() + #expect(["NAME", "TAG", "DIGEST"].allSatisfy { result.output.contains($0) }) + #expect(result.output.contains("alpine")) + } + } + + @Test func testInspectMissingImageFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "inspect", "definitely-missing-image:latest"]) + #expect(result.status != 0) + #expect(result.error.contains("image not found")) + } + } + + @Test func testImageLoadMissingFileErrorToStderr() async throws { + try await ContainerFixture.with { f in + let missingPath = "/path/that/does/not/exist-\(UUID().uuidString)" + let result = try f.run(["image", "load", "-i", missingPath]) + #expect(result.status != 0) + #expect(result.output.isEmpty, "stdout should be empty") + #expect(result.error.contains("file does not exist") && result.error.contains(missingPath)) + } + } + + // MARK: - Private helpers + + private func addInvalidMemberToTar(tarPath: String, maliciousFilename: String) throws { + let evilEntryName = "../../../../../../../../../../../tmp/\(maliciousFilename)" + let evilEntryContent = "pwned\n".data(using: .utf8)! + let tempModifiedTar = URL(filePath: tarPath + ".modified") + + let writer = try ArchiveWriter(format: .pax, filter: .none, file: tempModifiedTar) + let reader = try ArchiveReader(file: URL(fileURLWithPath: tarPath)) + for (entry, data) in reader { + if entry.fileType == .regular { + try writer.writeEntry(entry: entry, data: data) + } else { + try writer.writeEntry(entry: entry, data: nil) + } + } + let evilEntry = WriteEntry() + evilEntry.path = evilEntryName + evilEntry.size = Int64(evilEntryContent.count) + evilEntry.modificationDate = Date() + evilEntry.fileType = .regular + evilEntry.permissions = 0o644 + try writer.writeEntry(entry: evilEntry, data: evilEntryContent) + try writer.finishEncoding() + + try FileManager.default.removeItem(atPath: tarPath) + try FileManager.default.moveItem(at: tempModifiedTar, to: URL(fileURLWithPath: tarPath)) + } +} diff --git a/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift b/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift new file mode 100644 index 0000000..c036377 --- /dev/null +++ b/Tests/IntegrationTests/Images/TestCLIProgressAuto.swift @@ -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 Testing + +@Suite +struct TestCLIProgressAuto { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testAutoProgressFallsBackToPlainWhenPiped() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "auto", alpine]) + #expect(result.status == 0, "image pull should succeed, stderr: \(result.error)") + let lines = result.error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr when piped") + #expect(!result.error.contains("\u{1B}["), "expected no ANSI escapes in piped output") + } + } + + @Test func testExplicitPlainProgress() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "plain", alpine]) + #expect( + result.status == 0, + "image pull --progress plain should succeed, stderr: \(result.error)") + let lines = result.error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(!lines.isEmpty, "expected plain progress output on stderr") + #expect(!result.error.contains("\u{1B}["), "expected no ANSI escapes with --progress plain") + } + } + + @Test func testExplicitAnsiProgress() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "ansi", alpine]) + // Verify the command succeeds; ANSI output is suppressed in non-TTY contexts + // so we don't assert on stderr content here. + #expect( + result.status == 0, + "image pull --progress ansi should succeed, stderr: \(result.error)") + } + } + + @Test func testNoneProgressSuppressesOutput() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["image", "pull", "--progress", "none", alpine]) + #expect( + result.status == 0, + "image pull --progress none should succeed, stderr: \(result.error)") + let lines = result.error.components(separatedBy: .newlines) + .filter { !$0.contains("Warning! Running debug build") && !$0.isEmpty } + #expect(lines.isEmpty, "expected no progress output on stderr with --progress none") + } + } +} diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift new file mode 100644 index 0000000..657124d --- /dev/null +++ b/Tests/IntegrationTests/Machine/TestCLIMachineCommand.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// 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 Containerization +import Foundation +import MachineAPIClient +import Testing + +@Suite +struct TestCLIMachineCommand { + private let machineImage = "ghcr.io/linuxcontainers/alpine:3.20" + + @Test func testCreate() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineRemove(name: name) + } + } + + @Test func testCreateRejectsDots() async throws { + try await ContainerFixture.with { f in + let result = try f.runMachine(["create", "--name", "my.bad.name", machineImage]) + #expect(result.status != 0, "create should reject names with dots") + #expect(result.error.contains("must start and end"), "error should explain the constraint") + } + } + + @Test func testCreateNameLongestValid() async { + await withKnownIssue("XPC timeout on machine-apiserver.bootMachine", isIntermittent: true) { + try await ContainerFixture.with { f in + let maxNameLength = LinuxContainer.maxIDLength - MachineConfiguration.containerUUIDLength - 1 + let name = String(repeating: "a", count: maxNameLength) + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + } + } + } + + @Test func testCreateNameLongerThanMax() async throws { + try await ContainerFixture.with { f in + let maxNameLength = LinuxContainer.maxIDLength - MachineConfiguration.containerUUIDLength - 1 + let name = String(repeating: "a", count: maxNameLength + 1) + let result = try f.runMachine(["create", "--no-boot", "--name", name, machineImage]) + #expect(result.status != 0, "create should reject names longer than max") + } + } +} diff --git a/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift new file mode 100644 index 0000000..fe219b7 --- /dev/null +++ b/Tests/IntegrationTests/Machine/TestCLIMachineRuntimeSerial.swift @@ -0,0 +1,907 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Integration tests for container machine runtime commands: stop, inspect, run, set. +/// Serialized because VM operations share system resources (memory, CPU) and concurrent +/// VMs could interfere with each other or exhaust available resources. +@Suite(.serialized) +struct TestCLIMachineRuntimeSerial { + private let machineImage = "ghcr.io/linuxcontainers/alpine:3.20" + + // MARK: - Stop tests + + @Test func testStopRunningMachine() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let before = try f.doMachineInspect(name: name) + #expect(before.startedDate != nil, "running machine should have startedDate") + + let output = try f.doMachineStop(name: name) + #expect(output == name, "stop should output the machine name") + + let after = try f.doMachineInspect(name: name) + #expect(after.status == "stopped") + #expect(after.startedDate == nil, "stopped machine should not have startedDate") + } + } + + @Test func testStopIdempotent() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let output = try f.doMachineStop(name: name) + #expect(output == name, "stop on already-stopped machine should succeed") + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "stopped") + } + } + + @Test func testStopNonExistentMachine() async throws { + try await ContainerFixture.with { f in + let name = "nonexistent-\(f.testID)" + let result = try f.runMachine(["stop", name]) + #expect(result.status != 0) + #expect(result.error.contains("not found")) + } + } + + // MARK: - Inspect tests + + @Test func testInspectStoppedMachine() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "stopped") + #expect(snapshot.startedDate == nil) + #expect(snapshot.id == name) + #expect(snapshot.image.reference.contains("alpine")) + #expect(snapshot.createdDate != nil) + #expect(snapshot.ipAddress == nil) + #expect(snapshot.cpus > 0) + #expect(snapshot.memory > 0) + } + } + + @Test func testInspectRunningMachine() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "running") + #expect(snapshot.startedDate != nil) + #expect(snapshot.id == name) + #expect(snapshot.platform.os == "linux") + #expect(snapshot.ipAddress != nil) + } + } + + @Test func testInspectNonExistentMachine() async throws { + try await ContainerFixture.with { f in + let name = "nonexistent-\(f.testID)" + let result = try f.runMachine(["inspect", name]) + #expect(result.status != 0) + #expect(result.error.contains("not found")) + } + } + + // MARK: - Run tests + + @Test func testRunSimpleCommand() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let output = try f.doMachineRun(name: name, root: true, command: ["echo", "hello"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } + + @Test func testRunAutoBoots() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let before = try f.doMachineInspect(name: name) + #expect(before.status == "stopped") + + let output = try f.doMachineRun(name: name, root: true, command: ["echo", "autoboot"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "autoboot") + + let after = try f.doMachineInspect(name: name) + #expect(after.status == "running") + } + } + + @Test func testRunAsRoot() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let uid = try f.doMachineRun(name: name, root: true, command: ["id", "-u"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uid == "0", "running with --root should execute as uid 0") + } + } + + @Test func testRunAsHostUser() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let hostUid = getuid() + let uid = try f.doMachineRun(name: name, command: ["id", "-u"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uid == "\(hostUid)", "default run should use host user's UID") + } + } + + @Test func testRunWithEnvironment() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let output = try f.doMachineRun( + name: name, root: true, env: ["MY_VAR=hello_world"], + command: ["echo", "$MY_VAR"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello_world") + } + } + + @Test func testRunWithCwd() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let output = try f.doMachineRun(name: name, root: true, cwd: "/tmp", command: ["pwd"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "/tmp") + } + } + + @Test func testRunNonExistentMachine() async throws { + try await ContainerFixture.with { f in + let name = "nonexistent-\(f.testID)" + let result = try f.runMachine(["run", "-n", name, "echo", "test"]) + #expect(result.status != 0) + #expect(result.error.contains("not found")) + } + } + + @Test func testRunDefaultHostsEntries() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let inspect = try f.doMachineInspect(name: name) + let ip = try #require(inspect.ipAddress, "running machine should have an IP address") + + let output = try f.doMachineRun(name: name, root: true, command: ["cat", "/etc/hosts"]) + let lines = output.split(separator: "\n") + let expectedEntries = [("127.0.0.1", "localhost"), (ip, name)] + for (i, line) in lines.enumerated() { + guard i < expectedEntries.count else { break } + let words = line.split(separator: " ").map { String($0) } + #expect(words.count >= 2) + #expect(expectedEntries[i].0 == words[0]) + #expect(expectedEntries[i].1 == words[1]) + } + } + } + + @Test func testRunCommandInShell() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let output = try f.doMachineRun(name: name, root: true, command: ["echo", "$0"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "/bin/sh", "alpine shell should expand $0 to /bin/sh") + } + } + + @Test func testRunCommandExitCode() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["run", "-n", name, "--root", "exit", "42"]) + #expect(result.status == 42, "exit code should propagate from command") + } + } + + @Test func testRunMultipleEnvVars() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine([ + "run", "-n", name, "--root", + "-e", "VAR1=one", "-e", "VAR2=two", "-e", "VAR3=three", + "echo", "$VAR1-$VAR2-$VAR3", + ]) + #expect(result.status == 0) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "one-two-three") + } + } + + @Test func testRunWithUid() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["run", "-n", name, "--uid", "1000", "id", "-u"]) + #expect(result.status == 0) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "1000") + } + } + + @Test func testRunWithGid() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["run", "-n", name, "--root", "--gid", "1000", "id", "-G"]) + #expect(result.status == 0) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "0 1000") + } + } + + @Test func testRunWithUserFlag() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine([ + "run", "-n", name, "--user", "1000:1000", + "echo", "$(id -u):$(id -g)", + ]) + #expect(result.status == 0) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "1000:1000") + } + } + + @Test func testRunWithEnvFile() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let envFile = f.testDir.appending("test.env") + try "TEST_VAR=from_file\n".write(toFile: envFile.string, atomically: true, encoding: .utf8) + + let result = try f.runMachine([ + "run", "-n", name, "--root", "--env-file", envFile.string, "echo", "$TEST_VAR", + ]) + #expect(result.status == 0) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "from_file") + } + } + + // MARK: - List tests + + @Test func testListRunningMachines() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["ls"]).check() + #expect(result.output.contains(name)) + } + } + + @Test func testListAllMachines() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let result = try f.runMachine(["ls"]).check() + #expect(result.output.contains(name), "stopped machine should appear in list") + } + } + + @Test func testListQuietMode() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["ls", "-q"]).check() + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == name) + } + } + + @Test func testListJsonFormat() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["ls", "--format", "json"]).check() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let items = try decoder.decode([MachineListItem].self, from: result.outputData) + let item = items.first { $0.id == name } + try #require(item != nil) + #expect(item?.status == "running") + #expect(item?.ipAddress != nil) + #expect(item?.cpus ?? 0 > 0) + #expect(item?.memory ?? 0 > 0) + #expect(item?.createdDate != nil) + } + } + + // MARK: - set-default tests + + @Test func testSetDefault() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let result = try f.runMachine(["set-default", name]).check() + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == name) + + let list = try f.runMachine(["ls"]).check() + #expect(list.output.contains("*"), "machine should be marked as default in list") + } + } + + @Test func testSetDefaultNonExistent() async throws { + try await ContainerFixture.with { f in + let result = try f.runMachine(["set-default", "nonexistent-\(f.testID)"]) + #expect(result.status != 0) + #expect(result.error.contains("not found")) + } + } + + @Test func testSetDefaultSwitching() async throws { + try await ContainerFixture.with { f in + let name1 = "\(f.testID)-m1" + let name2 = "\(f.testID)-m2" + f.addCleanup { + f.cleanupMachine(name1) + f.cleanupMachine(name2) + } + try f.doMachineCreate(name: name1, image: machineImage) + try f.doMachineCreate(name: name2, image: machineImage) + + try f.runMachine(["set-default", name1]).check() + let result = try f.runMachine(["set-default", name2]).check() + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == name2) + + let snapshot = try f.doMachineInspect() + #expect(snapshot.id == name2, "inspect without ID should use the new default") + } + } + + @Test func testInspectUsesDefault() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.runMachine(["set-default", name]).check() + + let snapshot = try f.doMachineInspect() + #expect(snapshot.id == name) + } + } + + @Test func testRunUsesDefault() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.runMachine(["set-default", name]).check() + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["run", "--root", "echo", "default-test"]) + #expect(result.status == 0) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) == "default-test") + } + } + + // MARK: - User / home tests + + @Test func testFirstBootCreatesSudoersEntry() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let username = NSUserName() + let output = try f.doMachineRun( + name: name, root: true, + command: ["cat", "/etc/sudoers.d/\(username)"] + ) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "\(username) ALL=(ALL) NOPASSWD:ALL") + } + } + + @Test func testUserSetupIdempotent() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let uid1 = try f.doMachineRun(name: name, command: ["id", "-u"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let uid2 = try f.doMachineRun(name: name, command: ["id", "-u"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(uid1 == uid2) + #expect(uid1 == "\(getuid())") + } + } + + @Test func testHostUserHasCorrectHome() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let home = try f.doMachineRun(name: name, command: ["echo", "$HOME"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(home == "/home/\(NSUserName())") + } + } + + // MARK: - set tests + + @Test func testSetCpus() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + try f.runMachine(["set", "--name", name, "cpus=4"]).check() + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.cpus == 4) + } + } + + @Test func testSetMemory() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + try f.runMachine(["set", "--name", name, "memory=8G"]).check() + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.memory == UInt64(8 * 1024 * 1024 * 1024)) + } + } + + @Test func testSetMultiple() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + try f.runMachine(["set", "--name", name, "cpus=2", "memory=4G"]).check() + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.cpus == 2) + #expect(snapshot.memory == UInt64(4 * 1024 * 1024 * 1024)) + } + } + + @Test func testSetInvalidKey() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let result = try f.runMachine(["set", "--name", name, "bogus=value"]) + #expect(result.status != 0) + } + } + + @Test func testSetRunningWarning() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let result = try f.runMachine(["set", "--name", name, "cpus=2"]) + #expect(result.status == 0) + #expect(result.error.contains("will take effect")) + } + } + + @Test func testSetHomeMount() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + try f.runMachine(["set", "--name", name, "home-mount=ro"]).check() + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.homeMount == "ro") + } + } + + @Test func testSetHomeMountInvalid() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + let result = try f.runMachine(["set", "--name", name, "home-mount=badvalue"]) + #expect(result.status != 0) + } + } + + // MARK: - Create config flag tests + + @Test func testCreateWithCpus() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage, extraArgs: ["--cpus", "2"]) + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.cpus == 2) + } + } + + @Test func testGuestCpuCountMatchesRequested() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage, extraArgs: ["--cpus", "2"]) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let guestCpus = try f.doMachineRun(name: name, root: true, command: ["nproc"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(guestCpus == "2") + } + } + + @Test func testCreateWithMemory() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage, extraArgs: ["--memory", "2G"]) + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.memory == UInt64(2 * 1024 * 1024 * 1024)) + } + } + + @Test func testCreateWithHomeMount() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage, extraArgs: ["--home-mount", "none"]) + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.homeMount == "none") + } + } + + @Test func testCreateAutoBoots() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + // Omit --no-boot so the machine boots automatically. + try f.runMachine(["create", "--name", name, machineImage]).check() + + let snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "running") + #expect(snapshot.startedDate != nil) + } + } + + @Test func testAmd64PlatformSupported() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate( + name: name, image: "alpine:3.22", + extraArgs: ["--platform", "linux/amd64"]) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let arch = try f.doMachineRun(name: name, root: true, command: ["uname", "-m"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(arch == "x86_64") + } + } + + // MARK: - Logs tests + + @Test func testLogs() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + try f.doMachineStop(name: name) + + let boot = try f.runMachine(["logs", "--boot", name]).check() + #expect(!boot.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + let stdio = try f.runMachine(["logs", name]) + #expect(stdio.status == 0) + } + } + + @Test func testLogsWhileRunning() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let boot = try f.runMachine(["logs", "--boot", name]).check() + #expect(!boot.output.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + let stdio = try f.runMachine(["logs", name]) + #expect(stdio.status == 0) + } + } + + @Test func testLogsNonExistentMachine() async throws { + try await ContainerFixture.with { f in + let result = try f.runMachine(["logs", "nonexistent-\(f.testID)"]) + #expect(result.status != 0) + #expect(result.error.contains("not found")) + } + } + + // MARK: - Machine isolation from container commands + + @Test func testMachineNotInContainerList() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let inspect = try f.doMachineInspect(name: name) + let containerId = try #require(inspect.containerId) + + let running = try f.run(["ls", "-q"]).check() + #expect(!running.output.contains(containerId)) + + let all = try f.run(["ls", "-a", "-q"]).check() + #expect(!all.output.contains(containerId)) + } + } + + @Test func testMachineNotDeletedByRmAll() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let inspect = try f.doMachineInspect(name: name) + let containerId = try #require(inspect.containerId) + + try f.run(["delete", "--all"]).check() + + let containerInspect = try f.inspectContainer(containerId) + #expect( + containerInspect.status.state == "running", + "machine container should survive 'container delete --all'") + } + } + + @Test func testMachineNotKilledByKillAll() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let inspect = try f.doMachineInspect(name: name) + let containerId = try #require(inspect.containerId) + + try f.run(["kill", "--all"]).check() + + let containerInspect = try f.inspectContainer(containerId) + #expect( + containerInspect.status.state == "running", + "machine container should survive 'container kill --all'") + } + } + + @Test func testMachineNotStoppedByStopAll() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let inspect = try f.doMachineInspect(name: name) + let containerId = try #require(inspect.containerId) + + try f.run(["stop", "--all"]).check() + + let containerInspect = try f.inspectContainer(containerId) + #expect( + containerInspect.status.state == "running", + "machine container should survive 'container stop --all'") + } + } + + @Test func testContainerExitState() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + + let inspect = try f.doMachineInspect(name: name) + let containerId = try #require( + inspect.containerId, + "running machine should have a containerId") + + try f.doStop(containerId) + try await f.waitForMachineStatus(name, status: "stopped") + + let after = try f.doMachineInspect(name: name) + #expect(after.status == "stopped") + #expect(after.containerId == nil) + #expect(after.startedDate == nil) + } + } + + // MARK: - Lifecycle test + + @Test func testFullLifecycle() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + try f.doMachineCreate(name: name, image: machineImage) + + var snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "stopped") + + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "running") + + let output = try f.doMachineRun(name: name, root: true, command: ["hostname"]) + #expect(!output.isEmpty) + + try f.doMachineStop(name: name) + snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "stopped") + + try f.doMachineBoot(name: name) + try await f.waitForMachineStatus(name, status: "running") + snapshot = try f.doMachineInspect(name: name) + #expect(snapshot.status == "running") + } + } + + // MARK: - SSH forwarding + + @Test func testSSHForwarding() async throws { + try await ContainerFixture.with { f in + let name = "\(f.testID)-machine" + f.addCleanup { f.cleanupMachine(name) } + + let socketPath = try f.makeFakeSSHAgentSocket() + try await Task.sleep(for: .seconds(1)) + + try f.doMachineCreate(name: name, image: machineImage) + try f.runMachine( + ["run", "--root", "-n", name, "true"], + env: ["SSH_AUTH_SOCK": socketPath] + ).check() + try await f.waitForMachineStatus(name, status: "running") + + let sockValue = try f.doMachineRun( + name: name, root: true, + command: ["echo", "$SSH_AUTH_SOCK"] + ) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(sockValue == "/var/host-services/ssh-auth.sock") + + let exists = try f.doMachineRun( + name: name, root: true, + command: [ + "[ -S /var/host-services/ssh-auth.sock ]", "&&", "echo", "exists", "||", "echo", "missing", + ] + ).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(exists == "exists") + } + } +} diff --git a/Tests/IntegrationTests/Network/TestCLINetwork.swift b/Tests/IntegrationTests/Network/TestCLINetwork.swift new file mode 100644 index 0000000..5eec539 --- /dev/null +++ b/Tests/IntegrationTests/Network/TestCLINetwork.swift @@ -0,0 +1,215 @@ +//===----------------------------------------------------------------------===// +// 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 AsyncHTTPClient +import ContainerizationExtras +import Foundation +import Testing + +@Suite +struct TestCLINetwork { + + // MARK: - Tests + + @available(macOS 26, *) + @Test func testNetworkCreateAndUse() async throws { + try await ContainerFixture.with { f in + let net = "\(f.testID)-net" + let c = "\(f.testID)-c" + f.addCleanup { f.doNetworkDeleteIfExists(net) } + + try f.doNetworkCreate(net) + + let listResult = try f.run(["network", "ls", "--quiet"]).check() + let networkIds = listResult.output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespaces) } + .filter { !$0.isEmpty } + #expect(networkIds == networkIds.sorted(), "network IDs should be sorted") + + let port = UInt16.random(in: 50000..<60000) + try f.doLongRun( + name: c, image: "docker.io/library/python:alpine", + args: ["--network", net], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let container = try f.inspectContainer(c) + #expect(container.networks.count > 0) + let ip = container.networks[0].ipv4Address.address + let url = "http://\(ip):\(port)" + + // waitForContainerRunning only tells us init is running; the python http + // server inside is still starting, so retry until it accepts connections. + let client = f.makeHTTPClient() + defer { _ = client.shutdown() } + try await f.retry(attempts: 10) { + do { + var req = HTTPClientRequest(url: url) + req.method = .GET + let resp = try await client.execute(req, timeout: .seconds(3)) + return resp.status.code >= 200 && resp.status.code < 300 + } catch { + return false + } + } + } + } + + @available(macOS 26, *) + @Test func testNetworkDeleteWithContainer() async throws { + try await ContainerFixture.with { f in + let net = "\(f.testID)-net" + let c = "\(f.testID)-c" + f.addCleanup { f.doNetworkDeleteIfExists(net) } + f.addCleanup { try? f.doRemove(c, force: true) } + + try f.doNetworkCreate(net) + try f.doCreate(name: c, networks: [net]) + + let deleteResult = try f.run(["network", "delete", net]) + try #require(deleteResult.status != 0, "network delete should fail while container references it") + #expect(deleteResult.error.contains("delete failed")) + #expect(deleteResult.error.contains("[\"\(net)\"]")) + + try f.doRemove(c, force: true) + try f.doNetworkDelete(net) + } + } + + @available(macOS 26, *) + @Test func testNetworkLabels() async throws { + try await ContainerFixture.with { f in + let net = "\(f.testID)-net" + f.addCleanup { f.doNetworkDeleteIfExists(net) } + + try f.doNetworkCreate(net, args: ["--label", "foo=bar", "--label", "baz=qux"]) + + let network = try f.inspectNetwork(net) + let expectedLabels = ["foo": "bar", "baz": "qux"] + #expect(expectedLabels == network.configuration.labels.dictionary) + } + } + + @Test func testNetworkMTU() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--network", "default,mtu=1500"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let output = try f.doExec(c, cmd: ["ip", "link", "show", "eth0"]) + #expect(output.contains("mtu 1500"), "expected mtu 1500 in ip link output: \(output)") + } + } + + @available(macOS 26, *) + @Test func testIsolatedNetwork() async { + await withKnownIssue("curl error 7 despite retries", isIntermittent: true) { + try await ContainerFixture.with { f in + let net = "\(f.testID)-net" + let server = "\(f.testID)-server" + let pythonImage = "docker.io/library/python:alpine" + let curlImage = "docker.io/curlimages/curl:8.6.0" + + f.addCleanup { f.doNetworkDeleteIfExists(net) } + f.addCleanup { + try? f.doStop(server) + try? f.doRemove(server) + } + + try f.doNetworkCreate(net, args: ["--internal"]) + + let port = UInt16.random(in: 50000..<60000) + try f.doLongRun( + name: server, image: pythonImage, + args: ["--network", net], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], + autoRemove: false) + try await f.waitForContainerRunning(server) + + let container = try f.inspectContainer(server) + #expect(container.networks.count > 0) + let ip = container.networks[0].ipv4Address.address + let serverURL = "http://\(ip):\(port)" + + // Internal connection should succeed. `waitForContainerRunning` only + // proves the container's init is up; the python http.server inside + // may still be starting, so retry until it accepts connections. + try await f.retry(attempts: 10) { + let result = try f.run([ + "run", "--rm", "--network", net, curlImage, + "curl", "--connect-timeout", "3", serverURL, + ]) + return result.status == 0 + } + + // External connection should be blocked — the isolated network has no gateway. + let externalResult = try f.run([ + "run", "--rm", "--network", net, curlImage, + "curl", "--connect-timeout", "5", "http://google.com", + ]) + let hostOnlyBlockedCodes: Set = [6, 7, 28] + #expect( + hostOnlyBlockedCodes.contains(externalResult.status), + "external connection from isolated network should be blocked, got exit \(externalResult.status)") + } + } + } + + @Test func testNetworkListTableFormat() async throws { + try await ContainerFixture.with { f in + let net = "\(f.testID)-net" + f.addCleanup { f.doNetworkDeleteIfExists(net) } + try f.doNetworkCreate(net) + + let result = try f.run(["network", "list"]).check() + #expect(["NETWORK", "SUBNET"].allSatisfy { result.output.contains($0) }) + #expect(result.output.contains(net)) + } + } + + @Test func testNetworkListJSONFormat() async throws { + try await ContainerFixture.with { f in + let net = "\(f.testID)-net" + f.addCleanup { f.doNetworkDeleteIfExists(net) } + try f.doNetworkCreate(net) + + let result = try f.run(["network", "list", "--format", "json"]).check() + guard let json = try JSONSerialization.jsonObject(with: result.outputData) as? [[String: Any]] else { + Issue.record("JSON output should be an array of objects") + return + } + #expect(json.contains { ($0["id"] as? String) == net }) + } + } + + @Test func testInspectMissingNetworkFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["network", "inspect", "definitely-missing-\(f.testID)"]) + #expect(result.status != 0) + #expect(result.error.contains("network not found")) + } + } +} diff --git a/Tests/IntegrationTests/Network/TestCLINetworkPruneSerial.swift b/Tests/IntegrationTests/Network/TestCLINetworkPruneSerial.swift new file mode 100644 index 0000000..276530c --- /dev/null +++ b/Tests/IntegrationTests/Network/TestCLINetworkPruneSerial.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +/// Serial tests for `network prune` — pruning affects all unused networks regardless of name. +@Suite(.serialized) +struct TestCLINetworkPruneSerial { + + @available(macOS 26, *) + @Test func testNetworkPruneNoNetworks() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["network", "prune"]).check() + #expect(result.output.isEmpty, "should show no networks pruned") + } + } + + @available(macOS 26, *) + @Test func testNetworkPruneUnusedNetworks() async throws { + try await ContainerFixture.with { f in + let net1 = "\(f.testID)-net1" + let net2 = "\(f.testID)-net2" + f.addCleanup { f.doNetworkDeleteIfExists(net1) } + f.addCleanup { f.doNetworkDeleteIfExists(net2) } + + try f.doNetworkCreate(net1) + try f.doNetworkCreate(net2) + + let listBefore = try f.run(["network", "list", "--quiet"]).check().output + #expect(listBefore.contains(net1)) + #expect(listBefore.contains(net2)) + + let result = try f.run(["network", "prune"]).check() + #expect(result.output.contains(net1), "should prune \(net1)") + #expect(result.output.contains(net2), "should prune \(net2)") + + let listAfter = try f.run(["network", "list", "--quiet"]).check().output + #expect(!listAfter.contains(net1), "\(net1) should be pruned") + #expect(!listAfter.contains(net2), "\(net2) should be pruned") + } + } + + @available(macOS 26, *) + @Test func testNetworkPruneSkipsNetworksInUse() async throws { + try await ContainerFixture.with { f in + let containerName = "\(f.testID)-c" + let netInUse = "\(f.testID)-inuse" + let netUnused = "\(f.testID)-unused" + f.addCleanup { f.doNetworkDeleteIfExists(netInUse) } + f.addCleanup { f.doNetworkDeleteIfExists(netUnused) } + f.addCleanup { + try? f.doStop(containerName) + try? f.doRemove(containerName) + } + + try f.doNetworkCreate(netInUse) + try f.doNetworkCreate(netUnused) + + let port = UInt16.random(in: 50000..<60000) + try f.doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", netInUse], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], + autoRemove: false) + try await f.waitForContainerRunning(containerName) + let container = try f.inspectContainer(containerName) + #expect(container.networks.count > 0) + + try f.run(["network", "prune"]).check() + + let listAfter = try f.run(["network", "list", "--quiet"]).check().output + #expect(listAfter.contains(netInUse), "network in use should not be pruned") + #expect(!listAfter.contains(netUnused), "unused network should be pruned") + } + } + + @available(macOS 26, *) + @Test func testNetworkPruneSkipsNetworkAttachedToStoppedContainer() async throws { + try await ContainerFixture.with { f in + let containerName = "\(f.testID)-c" + let networkName = "\(f.testID)-net" + f.addCleanup { f.doNetworkDeleteIfExists(networkName) } + f.addCleanup { + try? f.doStop(containerName) + try? f.doRemove(containerName) + } + + try f.doNetworkCreate(networkName) + + let port = UInt16.random(in: 50000..<60000) + try f.doLongRun( + name: containerName, + image: "docker.io/library/python:alpine", + args: ["--network", networkName], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(port)"], + autoRemove: false) + try await f.waitForContainerRunning(containerName) + + // Network is attached to a running container — prune must skip it. + try f.run(["network", "prune"]).check() + let listMid = try f.run(["network", "list", "--quiet"]).check().output + #expect( + listMid.contains(networkName), + "network attached to running container should not be pruned") + + // Stop and remove the container, then prune again — now it should go. + try f.doStop(containerName) + try f.doRemove(containerName) + try f.run(["network", "prune"]).check() + + let listFinal = try f.run(["network", "list", "--quiet"]).check().output + #expect( + !listFinal.contains(networkName), + "network should be pruned after container is deleted") + } + } +} diff --git a/Tests/IntegrationTests/Registry/TestCLIRegistry.swift b/Tests/IntegrationTests/Registry/TestCLIRegistry.swift new file mode 100644 index 0000000..0bbf5b0 --- /dev/null +++ b/Tests/IntegrationTests/Registry/TestCLIRegistry.swift @@ -0,0 +1,55 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite +struct TestCLIRegistry { + @Test func testListDefaultFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["registry", "list"]) + #expect(result.status == 0, "registry list should succeed, stderr: \(result.error)") + + let requiredHeaders = ["HOSTNAME", "USERNAME", "MODIFIED", "CREATED"] + #expect( + requiredHeaders.allSatisfy { result.output.contains($0) }, + "output should contain all required headers" + ) + } + } + + @Test func testListJSONFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["registry", "list", "--format", "json"]) + #expect( + result.status == 0, + "registry list --format json should succeed, stderr: \(result.error)") + + let json = try JSONSerialization.jsonObject(with: result.outputData, options: []) + #expect(json is [Any], "JSON output should be an array") + } + } + + @Test func testListQuietMode() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["registry", "list", "-q"]) + #expect(result.status == 0, "registry list -q should succeed, stderr: \(result.error)") + #expect(!result.output.contains("HOSTNAME"), "quiet mode should not contain headers") + #expect(!result.output.contains("USERNAME"), "quiet mode should not contain headers") + } + } +} diff --git a/Tests/IntegrationTests/Run/TestCLIRunCapabilities.swift b/Tests/IntegrationTests/Run/TestCLIRunCapabilities.swift new file mode 100644 index 0000000..e631b34 --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunCapabilities.swift @@ -0,0 +1,434 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite +struct TestCLIRunCapabilities { + private let alpine = ContainerFixture.warmupImages[0] + + // MARK: - Invalid capability names + + @Test func testCapDropInvalid() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let result = try f.run(["run", "--rm", "--cap-drop=CHWOWZERS", image, "ls"]) + #expect(result.status != 0) + #expect(result.error.contains("CHWOWZERS") || result.error.contains("invalid")) + } + } + + @Test func testCapAddInvalid() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let result = try f.run(["run", "--rm", "--cap-add=CHWOWZERS", image, "ls"]) + #expect(result.status != 0) + #expect(result.error.contains("CHWOWZERS") || result.error.contains("invalid")) + } + } + + // MARK: - Config stored correctly via inspect + + @Test func testCapAddStored() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.capAdd.contains("CAP_NET_ADMIN")) + #expect(inspect.configuration.capDrop.isEmpty) + } + } + + @Test func testCapDropStored() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.capDrop.contains("CAP_MKNOD")) + #expect(inspect.configuration.capAdd.isEmpty) + } + } + + @Test func testCapAddDropALLStored() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--cap-drop", "ALL", "--cap-add", "SETGID", "--cap-add", "NET_RAW"], + autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.capDrop.contains("ALL")) + #expect(inspect.configuration.capAdd.contains("CAP_SETGID")) + #expect(inspect.configuration.capAdd.contains("CAP_NET_RAW")) + } + } + + @Test func testCapAddALLStored() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.capAdd.contains("ALL")) + } + } + + @Test func testCapDropLowerCase() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.capDrop.contains("CAP_MKNOD")) + } + } + + // MARK: - In-container capability verification + + @Test func testCapDropMknodCannotMknod() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-drop", "MKNOD"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"]) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok") + #expect(result.status != 0) + } + } + + @Test func testCapDropMknodLowerCaseCannotMknod() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-drop", "mknod"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"]) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok") + #expect(result.status != 0) + } + } + + @Test func testCapDropALLCannotMknod() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--cap-drop", "ALL", "--cap-add", "SETGID"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"]) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok") + #expect(result.status != 0) + } + } + + @Test func testCapDropALLAddMknodCanMknod() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--cap-drop", "ALL", "--cap-add", "MKNOD", "--cap-add", "SETGID"], + autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "ok") + } + } + + @Test func testCapAddALLCanDownInterface() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["sh", "-c", "ip link set lo down && echo ok"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "ok") + } + } + + @Test func testCapAddALLDropNetAdminCannotDownInterface() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--cap-add", "ALL", "--cap-drop", "NET_ADMIN"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let result = try f.run(["exec", c, "sh", "-c", "ip link set lo down && echo ok"]) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok") + #expect(result.status != 0) + } + } + + @Test func testCapAddNetAdminCanDownInterface() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-add", "NET_ADMIN"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["sh", "-c", "ip link set lo down && echo ok"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "ok") + } + } + + // MARK: - Default capability behavior + + @Test func testDefaultCapChown() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + _ = try f.doExec(c, cmd: ["chown", "100", "/tmp"]) + } + } + + @Test func testNonRootUserCannotReadShadow() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + _ = try f.doExec(c, cmd: ["cat", "/etc/shadow"]) + let result = try f.run(["exec", "-u", "nobody", c, "cat", "/etc/shadow"]) + #expect(result.status != 0, "non-root user should not be able to read /etc/shadow") + } + } + + @Test func testCapDropChown() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-drop", "chown"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let result = try f.run(["exec", c, "chown", "100", "/tmp"]) + #expect(result.status != 0) + } + } + + @Test func testDefaultCapFowner() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + _ = try f.doExec(c, cmd: ["chmod", "777", "/etc/passwd"]) + } + } + + // MARK: - Capability bitmask verification via /proc + + @Test func testCapDropALLShowsZeroCaps() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--cap-drop", "ALL", "--cap-add", "SETUID", "--cap-add", "SETGID"], + autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"]) + let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") } + try #require(capEff != nil) + let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces) + #expect(value != "0000000000000000", "expected non-zero CapEff with SETUID+SETGID") + #expect(value != "000001ffffffffff", "expected restricted caps, not full set") + } + } + + @Test func testNoCapFlagsUsesDefaultCaps() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"]) + let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") } + try #require(capEff != nil) + let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces) + #expect(value != "0000000000000000") + } + } + + @Test func testCapAddALLShowsFullCaps() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-add", "ALL"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"]) + let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") } + try #require(capEff != nil) + let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces) + #expect(value != "0000000000000000") + } + } + + @Test func testCapDropALLOnlyShowsZeroEffective() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cap-drop", "ALL"], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["cat", "/proc/self/status"]) + let capEff = output.components(separatedBy: "\n").first { $0.hasPrefix("CapEff:") } + try #require(capEff != nil) + let value = capEff!.replacingOccurrences(of: "CapEff:", with: "").trimmingCharacters(in: .whitespaces) + #expect(value == "0000000000000000", "expected zero CapEff when ALL caps dropped, got \(value)") + } + } + + @Test func testMultipleCapAddDrop() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: [ + "--cap-add", "SYS_ADMIN", "--cap-add", "NET_RAW", + "--cap-drop", "MKNOD", "--cap-drop", "CHOWN", + ], + autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.capAdd.count == 2) + #expect(inspect.configuration.capDrop.count == 2) + #expect(inspect.configuration.capAdd.contains("CAP_SYS_ADMIN")) + #expect(inspect.configuration.capAdd.contains("CAP_NET_RAW")) + #expect(inspect.configuration.capDrop.contains("CAP_MKNOD")) + #expect(inspect.configuration.capDrop.contains("CAP_CHOWN")) + + let result = try f.run(["exec", c, "sh", "-c", "mknod /tmp/sda b 8 0 && echo ok"]) + #expect(result.output.trimmingCharacters(in: .whitespacesAndNewlines) != "ok") + } + } +} diff --git a/Tests/IntegrationTests/Run/TestCLIRunCommand.swift b/Tests/IntegrationTests/Run/TestCLIRunCommand.swift new file mode 100644 index 0000000..e744e62 --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunCommand.swift @@ -0,0 +1,727 @@ +//===----------------------------------------------------------------------===// +// 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 AsyncHTTPClient +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Testing + +@Suite +struct TestCLIRunCommand { + private let alpine = ContainerFixture.warmupImages[0] + + // MARK: - Basic run options + + @Test func testRunCommand() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + _ = try f.doExec(c, cmd: ["date"]) + } + } + + @Test func testRunCommandCWD() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cwd", "/tmp"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let output = try f.doExec(c, cmd: ["pwd"]).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "/tmp") + } + } + + @Test func testRunCommandEnv() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--env", "FOO=bar"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.initProcess.environment.contains("FOO=bar")) + } + } + + @Test func testRunCommandEnvFile() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + let envFile = f.testDir.appending("test.env") + let content = "# comment\nFOO=bar\nBAR=baz wow\nURL=https://foo.bar?baz=wow\n" + try content.write(toFile: envFile.string, atomically: true, encoding: .utf8) + + try f.doLongRun(name: c, image: image, args: ["--env-file", envFile.string], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let inspect = try f.inspectContainer(c) + for expected in ["FOO=bar", "BAR=baz wow", "URL=https://foo.bar?baz=wow"] { + #expect(inspect.configuration.initProcess.environment.contains(expected)) + } + } + } + + @Test func testRunCommandUserIDGroupID() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--uid", "10", "--gid", "100"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let output = try f.doExec(c, cmd: ["id"]).trimmingCharacters(in: .whitespacesAndNewlines) + try #expect(output.contains(Regex("uid=10.*?gid=100.*"))) + } + } + + @Test func testRunCommandUser() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--user", "nobody"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let output = try f.doExec(c, cmd: ["whoami"]).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "nobody") + } + } + + @Test func testRunCommandCPUs() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--cpus", "2"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let output = try f.doExec(c, cmd: ["cat", "/sys/fs/cgroup/cpu.max"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + let fields = output.components(separatedBy: .whitespaces) + #expect(fields.count == 2) + let numerator = try #require(Int(fields[0])) + let denominator = try #require(Int(fields[1])) + #expect(denominator > 0) + #expect(2 * denominator == numerator) + } + } + + @Test func testRunCommandMemory() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--memory", "1024M"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let inspect = try f.inspectContainer(c) + let expectedBytes = UInt64(1024) * 1024 * 1024 + #expect(inspect.configuration.resources.memoryInBytes == expectedBytes) + } + } + + @Test func testRunCommandUlimitNofile() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--ulimit", "nofile=1024:2048"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let inspect = try f.inspectContainer(c) + let nofile = inspect.configuration.initProcess.rlimits.first { $0.limit == "RLIMIT_NOFILE" } + try #require(nofile != nil) + #expect(nofile?.soft == 1024) + #expect(nofile?.hard == 2048) + + let output = try f.doExec(c, cmd: ["sh", "-c", "ulimit -n"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "1024") + } + } + + @Test func testRunCommandUlimitNproc() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--ulimit", "nproc=256"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let inspect = try f.inspectContainer(c) + let nproc = inspect.configuration.initProcess.rlimits.first { $0.limit == "RLIMIT_NPROC" } + try #require(nproc != nil) + #expect(nproc?.soft == 256) + #expect(nproc?.hard == 256) + + let output = try f.doExec(c, cmd: ["sh", "-c", "ulimit -u"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "256") + } + } + + @Test func testRunCommandMultipleUlimits() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--ulimit", "nofile=1024:2048", "--ulimit", "nproc=512", "--ulimit", "stack=8388608"], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let rlimits = try f.inspectContainer(c).configuration.initProcess.rlimits + #expect(rlimits.count == 3) + let nofile = rlimits.first { $0.limit == "RLIMIT_NOFILE" } + let nproc = rlimits.first { $0.limit == "RLIMIT_NPROC" } + let stack = rlimits.first { $0.limit == "RLIMIT_STACK" } + #expect(nofile?.soft == 1024 && nofile?.hard == 2048) + #expect(nproc?.soft == 512 && nproc?.hard == 512) + #expect(stack?.soft == 8_388_608 && stack?.hard == 8_388_608) + } + } + + // MARK: - Mounts and storage + + @Test func testRunCommandMount() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + let testData = "hello world" + let hostFile = f.testDir.appending("testfile.txt") + try testData.write(toFile: hostFile.string, atomically: true, encoding: .utf8) + + try f.doLongRun( + name: c, image: image, + args: ["--mount", "type=virtiofs,source=\(f.testDir.string),target=/tmp/testmount,readonly"], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let output = try f.doExec(c, cmd: ["cat", "/tmp/testmount/testfile.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == testData) + } + } + + @Test func testRunCommandUnixSocketMount() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + // sockaddr_un.sun_path is 104 bytes on macOS — use /tmp to keep + // the host socket path short regardless of project directory depth. + let socketDir = "/tmp/\(f.testID)-sock" + try FileManager.default.createDirectory( + atPath: socketDir, withIntermediateDirectories: true, attributes: nil) + f.addCleanup { try? FileManager.default.removeItem(atPath: socketDir) } + let socketPath = socketDir + "/ssh-auth.sock" + let guestSocketPath = "/run/ssh-auth.sock" + + let socketType = try UnixType(path: socketPath, perms: 0o766, unlinkExisting: true) + let socket = try Socket(type: socketType, closeOnDeinit: true) + try socket.listen() + f.addCleanup { try? socket.close() } + + try f.doLongRun( + name: c, image: image, + args: ["-v", "\(socketPath):\(guestSocketPath)", "-e", "SSH_AUTH_SOCK=\(guestSocketPath)"], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + _ = try f.doExec(c, cmd: ["apk", "add", "netcat-openbsd"]) + let perms = try f.doExec( + c, cmd: ["sh", "-c", "stat -c \"%a\" \"${SSH_AUTH_SOCK}\""], + user: "guest" + ).trimmingCharacters(in: .whitespacesAndNewlines) + #expect(perms == "766") + _ = try f.doExec(c, cmd: ["sh", "-c", "nc -zU \"${SSH_AUTH_SOCK}\""], user: "guest") + } + } + + @Test func testRunCommandTmpfs() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--tmpfs", "/tmp/testtmpfs"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let output = try f.doExec(c, cmd: ["df", "/tmp/testtmpfs"]) + let lines = output.split(separator: "\n") + #expect(lines.count == 2) + let words = lines[1].split(separator: " ") + #expect(words[0].lowercased() == "tmpfs") + } + } + + @Test func testRunCommandShmSize() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--shm-size", "128m"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let output = try f.doExec(c, cmd: ["mount"]) + let shmLine = output.split(separator: "\n").first { $0.contains("/dev/shm") } + try #require(shmLine != nil) + #expect(shmLine!.contains("size=\(128 * 1024)k")) + } + } + + @Test func testRunCommandVolume() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + let testData = "one small step" + let volumeFile = f.testDir.appending("data.txt") + try testData.write(toFile: volumeFile.string, atomically: true, encoding: .utf8) + + try f.doLongRun( + name: c, image: image, + args: ["--volume", "\(f.testDir.string):/tmp/testvolume"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let output = try f.doExec(c, cmd: ["cat", "/tmp/testvolume/data.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == testData) + } + } + + @Test func testRunCommandCidfile() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + let cidfile = f.testDir.appending("container.cid") + + try f.doLongRun(name: c, image: image, args: ["--cidfile", cidfile.string], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let actualID = try String(contentsOfFile: cidfile.string, encoding: .utf8) + #expect(actualID == c) + } + } + + // MARK: - Network and DNS + + @Test func testRunCommandNoDNS() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--no-dns"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let result = try f.run(["exec", c, "cat", "/etc/resolv.conf"]) + #expect(result.status != 0) + } + } + + @Test func testRunCommandDefaultResolvConf() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let output = try f.doExec(c, cmd: ["cat", "/etc/resolv.conf"]) + let actualLines = output.components(separatedBy: .newlines) + .filter { !$0.isEmpty } + .map { $0.components(separatedBy: .whitespaces).joined(separator: " ") } + + let inspect = try f.inspectContainer(c) + let ip = inspect.networks[0].ipv4Address.address + let nameserver = IPv4Address((ip.value & Prefix(length: 24)!.prefixMask32) + 1).description + let config = try f.getSystemConfig() + let expectedLines: [String] = [ + "nameserver \(nameserver)", + config.dns.domain.map { "domain \($0)" }, + ].compactMap { $0 } + + #expect(expectedLines == actualLines) + } + } + + @Test func testRunCommandNonDefaultResolvConf() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: [ + "--dns", "8.8.8.8", "--dns-domain", "example.com", + "--dns-search", "test.com", "--dns-option", "debug", + ], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let output = try f.doExec(c, cmd: ["cat", "/etc/resolv.conf"]) + let actualLines = output.components(separatedBy: .newlines) + .filter { !$0.isEmpty } + .map { $0.components(separatedBy: .whitespaces).joined(separator: " ") } + + #expect( + actualLines == [ + "nameserver 8.8.8.8", + "domain example.com", + "search test.com", + "options debug", + ]) + } + } + + @Test func testRunDefaultHostsEntries() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let inspect = try f.inspectContainer(c) + let ip = inspect.networks[0].ipv4Address.address.description + + let output = try f.doExec(c, cmd: ["cat", "/etc/hosts"]) + let lines = output.split(separator: "\n") + let expected = [("127.0.0.1", "localhost"), (ip, c)] + for (i, line) in lines.enumerated() { + guard i < expected.count else { break } + let words = line.split(separator: " ").map(String.init) + #expect(words.count >= 2) + #expect(words[0] == expected[i].0) + #expect(words[1] == expected[i].1) + } + } + } + + @Test func testPrivilegedPortError() async throws { + try #require(geteuid() != 0) + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + f.addCleanup { try? f.doRemove(c, force: true) } + let result = try f.run(["run", "--name", c, "--publish", "127.0.0.1:80:80", image]) + #expect(result.status != 0) + #expect(result.error.contains("Permission denied while binding to host port 80")) + #expect(result.error.contains("root privileges")) + } + } + + // MARK: - Platform + + @Test func testRunCommandOSArch() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--os", "linux", "--arch", "amd64"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let output = try f.doExec(c, cmd: ["uname", "-sm"]) + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + #expect(output == "linux x86_64") + } + } + + @Test func testRunCommandPlatform() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--platform", "linux/amd64"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let output = try f.doExec(c, cmd: ["uname", "-sm"]) + .trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + #expect(output == "linux x86_64") + } + } + + // MARK: - init process + + @Test func testRunCommandInit() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.useInit == true) + + let cmdline = try f.doExec(c, cmd: ["cat", "/proc/1/cmdline"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(!cmdline.hasPrefix("sleep"), "PID 1 should be init process, not 'sleep'") + } + } + + @Test func testRunCommandInitReapsZombies() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--init"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + + _ = try f.doExec(c, cmd: ["sh", "-c", "sh -c 'sh -c \"exit 0\" &' && sleep 1"]) + let ps = try f.doExec(c, cmd: ["sh", "-c", "ps aux | grep -c '\\[sh\\]' || true"]) + let zombieCount = Int(ps.trimmingCharacters(in: .whitespacesAndNewlines)) ?? -1 + #expect(zombieCount == 0, "expected no zombie processes with --init") + } + } + + @Test func testRunCommandWithoutInitDefault() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.useInit == false) + } + } + + // MARK: - Read-only rootfs + + @Test func testRunCommandReadOnly() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["--read-only"], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await f.waitForContainerRunning(c) + let result = try f.run(["exec", c, "touch", "/testfile"]) + #expect(result.status != 0, "touch on read-only rootfs should fail") + } + } + + // MARK: - Env file from named pipe + + @Test func testRunCommandEnvFileFromNamedPipe() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + let pipePath = f.testDir.appending("envfile.pipe") + guard mkfifo(pipePath.string, 0o600) == 0 else { + Issue.record("failed to create named pipe") + return + } + + let content = "FOO=bar\nBAR=baz\n" + // Write to the FIFO in a detached task so the open doesn't block forever. + let writeTask = Task.detached { + let handle = try FileHandle(forWritingTo: URL(filePath: pipePath.string)) + try handle.write(contentsOf: Data(content.utf8)) + try handle.close() + } + defer { writeTask.cancel() } + + try f.doLongRun(name: c, image: image, args: ["--env-file", pipePath.string], autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + try await writeTask.value + + try await f.waitForContainerRunning(c) + let inspect = try f.inspectContainer(c) + #expect(inspect.configuration.initProcess.environment.contains("FOO=bar")) + #expect(inspect.configuration.initProcess.environment.contains("BAR=baz")) + } + } + + // MARK: - TCP port forwarding + + @Test func testForwardTCP() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + let proxyPort = UInt16.random(in: 50000..<55000) + let serverPort = UInt16.random(in: 55000..<60000) + try f.doLongRun( + name: c, image: "docker.io/library/python:alpine", + args: ["--publish", "127.0.0.1:\(proxyPort):\(serverPort)/tcp"], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPort)"], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let client = f.makeHTTPClient() + defer { _ = client.shutdown() } + try await f.retry(attempts: 10, delay: .seconds(3)) { + do { + var req = HTTPClientRequest(url: "http://127.0.0.1:\(proxyPort)") + req.method = .GET + let resp = try await client.execute(req, timeout: .seconds(3)) + return resp.status.code >= 200 && resp.status.code < 300 + } catch { + return false + } + } + } + } + + @Test func testForwardTCPPortRange() async throws { + try await ContainerFixture.with { f in + let range = UInt16(10) + let proxyPortStart = UInt16.random(in: 50000..<55000) + let serverPortStart = UInt16.random(in: 55000..<60000) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: "docker.io/library/python:alpine", + args: ["--publish", "127.0.0.1:\(proxyPortStart)-\(proxyPortStart + range):\(serverPortStart)-\(serverPortStart + range)/tcp"], + containerArgs: ["python3", "-m", "http.server", "--bind", "0.0.0.0", "\(serverPortStart)"], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let client2 = f.makeHTTPClient() + defer { _ = client2.shutdown() } + try await f.retry(attempts: 10, delay: .seconds(3)) { + do { + var req = HTTPClientRequest(url: "http://127.0.0.1:\(proxyPortStart)") + req.method = .GET + let resp = try await client2.execute(req, timeout: .seconds(3)) + return resp.status.code >= 200 && resp.status.code < 300 + } catch { + return false + } + } + } + } + + @available(macOS 26, *) + @Test func testForwardTCPv6() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c" + let proxyPort = UInt16.random(in: 50000..<55000) + let serverPort = UInt16.random(in: 55000..<60000) + try f.doLongRun( + name: c, image: "docker.io/library/node:alpine", + args: ["--publish", "[::1]:\(proxyPort):\(serverPort)/tcp"], + containerArgs: ["npx", "http-server", "-a", "::", "-p", "\(serverPort)"], + autoRemove: false) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let client3 = f.makeHTTPClient() + defer { _ = client3.shutdown() } + try await f.retry(attempts: 10, delay: .seconds(3)) { + do { + var req = HTTPClientRequest(url: "http://[::1]:\(proxyPort)") + req.method = .GET + let resp = try await client3.execute(req, timeout: .seconds(3)) + return resp.status.code >= 200 && resp.status.code < 300 + } catch { + return false + } + } + } + } +} diff --git a/Tests/IntegrationTests/Run/TestCLIRunInitImage.swift b/Tests/IntegrationTests/Run/TestCLIRunInitImage.swift new file mode 100644 index 0000000..ce16ff3 --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunInitImage.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests for the `--init-image` flag which allows specifying a custom init filesystem +/// image for microvms. +/// +/// Note: A full integration test that verifies custom init behavior would require +/// a pre-built test init image that writes a marker to /dev/kmsg. This can be added +/// once a test init image is published to the registry. +@Suite +struct TestCLIRunInitImage { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testRunWithNonExistentInitImage() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + f.addCleanup { try? f.doRemove(c, force: true) } + let result = try f.run([ + "run", "--rm", "--name", c, "-d", + "--init-image", "nonexistent.invalid/init-image:does-not-exist", + image, "sleep", "infinity", + ]) + #expect(result.status != 0, "run with non-existent init-image should fail") + } + } + + @Test func testInitImageFlagInHelp() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["run", "--help"]).check() + #expect(result.output.contains("--init-image")) + #expect(result.output.contains("custom init image")) + } + } + + @Test func testCreateWithNonExistentInitImage() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + f.addCleanup { try? f.doRemove(c, force: true) } + let result = try f.run([ + "create", "--name", c, + "--init-image", "nonexistent.invalid/init-image:does-not-exist", + image, "echo", "hello", + ]) + #expect(result.status != 0, "create with non-existent init-image should fail") + } + } + + @Test func testRunWithExplicitDefaultInitImage() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + let config = try f.getSystemConfig() + try f.doLongRun( + name: c, image: image, + args: ["--init-image", config.vminit.image], autoRemove: false) + try await f.waitForContainerRunning(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + } + + let output = try f.doExec(c, cmd: ["echo", "hello"]) + #expect(output.trimmingCharacters(in: .whitespacesAndNewlines) == "hello") + } + } +} diff --git a/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift new file mode 100644 index 0000000..d5ed875 --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycle.swift @@ -0,0 +1,120 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Concurrent lifecycle tests for `container run` / `start` / `exec`. +@Suite +struct TestCLIRunLifecycle { + @Test func testRunFailureCleanup() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + + // First attempt with an invalid user — must fail. + let failResult = try f.run([ + "run", "--rm", "--name", name, "-d", + "--user", f.testID, // f.testID won't exist in /etc/passwd + image, "sleep", "infinity", + ]) + #expect(failResult.status != 0, "expected run to fail with invalid user") + + // Second attempt with the same name and no user — must succeed. + try await f.withContainer(image: image) { containerName in + _ = try f.doExec(containerName, cmd: ["date"]) + } + } + } + + @Test func testStartIdempotent() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["start", name]) + #expect(result.status == 0, "expected start to succeed on already running container") + #expect( + result.output.trimmingCharacters(in: .whitespacesAndNewlines) == name, + "expected output to be container name") + _ = try f.inspectContainer(name) + } + } + } + + @Test func testStartIdempotentAttachFails() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["start", "-a", name]) + #expect( + result.status != 0, + "expected start with attach to fail on already running container") + #expect(result.error.contains("attach is currently unsupported on already running containers")) + } + } + } + + @Test func testRunInvalidExecutable() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + let result = try f.run(["run", "--rm", "--name", name, "-d", image, "foobarbaz"]) + #expect(result.status != 0, "running invalid executable must fail, not hang") + } + } + + @Test func testExecInvalidExecutable() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + try await f.withContainer(image: image) { name in + let result = try f.run(["exec", name, "foobarbaz"]) + #expect(result.status != 0, "executing invalid executable must fail, not hang") + } + } + } + + @Test func testSSHForwarding() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + + let socketPath = try f.makeFakeSSHAgentSocket() + + // Run container with --ssh; SSH_AUTH_SOCK must be in the CLI process environment. + let name = "\(f.testID)-c" + f.addCleanup { try? f.doStop(name) } + try f.run( + ["run", "--rm", "--name", name, "-d", "--ssh", image, "sleep", "infinity"], + env: ["SSH_AUTH_SOCK": socketPath] + ).check() + try await f.waitForContainerRunning(name) + + let sshSockValue = try f.doExec(name, cmd: ["sh", "-c", "echo $SSH_AUTH_SOCK"]) + #expect( + sshSockValue.trimmingCharacters(in: .whitespacesAndNewlines) + == "/var/host-services/ssh-auth.sock", + "expected SSH_AUTH_SOCK to point to guest socket path") + + let socketCheck = try f.doExec( + name, cmd: ["sh", "-c", "[ -S /var/host-services/ssh-auth.sock ] && echo exists || echo missing"]) + #expect( + socketCheck.trimmingCharacters(in: .whitespacesAndNewlines) == "exists", + "expected forwarded SSH socket to exist in container") + + try f.doStop(name) + } + } +} diff --git a/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift b/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift new file mode 100644 index 0000000..b63db8f --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLIRunLifecycleSerial.swift @@ -0,0 +1,48 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Serial lifecycle tests that require non-warmup images or exclusive port binding. +@Suite(.serialized) +struct TestCLIRunLifecycleSerial { + @Test func testStartPortBindFails() async throws { + try await ContainerFixture.with { f in + let port = UInt16.random(in: 50000..<60000) + let serverImage = "docker.io/library/python:alpine" + try f.run(["image", "pull", serverImage]).check() + + let name = "\(f.testID)-c" + try f.doCreate(name: name, ports: ["\(port)"]) + f.addCleanup { try? f.doRemove(name) } + + let server = "\(f.testID)-server" + try f.doLongRun( + name: server, + image: serverImage, + args: ["--publish", "\(port):\(port)"], + containerArgs: ["python3", "-m", "http.server", "\(port)"]) + f.addCleanup { try? f.doStop(server) } + + let startResult = try f.run(["start", name]) + #expect(startResult.status != 0, "expected start to fail when port is already bound") + + let status = try f.getContainerStatus(name) + #expect(status == "stopped") + } + } +} diff --git a/Tests/IntegrationTests/Run/TestCLITermIO.swift b/Tests/IntegrationTests/Run/TestCLITermIO.swift new file mode 100644 index 0000000..a1635a3 --- /dev/null +++ b/Tests/IntegrationTests/Run/TestCLITermIO.swift @@ -0,0 +1,45 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests that an interactive `-it` session with a pty attached does not panic. +@Suite +struct TestCLITermIO { + @Test func testTermIODoesNotPanic() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(ContainerFixture.warmupImages[0]) + let name = "\(f.testID)-c" + f.addCleanup { try f.doRemoveIfExists(name, force: true, ignoreFailure: true) } + + let uniqMessage = UUID().uuidString + let stdin = Data("echo \(uniqMessage)\nexit\n".utf8) + let result = try f.run( + ["run", "--rm", "--name", name, "-it", image, "/bin/sh"], + stdin: stdin, + pty: true + ).check("interactive run should not panic") + + // The container's own pty echoes typed input back on stdout, so skip + // lines containing "echo" to find the command's actual output. + let found = result.output.components(separatedBy: .newlines).contains { + $0.contains(uniqMessage) && !$0.contains("echo") + } + #expect(found, "did not find expected stdout line, stdout: \(result.output)") + } + } +} diff --git a/Tests/IntegrationTests/System/TestCLIHelp.swift b/Tests/IntegrationTests/System/TestCLIHelp.swift new file mode 100644 index 0000000..c91f6bc --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLIHelp.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +@Suite +struct TestCLIHelp { + @Test func testHelp() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["help"]) + #expect(result.status == 0, "help should succeed, stderr: \(result.error)") + #expect( + result.output.contains("OVERVIEW: A container platform for macOS"), + "output should contain overview section" + ) + } + } + + @Test func testDebugHelp() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["--debug", "help"]) + #expect(result.status == 0, "help should succeed, stderr: \(result.error)") + #expect( + result.output.contains("OVERVIEW: A container platform for macOS"), + "output should contain overview section" + ) + } + } +} diff --git a/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift new file mode 100644 index 0000000..3c4945b --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLIKernelSetSerial.swift @@ -0,0 +1,130 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import ContainerizationArchive +import Foundation +import Testing + +/// Tests for `container system kernel set`. Each test modifies the global default +/// kernel binary, so the suite must run fully serialised. +@Suite(.serialized) +struct TestCLIKernelSetSerial { + private let remoteTar = ContainerSystemConfig().kernel.url + private let defaultBinaryPath = ContainerSystemConfig().kernel.binaryPath + + /// Kernel release string parsed from the binary filename. + /// + /// The binary path is conventionally `vmlinux-{release}` — but Kata's distribution + /// appends a `-{buildNumber}` suffix to the file (e.g. `vmlinux-6.18.15-186`) + /// while `uname -r` in the guest only reports the upstream release (`6.18.15`). + /// We strip a trailing `-N` where N is all digits to match what the guest reports, + /// while preserving non-numeric suffixes like `-rc1` or `-rt` that ARE part of the + /// upstream release string. + private var expectedKernelRelease: String { + let filename = URL(fileURLWithPath: defaultBinaryPath).lastPathComponent + let prefix = "vmlinux-" + let raw = filename.hasPrefix(prefix) ? String(filename.dropFirst(prefix.count)) : filename + if let dashIdx = raw.lastIndex(of: "-"), + raw[raw.index(after: dashIdx)...].allSatisfy({ $0.isNumber }) + { + return String(raw[..= 2) + #expect(lines[0].contains("FIELD") && lines[0].contains("VALUE")) + + let fullOutput = lines.joined(separator: "\n") + #expect(fullOutput.contains("status")) + #expect(fullOutput.contains("running")) + #expect(fullOutput.contains("appRoot")) + #expect(fullOutput.contains("installRoot")) + #expect(fullOutput.contains("apiserver.version")) + #expect(fullOutput.contains("apiserver.commit")) + } + } + + @Test func jsonFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "status", "--format", "json"]) + + if result.status != 0 { + #expect(!result.output.isEmpty) + let decoded = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) + #expect(decoded.status == "not running" || decoded.status == "unregistered") + return + } + + #expect(!result.output.isEmpty) + let decoded = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) + #expect(decoded.status == "running") + #expect(!decoded.appRoot.isEmpty) + #expect(!decoded.installRoot.isEmpty) + #expect(!decoded.apiServerVersion.isEmpty) + #expect(!decoded.apiServerCommit.isEmpty) + #expect(!decoded.apiServerBuild.isEmpty) + #expect(!decoded.apiServerAppName.isEmpty) + } + } + + @Test func explicitTableFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "status", "--format", "table"]) + guard result.status == 0 else { return } + + #expect(!result.output.isEmpty) + + let lines = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + #expect(lines[0].contains("FIELD") && lines[0].contains("VALUE")) + #expect(lines.joined(separator: "\n").contains("running")) + } + } + + @Test func statusFieldsMatch() async throws { + try await ContainerFixture.with { f in + let jsonResult = try f.run(["system", "status", "--format", "json"]) + let tableResult = try f.run(["system", "status", "--format", "table"]) + #expect(jsonResult.status == tableResult.status) + + guard jsonResult.status == 0 else { return } + + let decoded = try JSONDecoder().decode(StatusJSON.self, from: jsonResult.outputData) + #expect(tableResult.output.contains(decoded.status)) + #expect(tableResult.output.contains(decoded.appRoot)) + #expect(tableResult.output.contains(decoded.installRoot)) + #expect(tableResult.output.contains(decoded.apiServerVersion)) + #expect(tableResult.output.contains(decoded.apiServerCommit)) + #expect(tableResult.output.contains(decoded.apiServerBuild)) + #expect(tableResult.output.contains(decoded.apiServerAppName)) + } + } + + @Test func jsonOutputValidStructure() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "status", "--format", "json"]) + let decoded = try JSONDecoder().decode(StatusJSON.self, from: result.outputData) + if result.status == 0 { + #expect(decoded.status == "running") + #expect(!decoded.appRoot.isEmpty) + #expect(!decoded.installRoot.isEmpty) + #expect(!decoded.apiServerVersion.isEmpty) + } else { + #expect(decoded.status == "not running" || decoded.status == "unregistered") + } + } + } + + @Test func prefixOption() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "status", "--prefix", "com.apple.container."]) + guard result.status == 0 else { return } + + #expect(!result.output.isEmpty) + let lines = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + } + } +} diff --git a/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift b/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift new file mode 100644 index 0000000..e7e3b72 --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLISystemDFSerial.swift @@ -0,0 +1,101 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests for `container system df`. All tests clear and inspect the global image +/// store, so they must run serially with no concurrent image activity. +@Suite(.serialized) +struct TestCLISystemDFSerial { + private struct DiskUsageStats: Decodable { + let images: ResourceUsage + } + private struct ResourceUsage: Decodable { + let active: Int + let reclaimable: UInt64 + let sizeInBytes: UInt64 + let total: Int + } + + private let alpine = ContainerFixture.warmupImages[0] + + // Issue #1526: reported image size must include content blobs, not just unpacked snapshots. + @Test func imageDiskUsageIsPopulatedAfterPull() async throws { + try await ContainerFixture.with { f in + try withCleanImageStore(f) { + try f.doPull(self.alpine) + let stats = try systemDiskUsage(f) + #expect(stats.images.total >= 1) + #expect(stats.images.active == 0) + #expect(stats.images.sizeInBytes > 0) + #expect(stats.images.reclaimable == stats.images.sizeInBytes) + } + } + } + + // Issue #1527: tagging the same image must not double-count its storage. + @Test func tagsDoNotDoubleCountImageStorage() async throws { + try await ContainerFixture.with { f in + try withCleanImageStore(f) { + try f.doPull(self.alpine) + let before = try systemDiskUsage(f) + try f.doImageTag(self.alpine, newName: "local/system-df-alpine:tag-one") + try f.doImageTag(self.alpine, newName: "local/system-df-alpine:tag-two") + let after = try systemDiskUsage(f) + #expect(after.images.total == before.images.total + 2) + #expect(after.images.sizeInBytes == before.images.sizeInBytes) + #expect(after.images.reclaimable == before.images.reclaimable) + } + } + } + + // Issue #1527: removing one of several tags must not free shared storage. + @Test func deletingOneOfMultipleTagsPreservesSharedStorage() async throws { + try await ContainerFixture.with { f in + try withCleanImageStore(f) { + let baseline = try systemDiskUsage(f) + try f.doPull(self.alpine) + try f.doImageTag(self.alpine, newName: "local/system-df-alpine:delete-probe") + let beforeDelete = try systemDiskUsage(f) + + try f.doRemoveImages(["local/system-df-alpine:delete-probe"]) + let afterAliasDelete = try systemDiskUsage(f) + #expect(afterAliasDelete.images.total == beforeDelete.images.total - 1) + #expect(afterAliasDelete.images.sizeInBytes == beforeDelete.images.sizeInBytes) + #expect(afterAliasDelete.images.reclaimable == beforeDelete.images.reclaimable) + + _ = try? f.doRemoveImages() + let afterFullClean = try systemDiskUsage(f) + #expect(afterFullClean.images.total <= baseline.images.total) + #expect(afterFullClean.images.sizeInBytes <= baseline.images.sizeInBytes) + } + } + } + + // MARK: - Private helpers + + private func withCleanImageStore(_ f: ContainerFixture, _ body: () throws -> Void) throws { + _ = try? f.doRemoveImages() + defer { _ = try? f.doRemoveImages() } + try body() + } + + private func systemDiskUsage(_ f: ContainerFixture) throws -> DiskUsageStats { + let result = try f.run(["system", "df", "--format", "json"]).check() + return try JSONDecoder().decode(DiskUsageStats.self, from: result.outputData) + } +} diff --git a/Tests/IntegrationTests/System/TestCLISystemLogs.swift b/Tests/IntegrationTests/System/TestCLISystemLogs.swift new file mode 100644 index 0000000..cdd6067 --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLISystemLogs.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +/// Tests for `container system logs` argument validation. +@Suite +struct TestCLISystemLogs { + @Test func testLogsRejectsInvalidLastUnit() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "logs", "--last", "1x"]) + #expect(result.status != 0, "Expected non-zero exit for invalid --last unit") + #expect(result.error.contains("invalid --last value")) + } + } + + @Test func testLogsRejectsNonNumericLast() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "logs", "--last", "abc"]) + #expect(result.status != 0, "Expected non-zero exit for non-numeric --last") + #expect(result.error.contains("invalid --last value")) + } + } + + @Test func testLogsRejectsZeroLast() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "logs", "--last", "0m"]) + #expect(result.status != 0, "Expected non-zero exit for zero --last value") + #expect(result.error.contains("invalid --last value")) + } + } +} diff --git a/Tests/IntegrationTests/System/TestCLIVersion.swift b/Tests/IntegrationTests/System/TestCLIVersion.swift new file mode 100644 index 0000000..4c0d7bd --- /dev/null +++ b/Tests/IntegrationTests/System/TestCLIVersion.swift @@ -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 Foundation +import Testing +import Yams + +/// Tests for `container system version` output formats and build type detection. +@Suite +struct TestCLIVersion { + struct VersionInfo: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + } + + struct VersionOutput: Codable { + let version: String + let buildType: String + let commit: String + let appName: String + let server: VersionInfo? + } + + private func expectedBuildType() -> String { + #if DEBUG + return "debug" + #else + return "release" + #endif + } + + @Test func defaultDisplaysTable() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "version"]) + #expect(result.status == 0, "system version should succeed, stderr: \(result.error)") + #expect(!result.output.isEmpty) + + let lines = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + #expect( + lines[0].contains("COMPONENT") && lines[0].contains("VERSION") + && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) + #expect(lines[1].hasPrefix("container")) + + let expected = expectedBuildType() + #expect(lines.joined(separator: "\n").contains(" \(expected) ")) + } + } + + @Test func jsonFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "version", "--format", "json"]) + #expect(result.status == 0, "system version --format json should succeed, stderr: \(result.error)") + #expect(!result.output.isEmpty) + + let decoded = try JSONDecoder().decode([VersionOutput].self, from: result.outputData) + #expect(decoded[0].appName == "container") + #expect(!decoded[0].version.isEmpty) + #expect(!decoded[0].commit.isEmpty) + #expect(decoded[0].buildType == expectedBuildType()) + } + } + + @Test func yamlFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "version", "--format", "yaml"]) + #expect(result.status == 0, "system version --format yaml should succeed, stderr: \(result.error)") + #expect(!result.output.isEmpty) + + let decoded = try YAMLDecoder().decode([VersionOutput].self, from: result.outputData) + #expect(decoded[0].appName == "container") + #expect(!decoded[0].version.isEmpty) + #expect(!decoded[0].commit.isEmpty) + #expect(decoded[0].buildType == expectedBuildType()) + } + } + + @Test func explicitTableFormat() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "version", "--format", "table"]) + #expect(result.status == 0, "system version --format table should succeed, stderr: \(result.error)") + #expect(!result.output.isEmpty) + + let lines = result.output.trimmingCharacters(in: .whitespacesAndNewlines) + .components(separatedBy: .newlines) + #expect(lines.count >= 2) + #expect( + lines[0].contains("COMPONENT") && lines[0].contains("VERSION") + && lines[0].contains("BUILD") && lines[0].contains("COMMIT")) + } + } + + @Test func buildTypeMatchesBinary() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["system", "version", "--format", "json"]) + #expect(result.status == 0, "version --format json should succeed, stderr: \(result.error)") + + let decoded = try JSONDecoder().decode([VersionOutput].self, from: result.outputData) + let expected = expectedBuildType() + #expect( + decoded[0].buildType == expected, + "Expected build type \(expected) but got \(decoded[0].buildType)") + } + } +} diff --git a/Tests/IntegrationTests/Utilities/CommandResult.swift b/Tests/IntegrationTests/Utilities/CommandResult.swift new file mode 100644 index 0000000..0ac083f --- /dev/null +++ b/Tests/IntegrationTests/Utilities/CommandResult.swift @@ -0,0 +1,46 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +struct CommandResult: Sendable { + let outputData: Data + let errorData: Data + let status: Int32 + + var output: String { + String(data: outputData, encoding: .utf8) ?? "" + } + + var error: String { + String(data: errorData, encoding: .utf8) ?? "" + } + + @discardableResult + func check(_ message: String? = nil) throws -> CommandResult { + guard status == 0 else { + let detail = message ?? error.trimmingCharacters(in: .whitespacesAndNewlines) + throw CommandError.nonZeroExit(status, detail) + } + return self + } +} + +enum CommandError: Error { + case binaryNotFound + case executionFailed(String) + case nonZeroExit(Int32, String) +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift new file mode 100644 index 0000000..e628cd4 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+ContainerHelpers.swift @@ -0,0 +1,175 @@ +//===----------------------------------------------------------------------===// +// 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 +import SystemPackage + +// MARK: - Inspect types + +extension ContainerFixture { + /// Decoded output of `container inspect `. + struct InspectOutput: Codable { + struct Status: Codable { + let state: String + let networks: [ContainerResource.Attachment] + } + let configuration: ContainerConfiguration + let status: Status + var networks: [ContainerResource.Attachment] { status.networks } + } +} + +// MARK: - Container lifecycle helpers + +extension ContainerFixture { + + /// `-e` flags forwarding proxy env vars into container commands. + var proxyEnvironmentArgs: [String] { + let vars = Set(["HTTP_PROXY", "http_proxy", "HTTPS_PROXY", "https_proxy", "NO_PROXY", "no_proxy"]) + return ProcessInfo.processInfo.environment + .filter { vars.contains($0.key) } + .flatMap { ["-e", "\($0.key)=\($0.value)"] } + } + + /// Starts a detached container. Uses the first warmup image when `image` is nil. + /// + /// `containerEnv` injects environment variables into the container via `-e` flags. + /// To set the CLI subprocess environment (e.g. for `--ssh`), use ``run(_:env:)`` directly. + func doLongRun( + name: String, + image: String? = nil, + args: [String] = [], + containerArgs: [String] = ["sleep", "infinity"], + autoRemove: Bool = true, + containerEnv: [String: String] = [:] + ) throws { + let imageRef = image ?? ContainerFixture.warmupImages[0] + var runArgs = ["run"] + if autoRemove { runArgs.append("--rm") } + runArgs += ["--name", name, "-d"] + runArgs += proxyEnvironmentArgs + runArgs += args + for (k, v) in containerEnv { runArgs += ["-e", "\(k)=\(v)"] } + runArgs.append(imageRef) + runArgs += containerArgs + try run(runArgs).check() + } + + /// Creates a stopped container (`container create`). + func doCreate( + name: String, + image: String? = nil, + args: [String] = ["sleep", "infinity"], + volumes: [String] = [], + networks: [String] = [], + ports: [String] = [] + ) throws { + let imageRef = image ?? ContainerFixture.warmupImages[0] + var createArgs = ["create", "--rm", "--name", name] + createArgs += proxyEnvironmentArgs + for v in volumes { createArgs += ["-v", v] } + for n in networks { createArgs += ["--network", n] } + for p in ports { createArgs += ["--publish", "\(p):\(p)"] } + createArgs.append(imageRef) + createArgs += args + try run(createArgs).check() + } + + /// Starts a stopped container. + func doStart(_ name: String) throws { + try run(["start", name]).check() + } + + /// Stops a container. Pass `signal: nil` to use the server's default. + func doStop(_ name: String, signal: String? = "SIGKILL") throws { + var args = ["stop"] + if let signal { args += ["-s", signal] } + args.append(name) + try run(args).check() + } + + /// Deletes a container. + func doRemove(_ name: String, force: Bool = false) throws { + var args = ["delete"] + if force { args.append("--force") } + args.append(name) + try run(args).check() + } + + /// Deletes a container. + /// + /// When `ignoreFailure` is `false` (default) any error is rethrown — use + /// this when the container is expected to exist and removal must succeed. + /// Set `ignoreFailure: true` in cleanup contexts where best-effort removal + /// is acceptable (e.g. the container may have already been removed). + func doRemoveIfExists(_ name: String, force: Bool = false, ignoreFailure: Bool = false) throws { + do { + try doRemove(name, force: force) + } catch { + if !ignoreFailure { throw error } + } + } + + /// Runs a command inside a container, returns stdout. Throws on non-zero exit. + @discardableResult + func doExec( + _ name: String, + cmd: [String], + detach: Bool = false, + user: String? = nil + ) throws -> String { + var args = ["exec"] + args += proxyEnvironmentArgs + if detach { args.append("-d") } + if let user { args += ["-u", user] } + args.append(name) + args += cmd + return try run(args).check().output + } + + /// Exports a container filesystem to a tar archive at `path`. + func doExport(_ name: String, to path: FilePath) throws { + try run(["export", name, "-o", path.string]).check() + } +} + +// MARK: - Inspect helpers + +extension ContainerFixture { + + /// Returns the parsed inspect output for a container. + func inspectContainer(_ name: String) throws -> InspectOutput { + let result = try run(["inspect", name]).check() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let outputs = try decoder.decode([InspectOutput].self, from: result.outputData) + guard let first = outputs.first else { + throw CommandError.executionFailed("container '\(name)' not found in inspect output") + } + return first + } + + /// Returns the `status.state` string for a container (e.g. `"running"`, `"stopped"`). + func getContainerStatus(_ name: String) throws -> String { + try inspectContainer(name).status.state + } + + /// Returns the `configuration.id` for a container. + func getContainerId(_ name: String) throws -> String { + try inspectContainer(name).configuration.id + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift new file mode 100644 index 0000000..f8c04b1 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+ImageHelpers.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +// MARK: - Image inspect types + +extension ContainerFixture { + /// Decoded output of `container image inspect` or `container image list --format json`. + struct ImageInspectOutput: Codable { + struct Configuration: Codable { let name: String } + struct Variant: Codable { + struct Platform: Codable { + let os: String + let architecture: String + } + let platform: Platform + } + let configuration: Configuration + let variants: [Variant] + } +} + +// MARK: - Image lifecycle helpers + +extension ContainerFixture { + + /// Pulls an image. Passes optional extra args (e.g. `["--platform", "linux/amd64"]`). + func doPull(_ imageName: String, args: [String] = []) throws { + var pullArgs = ["image", "pull"] + args + pullArgs.append(imageName) + try run(pullArgs).check() + } + + /// Returns all images currently in the local store. + func doListImages() throws -> [ImageInspectOutput] { + let result = try run(["image", "list", "--format", "json"]).check() + return try JSONDecoder().decode([ImageInspectOutput].self, from: result.outputData) + } + + /// Returns true if an image with the given exact reference is present. + func isImagePresent(_ targetImage: String) throws -> Bool { + try doListImages().contains { $0.configuration.name == targetImage } + } + + /// Tags `image` with `newName`. + func doImageTag(_ image: String, newName: String) throws { + try run(["image", "tag", image, newName]).check() + } + + /// Removes the given images, or all images when `images` is `nil`. + func doRemoveImages(_ images: [String]? = nil) throws { + var args = ["image", "rm"] + if let images { args.append(contentsOf: images) } else { args.append("--all") } + try run(args).check() + } + + /// Returns the full inspect output for an image, including variant information. + func doInspectImages(_ name: String) throws -> [ImageInspectOutput] { + let result = try run(["image", "inspect", name]).check() + return try JSONDecoder().decode([ImageInspectOutput].self, from: result.outputData) + } + + /// Returns the `configuration.name` of an image. + func inspectImage(_ name: String) throws -> String { + let outputs = try doInspectImages(name) + guard let first = outputs.first else { + throw CommandError.executionFailed("image '\(name)' not found in inspect output") + } + return first.configuration.name + } + + /// Asserts that the image was successfully built and is present in the image store. + func assertImageBuilt(_ image: String) throws { + let name = try inspectImage(image) + #expect(name == image, "expected image \(image) to be present") + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+MachineHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+MachineHelpers.swift new file mode 100644 index 0000000..2f807bc --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+MachineHelpers.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +// MARK: - Machine output types + +struct MachineListItem: Codable { + let id: String + let status: String + let `default`: Bool + let ipAddress: String? + let cpus: Int + let memory: UInt64 + let diskSize: UInt64? + let createdDate: Date? +} + +struct MachineInspectOutput: Codable { + let id: String + let image: ImageDescription + let platform: Platform + let status: String + let startedDate: Date? + let createdDate: Date? + let containerId: String? + let cpus: Int + let memory: UInt64 + let homeMount: String? + let diskSize: UInt64? + let ipAddress: String? + + struct ImageDescription: Codable { + let reference: String + } + + struct Platform: Codable { + let os: String + let architecture: String + } +} + +// MARK: - Machine lifecycle helpers + +extension ContainerFixture { + + /// Runs `container machine ` and returns the result. + func runMachine(_ arguments: [String], env: [String: String] = [:]) throws -> CommandResult { + try run(["machine"] + arguments, env: env, pty: true) + } + + /// Creates a machine without booting it. + func doMachineCreate(name: String, image: String, extraArgs: [String] = []) throws { + var args = ["create", "--no-boot", "--name", name] + args += extraArgs + args.append(image) + try runMachine(args).check() + } + + /// Boots a machine by running a trivial command (which auto-boots). + func doMachineBoot(name: String) throws { + try runMachine(["run", "--root", "-n", name, "true"]).check() + } + + /// Stops a machine and returns the output (the machine name). + @discardableResult + func doMachineStop(name: String) throws -> String { + let result = try runMachine(["stop", name]).check() + return result.output.trimmingCharacters(in: .whitespacesAndNewlines) + } + + /// Removes a machine. + func doMachineRemove(name: String) throws { + try runMachine(["rm", name]).check() + } + + /// Silently stops and removes a machine, ignoring errors. + func cleanupMachine(_ name: String) { + _ = try? runMachine(["stop", name]) + _ = try? runMachine(["rm", name]) + } + + /// Inspects a machine and decodes the JSON output. + func doMachineInspect(name: String? = nil) throws -> MachineInspectOutput { + var args = ["inspect"] + if let name { args.append(name) } + let result = try runMachine(args).check() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let results = try decoder.decode([MachineInspectOutput].self, from: result.outputData) + guard let first = results.first else { + throw CommandError.executionFailed("machine inspect returned empty array") + } + return first + } + + /// Runs a command inside a machine and returns its stdout. + @discardableResult + func doMachineRun( + name: String, + root: Bool = false, + env: [String] = [], + cwd: String? = nil, + command: [String] + ) throws -> String { + var args = ["run", "-n", name] + if root { args.append("--root") } + if let cwd { args += ["--cwd", cwd] } + for e in env { args += ["-e", e] } + args += command + let result = try runMachine(args).check() + return result.output + } + + /// Polls machine inspect until the status matches or the attempt limit is reached. + func waitForMachineStatus(_ name: String, status: String, maxAttempts: Int = 30) async throws { + for _ in 0.. NetworkInspectOutput { + let result = try run(["network", "inspect", name]).check() + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let networks = try decoder.decode([NetworkInspectOutput].self, from: result.outputData) + guard let network = networks.first else { + throw CommandError.executionFailed("network inspect returned empty array") + } + return network + } + + /// Returns an `HTTPClient` for use in network connectivity tests. + func makeHTTPClient() -> HTTPClient { + HTTPClient(eventLoopGroupProvider: .singleton) + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+SSHTestHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+SSHTestHelpers.swift new file mode 100644 index 0000000..2786a2d --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+SSHTestHelpers.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// 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 Darwin +import Foundation + +// MARK: - Fake SSH agent socket + +extension ContainerFixture { + + /// Creates a Unix-domain listening socket at a short path under `/tmp`, + /// suitable for use as `SSH_AUTH_SOCK` in tests that exercise `--ssh` forwarding. + /// + /// The path is `/tmp/{testID}-ssh/ssh-auth.sock`, which fits comfortably within + /// `sockaddr_un.sun_path`'s 104-byte limit on macOS regardless of project depth. + /// + /// An accept loop runs on a background thread, closing each incoming connection. + /// `accept()` is a blocking syscall, so `Thread` is appropriate here — using + /// `Task.detached` would block a cooperative thread without yielding. + /// + /// The socket fd and its parent directory are auto-cleaned on fixture scope exit; + /// closing the listening fd is what causes the accept loop to exit. + /// + /// Returns the socket path. Pass it as `SSH_AUTH_SOCK` in the CLI process env. + func makeFakeSSHAgentSocket() throws -> String { + let socketDir = "/tmp/\(testID)-ssh" + try FileManager.default.createDirectory( + atPath: socketDir, withIntermediateDirectories: true, attributes: nil) + let socketPath = socketDir + "/ssh-auth.sock" + + let serverFd = socket(AF_UNIX, SOCK_STREAM, 0) + guard serverFd >= 0 else { + throw CommandError.executionFailed("socket() failed with errno \(errno)") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + withUnsafeMutableBytes(of: &addr.sun_path) { bytes in + socketPath.withCString { cStr in + bytes.copyMemory( + from: UnsafeRawBufferPointer(start: cStr, count: socketPath.utf8.count + 1)) + } + } + let bindResult = withUnsafePointer(to: addr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { + bind(serverFd, $0, socklen_t(MemoryLayout.size)) + } + } + guard bindResult == 0 else { + let savedErrno = errno + Darwin.close(serverFd) + throw CommandError.executionFailed("bind() failed with errno \(savedErrno) for path \(socketPath)") + } + guard listen(serverFd, 5) == 0 else { + let savedErrno = errno + Darwin.close(serverFd) + throw CommandError.executionFailed("listen() failed with errno \(savedErrno)") + } + + let acceptThread = Thread { + while true { + let clientFd = accept(serverFd, nil, nil) + if clientFd < 0 { break } + Darwin.close(clientFd) + } + } + acceptThread.start() + + addCleanup { + Darwin.close(serverFd) + try? FileManager.default.removeItem(atPath: socketDir) + } + + return socketPath + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift new file mode 100644 index 0000000..8657d76 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+SystemHelpers.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerPersistence +import Foundation +import SystemPackage +import TOML + +// MARK: - System helpers + +extension ContainerFixture { + + /// Returns the decoded system configuration from `container system property list`. + func getSystemConfig() throws -> ContainerSystemConfig { + let result = try run(["system", "property", "list", "--format", "toml"]).check() + return try TOMLDecoder().decode(ContainerSystemConfig.self, from: Data(result.output.utf8)) + } + + /// Creates a temporary directory, calls `body` with its URL, then removes it + /// regardless of whether `body` throws. + func withTempDir(_ body: (URL) async throws -> T) async throws -> T { + let dir = FileManager.default.temporaryDirectory + .appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: dir) } + return try await body(dir) + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift b/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift new file mode 100644 index 0000000..6bf96be --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture+VolumeHelpers.swift @@ -0,0 +1,87 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation + +// MARK: - Volume lifecycle helpers + +extension ContainerFixture { + + /// Creates a named volume, optionally with extra `--opt` arguments. + func doVolumeCreate(_ name: String, opts: [String] = []) throws { + var args = ["volume", "create"] + for opt in opts { args += ["--opt", opt] } + args.append(name) + try run(args).check() + } + + /// Deletes a volume, throwing on failure. + func doVolumeDelete(_ name: String) throws { + try run(["volume", "rm", name]).check() + } + + /// Deletes a volume, silently ignoring errors. + func doVolumeDeleteIfExists(_ name: String) { + _ = try? run(["volume", "rm", name]) + } + + /// Returns `true` if `volume rm` exits non-zero (i.e. the delete was blocked). + func doesVolumeDeleteFail(_ name: String) throws -> Bool { + try run(["volume", "rm", name]).status != 0 + } + + /// Returns the names of all volume attachments on a container + /// (the UUID name for anonymous volumes, the explicit name for named volumes). + func getContainerMountedVolumeNames(_ containerName: String) throws -> [String] { + let inspect = try inspectContainer(containerName) + return inspect.configuration.mounts.compactMap { mount in + if case .volume(let name, _, _, _) = mount.type { return name } + return nil + } + } + + /// Returns the names of all anonymous volumes (UUID-format names) in the local store. + func getAnonymousVolumeNames() throws -> [String] { + let result = try run(["volume", "list", "--quiet"]).check() + return result.output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty && isAnonymousVolumeName($0) } + } + + /// Deletes all currently known anonymous volumes. Useful before count-based assertions. + func doCleanupAnonymousVolumes() { + for vol in (try? getAnonymousVolumeNames()) ?? [] { + doVolumeDeleteIfExists(vol) + } + } + + /// Returns `true` if a volume with the given name appears in `volume list`. + func volumeExists(_ name: String) throws -> Bool { + let result = try run(["volume", "list", "--quiet"]).check() + return result.output + .components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .contains(name) + } + + /// Returns `true` if `name` has the UUID format used for anonymous volumes. + private func isAnonymousVolumeName(_ name: String) -> Bool { + let pattern = #"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"# + guard let regex = try? Regex(pattern) else { return false } + return (try? regex.firstMatch(in: name)) != nil + } +} diff --git a/Tests/IntegrationTests/Utilities/ContainerFixture.swift b/Tests/IntegrationTests/Utilities/ContainerFixture.swift new file mode 100644 index 0000000..9b7de83 --- /dev/null +++ b/Tests/IntegrationTests/Utilities/ContainerFixture.swift @@ -0,0 +1,377 @@ +//===----------------------------------------------------------------------===// +// 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 ContainerLog +import Darwin +import Foundation +import Logging +import Synchronization +import SystemPackage +import Testing + +/// Per-test fixture for CLI integration tests. +/// +/// Open a fixture scope with ``ContainerFixture/with(_:)``. Every resource +/// created during the scope is tracked and torn down on exit — whether the +/// test passes, fails, or throws. +/// +/// ## Unstructured API (Tier 1) +/// +/// Primitives that execute commands or register cleanup without enforcing +/// a scope boundary. The caller owns the resource lifetime. +/// +/// - ``run(_:stdin:currentDirectory:env:)`` runs the CLI and returns a +/// ``CommandResult``; call ``CommandResult/check(_:)`` to assert success. +/// - ``addCleanup(_:)`` registers an async closure that runs LIFO on scope exit. +/// - ``copyWarmupImage(_:)`` tags a pre-warmed image to a test-local name and +/// auto-registers its removal. +/// - ``waitForContainerRunning(_:attempts:)`` polls until a container is +/// `running`; required when using lower-level create/start helpers directly. +/// +/// ## Structured API (Tier 2) +/// +/// Scoped helpers that manage resource lifetime via a closure boundary. +/// Resources are torn down when the closure exits regardless of whether it +/// throws. +/// +/// - ``withContainer(image:tag:runArgs:containerArgs:autoRemove:_:)`` starts a +/// detached container, waits for `running`, calls the body, then stops (and +/// optionally deletes) it on exit. +/// +/// ## Choosing a tier +/// +/// Prefer Tier 2 for common patterns — it eliminates cleanup boilerplate and +/// prevents leaks. Drop to Tier 1 when a test exercises a specific +/// create/start/stop sequence, needs low-level control, or uses a resource +/// pattern the structured helpers don't cover. +final class ContainerFixture: Sendable { + + // MARK: - Configuration + + /// Images preloaded by the ``ImageWarmup`` suite before concurrent tests run. + /// Add new commonly-used images here; the warmup pass pulls them in parallel. + static let warmupImages: [String] = [ + "ghcr.io/linuxcontainers/alpine:3.20", + "ghcr.io/linuxcontainers/alpine:3.18", + "ghcr.io/containerd/busybox:1.36", + ] + + // MARK: - State + + /// Short random identifier prefixed to every resource this test creates. + let testID: String + + /// Scratch directory for build inputs, test data, and command output. + /// Created at fixture init; removed on cleanup unless `CLITEST_PRESERVE_SCRATCH=true`. + let testDir: FilePath + + /// Logger for this fixture scope. Tests may emit diagnostic messages via this logger. + let log: Logger + + // MARK: - Unstructured API + + /// Runs `body` with a fresh fixture, then tears down all registered resources. + /// + /// Cleanup runs in LIFO order regardless of whether `body` throws. + @discardableResult + static func with(_ body: (ContainerFixture) async throws -> T) async throws -> T { + let testID = String(UUID().uuidString.prefix(8)).lowercased() + + let scratchRoot = + ProcessInfo.processInfo.environment["CLITEST_SCRATCH_ROOT"] + .map { FilePath($0) } + ?? FilePath(FileManager.default.temporaryDirectory.path) + + let testName = + Test.current.map { $0.name.hasSuffix("()") ? String($0.name.dropLast(2)) : $0.name } + ?? testID + let suiteName = Test.current.map { "\(type(of: $0))" } ?? "unknown" + + // Name the scratch directory so it's immediately identifiable when browsing: + // {sanitizedTestName}-{testID} + let safeName = testName.replacingOccurrences( + of: "[^a-zA-Z0-9]", with: "-", options: .regularExpression) + let testDir = scratchRoot.appending("\(safeName)-\(testID)") + try FileManager.default.createDirectory( + atPath: testDir.string, withIntermediateDirectories: true, attributes: nil) + + var logger = Logger(label: "com.apple.container.test") { label in + if let root = ProcessInfo.processInfo.environment["CLITEST_LOG_ROOT"], !root.isEmpty { + let path = + FilePath(root) + .appending("clitests") + .appending(suiteName) + .appending(testName + ".log") + if let handler = try? FileLogHandler(label: label, category: "clitests", path: path) { + return handler + } + } + return StreamLogHandler.standardOutput(label: label) + } + logger[metadataKey: "testID"] = "\(testID)" + + let fixture = ContainerFixture(testID: testID, testDir: testDir, log: logger) + + if ProcessInfo.processInfo.environment["CLITEST_PRESERVE_SCRATCH"] != "true" { + fixture.addCleanup { + try? FileManager.default.removeItem(atPath: testDir.string) + } + } + + do { + let result = try await body(fixture) + await fixture.runCleanup() + return result + } catch { + await fixture.runCleanup() + throw error + } + } + + /// Registers a cleanup closure to run when the fixture scope exits. + /// Closures execute in LIFO order. + func addCleanup(_ task: @escaping @Sendable () async throws -> Void) { + cleanupTasks.withLock { $0.append(task) } + } + + /// Runs the container CLI with the given arguments and returns the result. + /// + /// Throws ``CommandError`` only for execution failures (binary not found, + /// process launch error). A non-zero exit status is represented in + /// ``CommandResult/status`` — call ``CommandResult/check(_:)`` to turn it + /// into a thrown error. + func run( + _ arguments: [String], + stdin: Data? = nil, + currentDirectory: FilePath? = nil, + env: [String: String] = [:], + pty: Bool = false + ) throws -> CommandResult { + let seq = Self.commandSeq.withLock { n in + defer { n += 1 } + return n + } + log.info( + "command start", + metadata: ["seq": "\(seq)", "args": "\(arguments.joined(separator: " "))"]) + + let process = Process() + process.executableURL = try executableURL + process.arguments = arguments + if let dir = currentDirectory { process.currentDirectoryURL = URL(filePath: dir.string) } + if !env.isEmpty { + var e = ProcessInfo.processInfo.environment + for (k, v) in env { e[k] = v } + process.environment = e + } + + // When pty is true, allocate a PTY slave for stdin so the child process + // sees a real terminal (satisfying isatty checks and tcgetattr calls). + // stdout/stderr still go to temp files so output is captured separately. + var masterFd: Int32 = -1 + let inputPipe = Pipe() + if pty { + var slaveFd: Int32 = -1 + guard openpty(&masterFd, &slaveFd, nil, nil, nil) == 0 else { + throw CommandError.executionFailed("openpty failed: errno \(errno)") + } + process.standardInput = FileHandle(fileDescriptor: slaveFd, closeOnDealloc: true) + } else { + process.standardInput = inputPipe + } + + // Write stdout/stderr to temp files to avoid blocking on full pipe buffers. + let tmpDir = FilePath(FileManager.default.temporaryDirectory.path) + .appending(UUID().uuidString) + try FileManager.default.createDirectory( + atPath: tmpDir.string, withIntermediateDirectories: true, attributes: nil) + defer { try? FileManager.default.removeItem(atPath: tmpDir.string) } + + let stdoutPath = tmpDir.appending("stdout") + let stderrPath = tmpDir.appending("stderr") + FileManager.default.createFile(atPath: stdoutPath.string, contents: nil) + FileManager.default.createFile(atPath: stderrPath.string, contents: nil) + + let stdoutHandle = try FileHandle(forWritingTo: URL(filePath: stdoutPath.string)) + defer { try? stdoutHandle.close() } + let stderrHandle = try FileHandle(forWritingTo: URL(filePath: stderrPath.string)) + defer { try? stderrHandle.close() } + + process.standardOutput = stdoutHandle + process.standardError = stderrHandle + + do { + try process.run() + } catch { + throw CommandError.executionFailed("process launch failed: \(error)") + } + if pty { + // Write through the master side; the kernel tty buffers input until the + // child reads it, so this works even before the child is ready (e.g. a + // shell that hasn't started reading stdin yet). Master stays open until + // after the process exits so the slave doesn't receive SIGHUP prematurely. + if let data = stdin { FileHandle(fileDescriptor: masterFd, closeOnDealloc: false).write(data) } + } else { + if let data = stdin { inputPipe.fileHandleForWriting.write(data) } + inputPipe.fileHandleForWriting.closeFile() + } + process.waitUntilExit() + if masterFd >= 0 { Darwin.close(masterFd) } + + let outputData = (try? Data(contentsOf: URL(filePath: stdoutPath.string))) ?? Data() + let errorData = (try? Data(contentsOf: URL(filePath: stderrPath.string))) ?? Data() + + log.info( + "command end", + metadata: ["seq": "\(seq)", "status": "\(process.terminationStatus)"]) + + return CommandResult( + outputData: outputData, + errorData: errorData, + status: process.terminationStatus) + } + + /// Tags a warmup image to a test-local reference and registers its removal. + /// + /// The returned name is `{testID}-{imageName}:{tag}`, e.g. + /// `a3f7c2b1-alpine:3.20`. Tests operate freely on this reference; + /// the canonical warmup image is never touched. + func copyWarmupImage(_ canonical: String) throws -> String { + let lastComponent = canonical.split(separator: "/").last.map(String.init) ?? canonical + let parts = lastComponent.split(separator: ":", maxSplits: 1) + let name = String(parts[0]) + let tag = parts.count > 1 ? String(parts[1]) : "latest" + let localRef = "\(testID)-\(name):\(tag)" + + try run(["image", "tag", canonical, localRef]).check() + addCleanup { + _ = try? self.run(["image", "rm", localRef]) + } + return localRef + } + + /// Polls until the named container reaches the `running` state. + /// + /// Call this directly only when using ``doCreate(_:image:args:volumes:networks:ports:)`` + /// and ``doStart(_:)`` — ``withContainer(image:tag:runArgs:containerArgs:autoRemove:_:)`` + /// waits automatically. + func waitForContainerRunning(_ name: String, attempts: Int = 30) async throws { + for _ in 0.. Void + ) async throws { + let name = "\(testID)-\(tag)" + var args = ["run", "--name", name, "-d"] + if autoRemove { args.append("--rm") } + args += runArgs + [image] + containerArgs + try run(args).check() + defer { + _ = try? run(["stop", "-s", "SIGKILL", name]) + if !autoRemove { _ = try? run(["delete", name]) } + } + try await waitForContainerRunning(name) + try await body(name) + } + + // MARK: - Private + + private let cleanupTasks: Mutex<[@Sendable () async throws -> Void]> = .init([]) + private static let commandSeq: Mutex = .init(0) + + private init(testID: String, testDir: FilePath, log: Logger) { + self.testID = testID + self.testDir = testDir + self.log = log + } + + private func runCleanup() async { + let tasks = cleanupTasks.withLock { tasks -> [@Sendable () async throws -> Void] in + let reversed = Array(tasks.reversed()) + tasks.removeAll() + return reversed + } + for task in tasks { + try? await task() + } + } + + private var executableURL: URL { + get throws { + let path: FilePath + if let env = ProcessInfo.processInfo.environment["CONTAINER_CLI_PATH"] { + path = FilePath(env) + } else { + let candidate = FilePath(FileManager.default.currentDirectoryPath) + .appending("bin").appending("container") + guard FileManager.default.fileExists(atPath: candidate.string) else { + throw CommandError.binaryNotFound + } + path = candidate + } + return URL(filePath: path.string) + } + } +} + +// MARK: - Retry + +extension ContainerFixture { + /// Retries `body` up to `attempts` times, sleeping `delay` between each attempt. + /// + /// - Returns when `body` returns `true`. + /// - Retries when `body` returns `false`. + /// - Propagates immediately (aborting the loop) when `body` throws. + /// + /// Throws `CommandError.executionFailed` if all attempts return `false`. + func retry(attempts: Int, delay: Duration = .seconds(1), _ body: () async throws -> Bool) async throws { + for attempt in 1...attempts { + if try await body() { return } + print("retry: attempt \(attempt)/\(attempts) not yet ready") + if attempt < attempts { + try await Task.sleep(for: delay) + } + } + throw CommandError.executionFailed("retry: condition not met after \(attempts) attempts") + } +} diff --git a/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift b/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift new file mode 100644 index 0000000..ca7306e --- /dev/null +++ b/Tests/IntegrationTests/Volumes/TestCLIAnonymousVolumes.swift @@ -0,0 +1,252 @@ +//===----------------------------------------------------------------------===// +// 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 +import Testing + +/// Tests for anonymous (UUID-named) volumes. +/// +/// Each test discovers its volumes via container inspect rather than counting +/// global volume state, so the suite runs in the concurrent pass. +@Suite +struct TestCLIAnonymousVolumes { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testAnonymousVolumeCreationAndPersistence() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1, "should have exactly one anonymous volume") + let volumeID = volumeIDs[0] + f.addCleanup { f.doVolumeDeleteIfExists(volumeID) } + + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(volumeID), "anonymous volume should persist after container removal") + } + } + + @Test func testAnonymousVolumePersistenceWithoutRm() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c1" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + _ = try f.doExec(c, cmd: ["sh", "-c", "echo 'persistent-data' > /data/test.txt"]) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { f.doVolumeDeleteIfExists(volumeID) } + + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(volumeID), "anonymous volume should persist without --rm") + + let c2 = "\(f.testID)-c2" + try f.doLongRun(name: c2, image: image, args: ["-v", "\(volumeID):/data"], autoRemove: false) + try await f.waitForContainerRunning(c2) + let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "persistent-data") + try f.doStop(c2) + try f.doRemove(c2) + } + } + + @Test func testMultipleAnonymousVolumes() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["-v", "/data1", "-v", "/data2", "-v", "/data3"], autoRemove: false) + try await f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + #expect(volumeIDs.count == 3, "should have 3 anonymous volumes") + f.addCleanup { for v in volumeIDs { f.doVolumeDeleteIfExists(v) } } + + try f.doStop(c) + try f.doRemove(c) + for v in volumeIDs { #expect(try f.volumeExists(v), "volume \(v) should persist") } + } + } + + @Test func testAnonymousMountSyntax() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun( + name: c, image: image, + args: ["--mount", "type=volume,dst=/mydata"], autoRemove: false) + try await f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + #expect(volumeIDs.count == 1, "should have one anonymous volume from --mount syntax") + f.addCleanup { for v in volumeIDs { f.doVolumeDeleteIfExists(v) } } + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(volumeIDs[0])) + } + } + + @Test func testAnonymousVolumeUUIDFormat() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + + // Capture volume IDs before any stop/remove so cleanup and assert can use them. + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + f.doVolumeDeleteIfExists(volumeID) + } + + #expect(volumeID.count == 36, "volume name should be 36 characters (UUID format)") + } + } + + @Test func testAnonymousVolumeMetadata() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + + // Capture volume ID before stop/remove. + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + f.doVolumeDeleteIfExists(volumeID) + } + + let result = try f.run(["volume", "list", "--format", "json"]).check() + #expect(result.output.contains("\"creationDate\"")) + #expect(!result.output.contains("\"createdAt\"")) + + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + let volumes = try decoder.decode([VolumeResource].self, from: result.outputData) + let anonVolume = volumes.first { $0.name == volumeID } + try #require(anonVolume != nil, "should find anonymous volume in list") + #expect(anonVolume!.isAnonymous == true) + } + } + + @Test func testAnonymousVolumeListDisplay() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let namedVol = "\(f.testID)-namedvol" + let c = "\(f.testID)-c" + try f.doVolumeCreate(namedVol) + f.addCleanup { f.doVolumeDeleteIfExists(namedVol) } + + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + + // Capture volume IDs while container is running. + let volumeIDs = try f.getContainerMountedVolumeNames(c) + f.addCleanup { + try? f.doStop(c) + try? f.doRemove(c) + for v in volumeIDs { f.doVolumeDeleteIfExists(v) } + } + + let result = try f.run(["volume", "list"]).check() + #expect(result.output.contains("TYPE")) + #expect(result.output.contains("named")) + #expect(result.output.contains("anonymous")) + #expect(result.output.contains(namedVol)) + } + } + + @Test func testAnonymousVolumeMixedWithNamedVolume() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let namedVol = "\(f.testID)-namedvol" + let c = "\(f.testID)-c" + try f.doVolumeCreate(namedVol) + f.addCleanup { f.doVolumeDeleteIfExists(namedVol) } + + try f.doLongRun( + name: c, image: image, + args: ["-v", "\(namedVol):/named", "-v", "/anon"], autoRemove: false) + try await f.waitForContainerRunning(c) + + let allVolumeIDs = try f.getContainerMountedVolumeNames(c) + let anonVols = allVolumeIDs.filter { $0 != namedVol } + #expect(anonVols.count == 1, "should have one anonymous volume") + f.addCleanup { for v in anonVols { f.doVolumeDeleteIfExists(v) } } + + try f.doStop(c) + try f.doRemove(c) + #expect(try f.volumeExists(namedVol), "named volume should persist") + #expect(try f.volumeExists(anonVols[0]), "anonymous volume should persist") + } + } + + @Test func testAnonymousVolumeManualDeletion() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + + try f.doStop(c) + try f.doRemove(c) + let result = try f.run(["volume", "rm", volumeID]) + #expect(result.status == 0, "manual deletion of unmounted anonymous volume should succeed") + #expect(!(try f.volumeExists(volumeID))) + } + } + + @Test func testAnonymousVolumeDetachedMode() async throws { + try await ContainerFixture.with { f in + let image = try f.copyWarmupImage(alpine) + let c = "\(f.testID)-c" + try f.doLongRun(name: c, image: image, args: ["-v", "/data"], autoRemove: true) + try await f.waitForContainerRunning(c) + + // Capture volume IDs while the container is still running; --rm means + // doStop will also remove it. + let volumeIDs = try f.getContainerMountedVolumeNames(c) + try #require(volumeIDs.count == 1) + let volumeID = volumeIDs[0] + f.addCleanup { f.doVolumeDeleteIfExists(volumeID) } + + try f.doStop(c) + #expect(try f.volumeExists(volumeID), "anonymous volume should persist after container removal") + } + } +} diff --git a/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift b/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift new file mode 100644 index 0000000..f30c234 --- /dev/null +++ b/Tests/IntegrationTests/Volumes/TestCLIVolumes.swift @@ -0,0 +1,262 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite +struct TestCLIVolumes { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testVolumeDataPersistenceAcrossContainers() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + let image = alpine + f.addCleanup { + f.doVolumeDeleteIfExists(vol) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + } + + try f.doVolumeCreate(vol) + try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try await f.waitForContainerRunning(c1) + _ = try f.doExec(c1, cmd: ["sh", "-c", "echo 'persistent-data-test' > /data/test.txt"]) + try f.doStop(c1) + try f.doRemove(c1) + try f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try await f.waitForContainerRunning(c2) + let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "persistent-data-test") + try f.doStop(c2) + try f.doRemove(c2) + try f.doVolumeDelete(vol) + } + } + + @Test func testVolumeSharedAccessConflict() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + let image = alpine + f.addCleanup { + try? f.doStop(c1) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try await f.waitForContainerRunning(c1) + + let result = try f.run(["run", "--name", c2, "-v", "\(vol):/data", image, "sleep", "infinity"]) + #expect(result.status != 0, "second container should fail when volume is already in use") + + try f.doStop(c1) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + } + + @Test func testVolumeDeleteProtectionWhileInUse() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c = "\(f.testID)-c1" + let image = alpine + f.addCleanup { + try? f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doLongRun(name: c, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + + #expect(try f.doesVolumeDeleteFail(vol), "volume delete should fail while in use") + + try f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + try f.doVolumeDelete(vol) + } + } + + @Test func testVolumeDeleteProtectionWithCreatedContainer() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c = "\(f.testID)-c1" + let image = alpine + f.addCleanup { + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doCreate(name: c, image: image, volumes: ["\(vol):/mnt/data"]) + try await Task.sleep(for: .seconds(1)) + + #expect(try f.doesVolumeDeleteFail(vol), "volume delete should fail when used by created container") + + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + } + + @Test func testVolumeBasicOperations() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + + try f.doVolumeCreate(vol) + + let listResult = try f.run(["volume", "list", "--quiet"]).check() + let volumes = listResult.output.components(separatedBy: .newlines) + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + #expect(volumes.contains(vol), "created volume should appear in list") + + let inspectResult = try f.run(["volume", "inspect", vol]).check() + #expect(inspectResult.output.contains(vol)) + #expect(inspectResult.output.contains("\"creationDate\"")) + #expect(!inspectResult.output.contains("\"createdAt\"")) + + try f.doVolumeDelete(vol) + } + } + + @Test func testImplicitNamedVolumeCreation() async throws { + try await ContainerFixture.with { f in + let c = "\(f.testID)-c1" + let vol = "\(f.testID)-autovolume" + let image = alpine + f.addCleanup { + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + #expect(!(try f.volumeExists(vol)), "volume should not exist initially") + + let result = try f.run(["run", "--name", c, "-v", "\(vol):/data", image, "echo", "test"]) + #expect(result.status == 0, "should succeed and auto-create named volume") + #expect(result.output.contains("test")) + #expect(try f.volumeExists(vol), "volume should be created") + } + } + + @Test func testImplicitNamedVolumeReuse() async throws { + try await ContainerFixture.with { f in + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + let vol = "\(f.testID)-sharedvolume" + let image = alpine + f.addCleanup { + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + let r1 = try f.run(["run", "--name", c1, "-v", "\(vol):/data", image, "sh", "-c", "echo 'first' > /data/test.txt"]) + #expect(r1.status == 0) + let r2 = try f.run(["run", "--name", c2, "-v", "\(vol):/data", image, "cat", "/data/test.txt"]) + #expect(r2.status == 0) + } + } + + @Test func testVolumeDeleteNoArgs() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "delete"]) + #expect(result.status != 0) + } + } + + @Test func testVolumeDeleteExplicitNamesConflictWithAll() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "delete", "--all", "some-volume"]) + #expect(result.status != 0) + #expect(result.error.contains("conflict")) + } + } + + @Test func testVolumeInspectMissingFails() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "inspect", "definitely-missing-volume"]) + #expect(result.status != 0) + #expect(result.error.contains("volume not found")) + } + } + + @Test func testVolumeCreateWithJournalOrdered() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + try f.doVolumeCreate(vol, opts: ["journal=ordered"]) + #expect(try f.volumeExists(vol)) + } + } + + @Test func testVolumeCreateWithJournalAndSize() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + try f.doVolumeCreate(vol, opts: ["journal=writeback:64m"]) + } + } + + @Test func testVolumeCreateWithInvalidJournalModeErrors() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + f.addCleanup { f.doVolumeDeleteIfExists(vol) } + let result = try f.run(["volume", "create", "--opt", "journal=none", vol]) + #expect(result.status != 0) + } + } + + @Test func testJournaledVolumeDataPersistence() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c1 = "\(f.testID)-c1" + let c2 = "\(f.testID)-c2" + let image = alpine + f.addCleanup { + try? f.doStop(c1) + try? f.doRemoveIfExists(c1, force: true, ignoreFailure: true) + try? f.doStop(c2) + try? f.doRemoveIfExists(c2, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol, opts: ["journal=ordered"]) + try f.doLongRun(name: c1, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try await f.waitForContainerRunning(c1) + _ = try f.doExec(c1, cmd: ["sh", "-c", "echo 'journaled-data' > /data/test.txt"]) + try f.doStop(c1) + try f.doRemove(c1) + try f.doLongRun(name: c2, image: image, args: ["-v", "\(vol):/data"], autoRemove: false) + try await f.waitForContainerRunning(c2) + let output = try f.doExec(c2, cmd: ["cat", "/data/test.txt"]) + .trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == "journaled-data") + try f.doStop(c2) + try f.doRemove(c2) + try f.doVolumeDelete(vol) + } + } +} diff --git a/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift b/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift new file mode 100644 index 0000000..5467534 --- /dev/null +++ b/Tests/IntegrationTests/Volumes/TestCLIVolumesSerial.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// 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 Foundation +import Testing + +@Suite(.serialized) +struct TestCLIVolumesSerial { + private let alpine = ContainerFixture.warmupImages[0] + + @Test func testVolumePruneNoVolumes() async throws { + try await ContainerFixture.with { f in + let result = try f.run(["volume", "prune"]).check() + #expect(result.error.contains("Zero KB"), "should show no space reclaimed") + } + } + + @Test func testVolumePruneUnusedVolumes() async throws { + try await ContainerFixture.with { f in + let v1 = "\(f.testID)-vol1" + let v2 = "\(f.testID)-vol2" + f.addCleanup { + f.doVolumeDeleteIfExists(v1) + f.doVolumeDeleteIfExists(v2) + } + + try f.doVolumeCreate(v1) + try f.doVolumeCreate(v2) + let list = try f.run(["volume", "list", "--quiet"]).check().output + #expect(list.contains(v1) && list.contains(v2)) + + let result = try f.run(["volume", "prune"]).check() + #expect(result.output.contains(v1)) + #expect(result.output.contains(v2)) + #expect(result.error.contains("Reclaimed")) + + let listAfter = try f.run(["volume", "list", "--quiet"]).check().output + #expect(!listAfter.contains(v1) && !listAfter.contains(v2)) + } + } + + @Test func testVolumePruneSkipsVolumeInUse() async throws { + try await ContainerFixture.with { f in + let vInUse = "\(f.testID)-inuse" + let vUnused = "\(f.testID)-unused" + let c = "\(f.testID)-c1" + try f.doPull(alpine) + let image = alpine + f.addCleanup { + try? f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vInUse) + f.doVolumeDeleteIfExists(vUnused) + } + + try f.doVolumeCreate(vInUse) + try f.doVolumeCreate(vUnused) + try f.doLongRun(name: c, image: image, args: ["-v", "\(vInUse):/data"], autoRemove: false) + try await f.waitForContainerRunning(c) + + try f.run(["volume", "prune"]).check() + + let listAfter = try f.run(["volume", "list", "--quiet"]).check().output + #expect(listAfter.contains(vInUse), "in-use volume should NOT be pruned") + #expect(!listAfter.contains(vUnused), "unused volume should be pruned") + + try f.doStop(c) + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vInUse) + } + } + + @Test func testVolumePruneSkipsVolumeAttachedToStoppedContainer() async throws { + try await ContainerFixture.with { f in + let vol = "\(f.testID)-vol" + let c = "\(f.testID)-c1" + try f.doPull(alpine) + let image = alpine + f.addCleanup { + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + f.doVolumeDeleteIfExists(vol) + } + + try f.doVolumeCreate(vol) + try f.doCreate(name: c, image: image, volumes: ["\(vol):/data"]) + try await Task.sleep(for: .seconds(1)) + + try f.run(["volume", "prune"]).check() + #expect(try f.volumeExists(vol), "volume attached to stopped container should NOT be pruned") + + try? f.doRemoveIfExists(c, force: true, ignoreFailure: true) + try f.run(["volume", "prune"]).check() + #expect(!(try f.volumeExists(vol)), "volume should be pruned after container is deleted") + } + } + +} diff --git a/Tests/IntegrationTests/Warmup/ImageWarmup.swift b/Tests/IntegrationTests/Warmup/ImageWarmup.swift new file mode 100644 index 0000000..71a910a --- /dev/null +++ b/Tests/IntegrationTests/Warmup/ImageWarmup.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 Testing + +/// Pulls each image in ``ContainerFixture/warmupImages`` in parallel before +/// concurrent integration tests run. The Makefile's warmup pass runs this +/// suite first so that ``ContainerFixture/copyWarmupImage(_:)`` can tag +/// from a pre-populated store rather than pulling on demand. +@Suite +struct ImageWarmup { + @Test(arguments: ContainerFixture.warmupImages) + func pull(image: String) async throws { + try await ContainerFixture.with { f in + try f.run(["image", "pull", image]).check("failed to pull \(image)") + } + } +} diff --git a/Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift b/Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift new file mode 100644 index 0000000..1d1e322 --- /dev/null +++ b/Tests/SocketForwarderTests/ConnectHandlerRaceTest.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// 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 NIO +import Testing + +@testable import SocketForwarder + +struct ConnectHandlerRaceTest { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + + @Test + func testRapidConnectDisconnect() async throws { + let requestCount = 500 + + let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let server = TCPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup) + let serverChannel = try await server.run().get() + let actualServerAddress = try #require(serverChannel.localAddress) + + let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let forwarder = try TCPForwarder( + proxyAddress: proxyAddress, + serverAddress: actualServerAddress, + eventLoopGroup: eventLoopGroup + ) + let forwarderResult = try await forwarder.run().get() + let actualProxyAddress = try #require(forwarderResult.proxyAddress) + + try await withThrowingTaskGroup(of: Void.self) { group in + for _ in 0..(size: 3) + #expect(cache.count == 0) + + #expect(cache.put(key: "foo", value: "1") == nil) + #expect(cache.count == 1) + + #expect(cache.put(key: "bar", value: "2") == nil) + #expect(cache.count == 2) + + #expect(cache.put(key: "baz", value: "3") == nil) + #expect(cache.count == 3) + + let replaced = try #require(cache.put(key: "bar", value: "4")) + #expect(replaced == ("bar", "2")) + #expect(cache.count == 3) + + let firstEvicted = try #require(cache.put(key: "qux", value: "5")) + #expect(firstEvicted == ("foo", "1")) + #expect(cache.count == 3) + + let secondEvicted = try #require(cache.put(key: "quux", value: "6")) + #expect(secondEvicted == ("baz", "3")) + #expect(cache.count == 3) + + #expect(cache.get("foo") == nil) + #expect(cache.get("bar") == "4") + #expect(cache.get("baz") == nil) + #expect(cache.get("qux") == "5") + #expect(cache.get("quux") == "6") + } +} diff --git a/Tests/SocketForwarderTests/TCPEchoHandler.swift b/Tests/SocketForwarderTests/TCPEchoHandler.swift new file mode 100644 index 0000000..9e44ec2 --- /dev/null +++ b/Tests/SocketForwarderTests/TCPEchoHandler.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// 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 NIO + +final class TCPEchoHandler: ChannelInboundHandler { + + typealias InboundIn = ByteBuffer + typealias OutboundOut = ByteBuffer + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + context.writeAndFlush(data, promise: nil) + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + context.close(promise: nil) + } +} diff --git a/Tests/SocketForwarderTests/TCPEchoServer.swift b/Tests/SocketForwarderTests/TCPEchoServer.swift new file mode 100644 index 0000000..81d63a2 --- /dev/null +++ b/Tests/SocketForwarderTests/TCPEchoServer.swift @@ -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 NIO + +struct TCPEchoServer: Sendable { + private let serverAddress: SocketAddress + + private let eventLoopGroup: MultiThreadedEventLoopGroup + + public init(serverAddress: SocketAddress, eventLoopGroup: MultiThreadedEventLoopGroup) { + self.serverAddress = serverAddress + self.eventLoopGroup = eventLoopGroup + } + + public func run() throws -> EventLoopFuture { + let bootstrap = ServerBootstrap(group: self.eventLoopGroup) + .serverChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelOption(ChannelOptions.socket(.init(SOL_SOCKET), .init(SO_REUSEADDR)), value: 1) + .childChannelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + BackPressureHandler() + ) + try channel.pipeline.syncOperations.addHandler( + TCPEchoHandler() + ) + } + } + + return bootstrap.bind(to: self.serverAddress) + } +} diff --git a/Tests/SocketForwarderTests/TCPForwarderTest.swift b/Tests/SocketForwarderTests/TCPForwarderTest.swift new file mode 100644 index 0000000..d79b657 --- /dev/null +++ b/Tests/SocketForwarderTests/TCPForwarderTest.swift @@ -0,0 +1,138 @@ +//===----------------------------------------------------------------------===// +// 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 NIO +import Testing + +@testable import SocketForwarder + +struct TCPForwarderTest { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + + @Test + func testTCPForwarder() async throws { + let requestCount = 100 + var responses: [String] = [] + + // bring up server on ephemeral port and get address + let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let server = TCPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup) + let serverChannel = try await server.run().get() + let actualServerAddress = try #require(serverChannel.localAddress) + + // bring up proxy on ephemeral port and get address + let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let forwarder = try TCPForwarder( + proxyAddress: proxyAddress, + serverAddress: actualServerAddress, + eventLoopGroup: eventLoopGroup + ) + let forwarderResult = try await forwarder.run().get() + let actualProxyAddress = try #require(forwarderResult.proxyAddress) + + // send a bunch of messages and collect them + try await withThrowingTaskGroup(of: String.self) { group in + for i in 0.. 1) + #expect(bParts.count > 1) + let aIndex = try #require(Int(aParts[0])) + let bIndex = try #require(Int(bParts[0])) + return aIndex < bIndex + } + #expect(sortedResponses.count == requestCount) + for i in 0.. DecodingState { + let readableBytes = buffer.readableBytesView + + guard let firstLine = readableBytes.firstIndex(of: self.newLine).map({ readableBytes[..<$0] }) else { + return .needMoreData + } + buffer.moveReaderIndex(forwardBy: firstLine.count + 1) + // Fire a read without a newline + let data = Self.wrapInboundOut(String(buffer: ByteBuffer(firstLine))) + context.fireChannelRead(data) + return .continue + } + + func encode(data: String, out: inout ByteBuffer) throws { + out.writeString(data) + out.writeInteger(self.newLine) + } +} diff --git a/Tests/SocketForwarderTests/UDPEchoHandler.swift b/Tests/SocketForwarderTests/UDPEchoHandler.swift new file mode 100644 index 0000000..c0f6ed1 --- /dev/null +++ b/Tests/SocketForwarderTests/UDPEchoHandler.swift @@ -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 NIO + +final class UDPEchoHandler: ChannelInboundHandler { + + typealias InboundIn = AddressedEnvelope + + func channelRead(context: ChannelHandlerContext, data: NIOAny) { + context.writeAndFlush(data, promise: nil) + } + + func errorCaught(context: ChannelHandlerContext, error: Error) { + context.close(promise: nil) + } +} diff --git a/Tests/SocketForwarderTests/UDPEchoServer.swift b/Tests/SocketForwarderTests/UDPEchoServer.swift new file mode 100644 index 0000000..48b085e --- /dev/null +++ b/Tests/SocketForwarderTests/UDPEchoServer.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// 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 NIO + +struct UDPEchoServer: Sendable { + private let serverAddress: SocketAddress + + private let eventLoopGroup: MultiThreadedEventLoopGroup + + public init(serverAddress: SocketAddress, eventLoopGroup: MultiThreadedEventLoopGroup) { + self.serverAddress = serverAddress + self.eventLoopGroup = eventLoopGroup + } + + public func run() throws -> EventLoopFuture { + let bootstrap = DatagramBootstrap(group: self.eventLoopGroup) + .channelInitializer { channel in + channel.eventLoop.makeCompletedFuture { + try channel.pipeline.syncOperations.addHandler( + UDPEchoHandler() + ) + } + } + + return bootstrap.bind(to: self.serverAddress) + } +} diff --git a/Tests/SocketForwarderTests/UDPForwarderTest.swift b/Tests/SocketForwarderTests/UDPForwarderTest.swift new file mode 100644 index 0000000..7ace242 --- /dev/null +++ b/Tests/SocketForwarderTests/UDPForwarderTest.swift @@ -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 Logging +import NIO +import Testing + +@testable import SocketForwarder + +struct UDPForwarderTest { + let eventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + + @Test + func testUDPForwarder() async throws { + let requestCount = 100 + var responses: [String] = [] + + // bring up server on ephemeral port and get address + let serverAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let server = UDPEchoServer(serverAddress: serverAddress, eventLoopGroup: eventLoopGroup) + let serverChannel = try await server.run().get() + let actualServerAddress = try #require(serverChannel.localAddress) + + // bring up proxy on ephemeral port and get address + let proxyAddress = try SocketAddress(ipAddress: "127.0.0.1", port: 0) + let forwarder = try UDPForwarder( + proxyAddress: proxyAddress, + serverAddress: actualServerAddress, + eventLoopGroup: eventLoopGroup + ) + let forwarderResult = try await forwarder.run().get() + let actualProxyAddress = try #require(forwarderResult.proxyAddress) + + // send a bunch of messages and collect them + print("testUDPForwarder: send messages") + try await withThrowingTaskGroup(of: String.self) { group in + for i in 0...self, + outboundType: AddressedEnvelope.self + ) + ) + } + } + + try await channel.executeThenClose { inbound, outbound in + let remoteAddress = try #require(channel.channel.remoteAddress) + let data = ByteBufferAllocator().buffer(string: "\(i): success-udp") + try await outbound.write(AddressedEnvelope(remoteAddress: remoteAddress, data: data)) + for try await inboundData in inbound { + response = String(buffer: inboundData.data) + break + } + } + + return response + } + } + + for try await response in group { + responses.append(response) + } + } + + // close everything down + print("testUDPForwarder: close server") + serverChannel.eventLoop.execute { _ = serverChannel.close() } + try await serverChannel.closeFuture.get() + + print("testUDPForwarder: close forwarder") + forwarderResult.close() + try await forwarderResult.wait() + + // verify all expected messages + print("testUDPForwarder: validate responses") + let sortedResponses = try responses.sorted { (a, b) in + let aParts = a.split(separator: ":") + let bParts = b.split(separator: ":") + #expect(aParts.count > 1) + #expect(bParts.count > 1) + let aIndex = try #require(Int(aParts[0])) + let bIndex = try #require(Int(bParts[0])) + return aIndex < bIndex + } + #expect(sortedResponses.count == requestCount) + for i in 0.. + +
container
CLI
apiserver
core-images
ContainerClient
library
runtime-linux
my-web-server
content store
runtime-linux
builder
network-vmnet
192.168.64.1/24
\ No newline at end of file diff --git a/docs/assets/landing-movie.gif b/docs/assets/landing-movie.gif new file mode 100644 index 0000000..ecd7ee3 Binary files /dev/null and b/docs/assets/landing-movie.gif differ diff --git a/docs/bug-report-how-to.md b/docs/bug-report-how-to.md new file mode 100644 index 0000000..8b69945 --- /dev/null +++ b/docs/bug-report-how-to.md @@ -0,0 +1,140 @@ +# How to file effective bug reports + +This guide helps you collect the essential information needed to file effective bug reports. Providing complete and accurate information helps maintainers reproduce and fix issues faster. + +💡 **Example of a good bug report**: [Issue #1094](https://github.com/apple/container/issues/1094) demonstrates many of the best practices outlined in this guide. + +## Steps to reproduce + +Clear reproduction steps are essential for maintainers to understand and fix the issue. + +### What to include +1. **Starting state**: What was your setup before the issue? + - Fresh installation or existing project? + - Any specific configuration files? + - Previous commands that led to this state? + - Has your machine recently been restarted? + +2. **Exact commands**: Copy-paste the exact commands you ran + - Include all flags and arguments + - Use code blocks for clarity + +3. **Reproducibility**: Does it happen every time or intermittently? + - Always reproducible + - Happens sometimes (describe conditions) + - Only happened once + +### Example +``` +1. Create new container: `container create --name test-app ubuntu:latest` +2. Start the container: `container start test-app` +3. Container fails during bootstrap with error: + "failed to bootstrap container test-app" +4. Container exits with code 1 +``` + +## Problem description + +Provide a comprehensive description of your problem. Include what currently happens (the bug), what you expect should happen instead, and any relevant log output. + +### What to include + +#### Current behavior +- Exact error messages (copy-paste, don't paraphrase) +- Exit codes or status indicators +- Performance issues (slowness, hangs, crashes) +- Unexpected outputs or results + +#### Expected behavior +- The correct output or result you anticipated +- Reference to documentation if available +- How it works in previous versions (if applicable) +- Logical expectations based on the command or action + +#### Relevant logs +Include any log output that helps illustrate the problem: +- Error messages or stack traces +- Warning messages related to your issue +- Output from failed commands +- Use verbose/debug flags to capture detailed information (see [Log Information](#log-information) section below for how to gather logs) + +## Environment information + +### Operating system details +Run this command in Terminal to get your macOS version: +```bash +sw_vers +``` + +Example output: +``` +ProductName: macOS +ProductVersion: 26.0 +BuildVersion: 12A345 +``` + +### Xcode version +Get your Xcode version with: +```bash +xcodebuild -version +``` + +Example output: +``` +Xcode 15.0 +Build version 15A240d +``` + +### Container CLI version +Check your Container CLI version: +```bash +container --version +``` + +Example output: +``` +container CLI version 0.10.0-27-g9fd15f0 (build: debug, commit: 9fd15f0) +``` + +## Log information + +### Finding relevant logs +When reporting issues, include logs that show: +- Error messages or stack traces +- Warning messages related to your issue +- Output from failed commands + +### Getting container logs +For Container CLI issues, run commands with verbose output: +```bash +container --debug +``` + +You can also use the `container logs` command to get logs from running containers. See the [container logs](command-reference.md#container-logs) documentation for full details. +```bash +container logs +``` + +### System logs +For system-level container issues, use the built-in system logs command. See the [container system logs](command-reference.md#container-system-logs) documentation for full details. +```bash +container system logs +``` + +## Common information gaps + +### Missing context +- What were you trying to accomplish? +- What changed recently in your setup? +- Does the issue occur in a fresh installation from main? + +### Incomplete error information +- Full error messages (not just the last line) +- Stack traces where relevant +- Related warning messages + +### Environment variations +- Does it work with a new instance of the container? +- Does it work with a fresh install of the Container package? +- Have your network settings changed? +- Have your Xcode or macOS versions changed? diff --git a/docs/command-reference.md b/docs/command-reference.md new file mode 100644 index 0000000..4066e3a --- /dev/null +++ b/docs/command-reference.md @@ -0,0 +1,1579 @@ +# Container CLI Command Reference + +> [!IMPORTANT] +> This file contains documentation for the CURRENT BRANCH. To find documentation for official releases, find the target release on the [Release Page](https://github.com/apple/container/releases) and click the tag corresponding to your release version. +> +> Example: [release 0.4.1 tag](https://github.com/apple/container/tree/0.4.1) + +Command availability may vary depending on your macOS version. + +## Core Commands + +### `container run` + +Runs a container from an image. If a command is provided, it will execute inside the container; otherwise the image's default command runs. By default the container runs in the foreground and stdin remains closed unless `-i`/`--interactive` is specified. + +**Usage** + +```bash +container run [] [ ...] +``` + +**Arguments** + +* ``: Image name +* ``: Container init process arguments + +**Process Options** + +* `-e, --env `: Set environment variables (format: key=value, or just key to inherit from host) +* `--env-file `: Read in a file of environment variables (key=value format, ignores # comments and blank lines) +* `--gid `: Set the group ID for the process +* `-i, --interactive`: Keep the standard input open even if not attached +* `-t, --tty`: Open a TTY with the process +* `-u, --user `: Set the user for the process (format: name|uid[:gid]) +* `--uid `: Set the user ID for the process +* `--ulimit `: Set resource limits (format: `=[:]`) +* `-w, --workdir, --cwd `: Set the initial working directory inside the container + +**Resource Options** + +* `-c, --cpus `: Number of CPUs to allocate to the container +* `-m, --memory `: Amount of memory (1MiByte granularity), with optional K, M, G, T, or P suffix + +**Management Options** + +* `-a, --arch `: Set arch if image can target multiple architectures (default: arm64) +* `--cap-add `: Add a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`) +* `--cap-drop `: Drop a Linux capability (e.g. `CAP_NET_RAW`, `NET_RAW`, or `ALL`) +* `--cidfile `: Write the container ID to the path provided +* `-d, --detach`: Run the container and detach from the process +* `--dns `: DNS nameserver IP address +* `--dns-domain `: Default DNS domain +* `--dns-option