commit 8e9670c8f81aed2bf7b9bb6e2f721c738abd3f05 Author: Kathryn Baldauf Date: Tue Jun 3 15:23:07 2025 -0700 Initial commit Co-authored-by: Aditya Ramani Co-authored-by: Agam Dua Co-authored-by: Danny Canter Co-authored-by: Dmitry Kovba Co-authored-by: Eric Ernst Co-authored-by: Evan Hazlett Co-authored-by: Gilbert Song Co-authored-by: Hugh Bussell Co-authored-by: John Logan Co-authored-by: Kathryn Baldauf Co-authored-by: Madhu Venugopal Co-authored-by: Michael Crosby Co-authored-by: Sidhartha Mani Co-authored-by: Tanweer Noor Co-authored-by: Ximena Perez Diaz Co-authored-by: Yibo Zhuang diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 00000000..7bf5a31e --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,20 @@ +name: container project - PR/merge build + +on: + pull_request: + types: [opened, reopened, synchronize] + push: + branches: + - main + - release/* + +jobs: + build: + name: Invoke build + uses: ./.github/workflows/common.yml + with: + release: false + secrets: inherit + permissions: + contents: read + packages: read diff --git a/.github/workflows/common.yml b/.github/workflows/common.yml new file mode 100644 index 00000000..633a9114 --- /dev/null +++ b/.github/workflows/common.yml @@ -0,0 +1,77 @@ +name: container project - common jobs + +on: + workflow_call: + inputs: + release: + type: boolean + description: "Publish this build for release" + default: false + +jobs: + buildAndTest: + name: Build and test the project + timeout-minutes: 30 + runs-on: [self-hosted, macos, sequoia, ARM64] + permissions: + contents: read + packages: read + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Update containerization + run: | + /usr/bin/swift package update containerization + env: + CONTAINERIZATION_REPO: https://${{ secrets.REPO_READ }}@github.com/apple-uat/containerization.git + - name: Check formatting + run: | + ./scripts/install-hawkeye.sh + make fmt + if ! git diff -- . ':(exclude)Package.swift' ':(exclude)Package.resolved'; then echo the following files require formatting or license headers: ; git diff --name-only ; false ; fi + - name: Check protobuf + run: | + make BUILDER_SHIM_REPO=https://${{ secrets.REPO_READ }}@github.com/apple-uat/container-builder-shim.git protos + + # TODO [launch]: TEMPORARILY we need to exclude these files since we had to modify them to add + # the github token for pulling the private repos. + if ! git diff -- . ':(exclude)Package.swift' ':(exclude)Package.resolved' ':(exclude)Protobuf.Makefile'; then echo the following files require formatting or license headers: ; git diff --name-only ; false ; fi + env: + CURRENT_SDK: y + CONTAINERIZATION_REPO: https://${{ secrets.REPO_READ }}@github.com/apple-uat/containerization.git + - name: Set build configuration + run: | + echo "BUILD_CONFIGURATION=debug" >> $GITHUB_ENV + if [ ${{ inputs.release }} == true ]; then + echo "BUILD_CONFIGURATION=release" >> $GITHUB_ENV + fi + - name: Make the container project + run: | + make container dsym + env: + DEVELOPER_DIR: "/Applications/Xcode_16.3.app/Contents/Developer" + CURRENT_SDK: y + CONTAINERIZATION_REPO: https://${{ secrets.REPO_READ }}@github.com/apple-uat/containerization.git + - name: Create package + run: | + mkdir -p outputs + mv bin/${{ env.BUILD_CONFIGURATION }}/container-installer-unsigned.pkg outputs + mv bin/${{ env.BUILD_CONFIGURATION }}/bundle/container-dSYM.zip outputs + - name: Test the container project + run: | + launchctl setenv HTTP_PROXY $HTTP_PROXY + make test integration + env: + CONTAINER_REGISTRY_TOKEN: ${{ secrets.GITHUB_TOKEN }} + CONTAINER_REGISTRY_USER: ${{ github.actor }} + CONTAINER_REGISTRY_HOST: ghcr.io + DEVELOPER_DIR: "/Applications/Xcode_16.3.app/Contents/Developer" + CURRENT_SDK: y + CONTAINERIZATION_REPO: https://${{ secrets.REPO_READ }}@github.com/apple-uat/containerization.git + - name: Save artifacts + uses: actions/upload-artifact@v4 + with: + name: container-package + path: ${{ github.workspace }}/outputs diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 00000000..6a193e79 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,42 @@ +name: container project - release build + +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 + release: + name: Publish release + timeout-minutes: 30 + needs: build + runs-on: ubuntu-latest + permissions: + contents: write + packages: read + steps: + - name: Download artifacts + uses: actions/download-artifact@v4 + with: + path: outputs + - name: Create release + uses: softprops/action-gh-release@v2 + with: + token: ${{ secrets.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 00000000..53c4d2cb --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +.DS_Store +bin +libexec +.build +.local +xcuserdata/ +DerivedData/ +Packages/ +.swiftpm/ +.netrc +.swiftpm +api-docs/ +workdir/ +installer/ +.xcode/ +.vscode/ +.venv/ +.clitests/ +test_results/ +*.pid +*.log +*.zip +*.o +*.ext4 +*.pkg +*.swp diff --git a/.swift-format b/.swift-format new file mode 100644 index 00000000..dcd60059 --- /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/CODE-OF-CONDUCT.md b/CODE-OF-CONDUCT.md new file mode 100644 index 00000000..b7131168 --- /dev/null +++ b/CODE-OF-CONDUCT.md @@ -0,0 +1,77 @@ +## Code of Conduct + +### Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our +project and our community a harassment-free experience for everyone, +regardless of age, body size, disability, ethnicity, sex +characteristics, gender identity and expression, level of experience, +education, socio-economic status, nationality, personal appearance, +race, religion, or sexual identity and orientation. + +### Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual +attention or advances +* Trolling, insulting/derogatory comments, and personal or political +attacks +* Public or private harassment +* Publishing others’ private information, such as a physical or +electronic address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a +professional setting + +### Our Responsibilities + +Project maintainers are responsible for clarifying the standards of +acceptable behavior and are expected to take appropriate and fair +corrective action in response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, +or reject comments, commits, code, wiki edits, issues, and other +contributions that are not aligned to this Code of Conduct, or to ban +temporarily or permanently any contributor for other behaviors that they +deem inappropriate, threatening, offensive, or harmful. + +### Scope + +This Code of Conduct applies within all project spaces, and it also +applies when an individual is representing the project or its community +in public spaces. Examples of representing a project or community +include using an official project e-mail address, posting via an +official social media account, or acting as an appointed representative +at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +### Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may +be reported by contacting the open source team at +opensource-conduct@group.apple.com. All complaints will be reviewed and +investigated and will result in a response that is deemed necessary and +appropriate to the circumstances. The project team is obligated to +maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted +separately. + +Project maintainers who do not follow or enforce the Code of Conduct in +good faith may face temporary or permanent repercussions as determined +by other members of the project’s leadership. + +### Attribution + +This Code of Conduct is adapted from the +[Contributor Covenant](https://www.contributor-covenant.org), version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html diff --git a/LICENSE b/LICENSE new file mode 100644 index 00000000..261eeb9e --- /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/Makefile b/Makefile new file mode 100644 index 00000000..69972303 --- /dev/null +++ b/Makefile @@ -0,0 +1,191 @@ +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 +export RELEASE_VERSION ?= $(shell git describe --tags --always) +export GIT_COMMIT := $(shell git rev-parse HEAD) + +# Commonly used locations +SWIFT := "/usr/bin/swift" +DESTDIR ?= /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 + +ifeq (,$(CURRENT_SDK)) + CURRENT_SDK_ARGS := +else + CURRENT_SDK_ARGS := -Xswiftc -DCURRENT_SDK +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... + @#Remove this when the updated MacOS SDK is available publicly + $(SWIFT) build -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) ; \ + +.PHONY: container +container: build + @# Install binaries under project directory + @"$(MAKE)" BUILD_CONFIGURATION=$(BUILD_CONFIGURATION) DESTDIR=$(ROOT_DIR)/ SUDO= install + +.PHONY: release +release: BUILD_CONFIGURATION = release +release: all + +.PHONY: init-block +init-block: + @scripts/install-init.sh + +.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 $${temp_dir} && tar -xf Payload -C $(DESTDIR)) ; \ + 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) + + @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 config/container-runtime-linux-config.json $(join $(STAGING_DIR), libexec/container/plugins/container-runtime-linux/config.json) + @install $(BUILD_BIN_DIR)/container-network-vmnet $(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/bin/container-network-vmnet) + @install config/container-network-vmnet-config.json $(join $(STAGING_DIR), libexec/container/plugins/container-network-vmnet/config.json) + @install $(BUILD_BIN_DIR)/container-core-images $(join $(STAGING_DIR), libexec/container/plugins/container-core-images/bin/container-core-images) + @install config/container-core-images-config.json $(join $(STAGING_DIR), libexec/container/plugins/container-core-images/config.json) + + @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) + + @echo Creating application installer + @pkgbuild --root $(STAGING_DIR) --identifier com.apple.container-installer --install-location /usr/local $(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: + @$(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --skip TestCLI + +.PHONY: integration +integration: cleancontent init-block + @echo Ensuring apiserver stopped before the CLI integration tests... + @bin/container system stop + @scripts/ensure-container-stopped.sh + @echo Running the integration tests... + @bin/container system start --install-dependencies + @echo "Removing any existing containers" + @bin/container rm --all + @echo "Starting CLI integration tests" + @RUN_CLI_INTEGRATION_TESTS=1 $(SWIFT) test -c $(BUILD_CONFIGURATION) $(CURRENT_SDK_ARGS) --filter TestCLI + @echo Ensuring apiserver stopped after the CLI integration tests... + @scripts/ensure-container-stopped.sh + +.PHONY: fmt +fmt: swift-fmt update-licenses + +.PHONY: swift-fmt +SWIFT_SRC = $(shell find . -type f -name '*.swift' -not -path "*/.*" -not -path "*.pb.swift" -not -path "*.grpc.swift" -not -path "*/checkouts/*") +swift-fmt: + @echo Applying the standard code formatting... + @$(SWIFT) format --recursive --configuration .swift-format -i $(SWIFT_SRC) + +.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: serve-docs +serve-docs: + @echo 'to browse: open http://localhost:8000/documentation/' + @python3 -m http.server --bind 127.0.0.1 --directory ./api-docs + +.PHONY: docs +docs: + @echo Updating documentation... + @rm -rf ./api-docs + @mkdir -p ./api-docs + @if [ -z "$${DOCS_BASE_PATH}" ] ; then \ + scripts/make-docs.sh ./api-docs ; \ + else \ + scripts/make-docs.sh ./api-docs $${DOCS_BASE_PATH} ; \ + fi + +.PHONY: cleancontent +cleancontent: + @echo Cleaning the content... + @rm -rf ~/Library/Application\ Support/com.apple.container + +.PHONY: clean +clean: + @echo Cleaning the build files... + @rm -rf bin/ libexec/ + @$(SWIFT) package clean diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 00000000..8c56de38 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,258 @@ +{ + "originHash" : "91e67bd3294f1765ebc1b6882f4b7fba5fdb32101c8b584ceb9e42d8738bf55b", + "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-uat/containerization.git", + "state" : { + "revision" : "6da89a3be63b520bb612f43ab99d6117718ac757", + "version" : "0.1.34" + } + }, + { + "identity" : "dns", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Bouke/DNS", + "state" : { + "revision" : "78bbd1589890a90b202d11d5f9e1297050cf0eb2", + "version" : "1.2.0" + } + }, + { + "identity" : "dnsclient", + "kind" : "remoteSourceControl", + "location" : "https://github.com/orlandos-nl/DNSClient", + "state" : { + "revision" : "551fbddbf4fa728d4cd86f6a5208fe4f925f0549", + "version" : "2.4.4" + } + }, + { + "identity" : "grpc-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/grpc/grpc-swift", + "state" : { + "revision" : "67ae0617e1be215ca8cb4a8df5b4af940095c818", + "version" : "1.26.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", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-asn1", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-asn1.git", + "state" : { + "revision" : "a54383ada6cecde007d374f58f864e29370ba5c3", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-atomics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-atomics.git", + "state" : { + "revision" : "cd142fd2f64be2100422d658e7411e39489da985", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-certificates", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-certificates.git", + "state" : { + "revision" : "999fd70c7803da89f3904d635a6815a2a7cd7585", + "version" : "1.10.0" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" + } + }, + { + "identity" : "swift-crypto", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-crypto.git", + "state" : { + "revision" : "e8d6eba1fef23ae5b359c46b03f7d94be2f41fed", + "version" : "3.12.3" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-plugin", + "state" : { + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "swift-http-structured-headers", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-structured-headers.git", + "state" : { + "revision" : "db6eea3692638a65e2124990155cd220c2915903", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-http-types", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-http-types.git", + "state" : { + "revision" : "a0a57e949a8903563aba4615869310c0ebf14c03", + "version" : "1.4.0" + } + }, + { + "identity" : "swift-log", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-log.git", + "state" : { + "revision" : "3d8596ed08bd13520157f0355e35caed215ffbfa", + "version" : "1.6.3" + } + }, + { + "identity" : "swift-nio", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio.git", + "state" : { + "revision" : "34d486b01cd891297ac615e40d5999536a1e138d", + "version" : "2.83.0" + } + }, + { + "identity" : "swift-nio-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-extras.git", + "state" : { + "revision" : "949cf2d3c895e3a576b0f5c5f902848ba17acbb9", + "version" : "1.27.0" + } + }, + { + "identity" : "swift-nio-http2", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-http2.git", + "state" : { + "revision" : "4281466512f63d1bd530e33f4aa6993ee7864be0", + "version" : "1.36.0" + } + }, + { + "identity" : "swift-nio-ssl", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-ssl.git", + "state" : { + "revision" : "4b38f35946d00d8f6176fe58f96d83aba64b36c7", + "version" : "2.31.0" + } + }, + { + "identity" : "swift-nio-transport-services", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-nio-transport-services.git", + "state" : { + "revision" : "cd1e89816d345d2523b11c55654570acd5cd4c56", + "version" : "1.24.0" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "e0ec0f5f3af6f3e4d5e7a19d2af26b481acb6ba8", + "version" : "1.0.3" + } + }, + { + "identity" : "swift-protobuf", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-protobuf.git", + "state" : { + "revision" : "d72aed98f8253ec1aa9ea1141e28150f408cf17f", + "version" : "1.29.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-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "0687f71944021d616d34d922343dcef086855920", + "version" : "600.0.1" + } + }, + { + "identity" : "swift-system", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-system.git", + "state" : { + "revision" : "a34201439c74b53f0fd71ef11741af7e7caf01e1", + "version" : "1.4.2" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 00000000..b0a80c09 --- /dev/null +++ b/Package.swift @@ -0,0 +1,319 @@ +// swift-tools-version: 6.0 +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 scDependency: Package.Dependency +let scVersion: String +if let path = ProcessInfo.processInfo.environment["CONTAINERIZATION_PATH"] { + scDependency = .package(path: path) + scVersion = "latest" +} else { + scVersion = "0.1.34" + if let containerizationRepo = ProcessInfo.processInfo.environment["CONTAINERIZATION_REPO"], containerizationRepo != "" { + scDependency = .package(url: containerizationRepo, exact: Version(stringLiteral: scVersion)) + } else { + scDependency = .package(url: "https://github.com/apple-uat/containerization.git", exact: Version(stringLiteral: scVersion)) + } +} + +let releaseVersion = ProcessInfo.processInfo.environment["RELEASE_VERSION"] ?? "0.0.0" +let gitCommit = ProcessInfo.processInfo.environment["GIT_COMMIT"] ?? "unspecified" + +let package = Package( + name: "container", + platforms: [.macOS("15")], + products: [ + .library(name: "ContainerSandboxService", targets: ["ContainerSandboxService"]), + .library(name: "ContainerNetworkService", targets: ["ContainerNetworkService"]), + .library(name: "ContainerImagesService", targets: ["ContainerImagesService", "ContainerImagesServiceClient"]), + .library(name: "ContainerClient", targets: ["ContainerClient"]), + .library(name: "ContainerBuild", targets: ["ContainerBuild"]), + .library(name: "ContainerLog", targets: ["ContainerLog"]), + .library(name: "ContainerPersistence", targets: ["ContainerPersistence"]), + .library(name: "ContainerPlugin", targets: ["ContainerPlugin"]), + .library(name: "ContainerXPC", targets: ["ContainerXPC"]), + ], + dependencies: [ + .package(url: "https://github.com/apple/swift-log.git", from: "1.0.0"), + .package(url: "https://github.com/apple/swift-argument-parser.git", from: "1.3.0"), + .package(url: "https://github.com/grpc/grpc-swift.git", from: "1.26.0"), + .package(url: "https://github.com/apple/swift-protobuf.git", from: "1.29.0"), + .package(url: "https://github.com/apple/swift-nio.git", from: "2.80.0"), + .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.1.0"), + .package(url: "https://github.com/swift-server/async-http-client.git", from: "1.20.1"), + .package(url: "https://github.com/orlandos-nl/DNSClient", from: "2.4.1"), + .package(url: "https://github.com/Bouke/DNS", from: "1.2.0"), + scDependency, + ], + targets: [ + .executableTarget( + name: "container", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "SwiftProtobuf", package: "swift-protobuf"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "TerminalProgress", + "ContainerBuild", + "ContainerClient", + "ContainerPlugin", + "ContainerLog", + ], + path: "Sources/CLI" + ), + .executableTarget( + name: "container-apiserver", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "AsyncHTTPClient", package: "async-http-client"), + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "DNSServer", + "ContainerNetworkService", + "ContainerSandboxService", + "ContainerClient", + "ContainerLog", + "ContainerPersistence", + "ContainerPlugin", + ], + path: "Sources/APIServer" + ), + .executableTarget( + name: "container-runtime-linux", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "GRPC", package: "grpc-swift"), + .product(name: "Containerization", package: "containerization"), + "CVersion", + "ContainerNetworkService", + "ContainerSandboxService", + "ContainerLog", + "ContainerXPC", + ], + path: "Sources/Helpers/RuntimeLinux" + ), + .target( + name: "ContainerSandboxService", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerNetworkService", + "ContainerClient", + "ContainerXPC", + ], + path: "Sources/Services/ContainerSandboxService" + ), + .executableTarget( + name: "container-network-vmnet", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "ContainerizationIO", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "CVersion", + "ContainerNetworkService", + "ContainerLog", + "ContainerXPC", + ], + path: "Sources/Helpers/NetworkVmnet" + ), + .target( + name: "ContainerNetworkService", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + "ContainerXPC", + ], + path: "Sources/Services/ContainerNetworkService" + ), + .executableTarget( + name: "container-core-images", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + "CVersion", + "ContainerLog", + "ContainerXPC", + "ContainerImagesService", + ], + path: "Sources/Helpers/Images" + ), + .target( + name: "ContainerImagesService", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + "ContainerXPC", + "ContainerLog", + "ContainerClient", + "ContainerImagesServiceClient", + ], + 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" + ), + .target( + name: "ContainerBuild", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "NIO", 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"), + "ContainerClient", + ] + ), + .testTarget( + name: "ContainerBuildTests", + dependencies: [ + "ContainerBuild" + ] + ), + .target( + name: "ContainerClient", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "NIOCore", package: "swift-nio"), + .product(name: "NIOPosix", package: "swift-nio"), + .product(name: "Containerization", package: "containerization"), + .product(name: "ContainerizationOCI", package: "containerization"), + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "ArgumentParser", package: "swift-argument-parser"), + "ContainerNetworkService", + "ContainerImagesServiceClient", + "TerminalProgress", + "ContainerXPC", + "CVersion", + ] + ), + .testTarget( + name: "ContainerClientTests", + dependencies: [ + .product(name: "Containerization", package: "containerization"), + "ContainerClient", + ] + ), + .target( + name: "ContainerPersistence", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "Containerization", package: "containerization"), + ] + ), + .target( + name: "ContainerPlugin", + dependencies: [ + .product(name: "Logging", package: "swift-log"), + .product(name: "ContainerizationOS", package: "containerization"), + ] + ), + .testTarget( + name: "ContainerPluginTests", + dependencies: [ + "ContainerPlugin" + ] + ), + .target( + name: "ContainerLog", + dependencies: [ + .product(name: "Logging", package: "swift-log") + ] + ), + .target( + name: "ContainerXPC", + dependencies: [ + .product(name: "ContainerizationExtras", package: "containerization"), + .product(name: "Logging", package: "swift-log"), + ] + ), + .target( + name: "TerminalProgress", + dependencies: [ + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "SendableProperty", 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: "DNSClient", package: "DNSClient"), + .product(name: "DNS", package: "DNS"), + .product(name: "Logging", package: "swift-log"), + ] + ), + .testTarget( + name: "DNSServerTests", + dependencies: [ + .product(name: "DNS", package: "DNS"), + "DNSServer", + ] + ), + .testTarget( + name: "CLITests", + dependencies: [ + .product(name: "ContainerizationOS", package: "containerization"), + .product(name: "Containerization", package: "containerization"), + "ContainerClient", + "ContainerBuild", + ], + path: "Tests/CLITests" + ), + .target( + name: "CVersion", + dependencies: [], + publicHeadersPath: "include", + cSettings: [ + .define("CZ_VERSION", to: "\"\(scVersion)\""), + .define("GIT_COMMIT", to: "\"\(gitCommit)\""), + .define("RELEASE_VERSION", to: "\"\(releaseVersion)\""), + ] + ), + ] +) diff --git a/Protobuf.Makefile b/Protobuf.Makefile new file mode 100644 index 00000000..3472dddd --- /dev/null +++ b/Protobuf.Makefile @@ -0,0 +1,59 @@ +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 +LOCALBIN := $(LOCAL_DIR)/bin + +BUILDER_SHIM_REPO ?= https://github.com/apple-uat/container-builder-shim.git +## Versions +PROTOC_VERSION=26.1 + +# protoc binary installation +PROTOC_ZIP = protoc-$(PROTOC_VERSION)-osx-universal_binary.zip +PROTOC = $(LOCALBIN)/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) + +protoc_gen_grpc_swift: + @$(SWIFT) build --product protoc-gen-grpc-swift + +protoc-gen-swift: + @$(SWIFT) build --product protoc-gen-swift + +protos: $(PROTOC) protoc-gen-swift protoc_gen_grpc_swift + @echo Generating protocol buffers source code... + @mkdir -p $(LOCAL_DIR) + @cd $(LOCAL_DIR) && git clone $(BUILDER_SHIM_REPO) + @$(PROTOC) $(LOCAL_DIR)/container-builder-shim/pkg/api/Builder.proto \ + --plugin=protoc-gen-grpc-swift=$(BUILD_BIN_DIR)/protoc-gen-grpc-swift \ + --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 + +clean-proto-tools: + @rm -rf $(LOCAL_DIR)/bin + @rm -rf $(LOCAL_DIR)/container-builder-shim + @echo "Removed $(LOCAL_DIR)/bin toolchains." diff --git a/README.md b/README.md new file mode 100644 index 00000000..dd1bacad --- /dev/null +++ b/README.md @@ -0,0 +1,696 @@ +# `container` + +![introductory movie showing some basic commands](./docs/assets/landing-movie.gif) + +`container` is an application 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 application consumes and produces OCI-compliant container images, 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-compliant application. + +`container` uses the [Containerization](https://github.com/apple/containerization) Swift package for low level container, image and process management. + +## Get started + +Install the `container` application on your Mac. + +### Requirements + +You need an Apple silicon Mac to build and run `container`. + +To build the Containerization package, your system needs either: + +- macOS 15 or newer and Xcode 17 beta. +- macOS 16 Developer Preview. + +`container` is designed to take advantage of the features of the macOS 16 Developer Preview. You can run the application on macOS Sequoia, but the `container` maintainers typically will not address Sequoia issues that cannot be reproduced on the macOS 16 Developer Beta. + +There are [significant networking limitations](https://github.com/apple-uat/container#macos-sequoia-limitations) that impact the usability `container` on macOS Sequoia. + +### Install and run + +Download the latest application installer package from the [Github release page](https://github.com/apple-uat/container/releases). + +To install the application, double click the installer package and follow the instructions. Enter your administrator password when prompted to give the installer permission to place the application under `/usr/local`. + +### Uninstall + +Use the `uninstall-container.sh` script to remove the application from your system. To remove your user data along with the application, run: + +```bash +uninstall-container.sh -d +``` + +To retain your user data so that it is available should you reinstall later, run: + +```bash +uninstall-container.sh -k +``` + +## Tutorial + +Take a guided tour of `container` by building, running, and publishing a simple web server image. + +### Try out the `container` CLI + +Start the application, and try out some basic commands to familiarize yourself with the command line interface (CLI) tool. + +#### Start the container service + +Start the services that `container` uses: + +```bash +container system start +``` + +If you have not installed a Linux kernel yet, the command will prompt you to install one: + +```shellsession +% container system start +Verifying apiserver is running... +Done +Missing required runtime dependencies: + 1. Initial Filesystem + 2. Kernel +Would like to install them now? [Y/n]: Y +Installing default kernel from [https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz]... +Installing initial filesystem from [ghcr.io/apple-uat/containerization/vminit:0.1.34]... +% +``` + +Then, verify that the application is working by running a command to list all containers: + +```bash +container list --all +``` + +If you haven't created any containers yet, the command outputs an empty list: + +```shellsession +% container list --all +ID IMAGE OS ARCH STATE ADDR +% +``` + +#### Get CLI help + +You can get help for any `container` CLI command by appending the `--help` option: + +```shellsession +% container --help +OVERVIEW: A container platform for macOS + +USAGE: container [--debug] + +OPTIONS: + --debug Enable debug output [environment: CONTAINER_DEBUG] + --version Show the version. + -h, --help Show help information. + +CONTAINER SUBCOMMANDS: + create Create a new container + delete, rm Delete one or more containers + exec Run a new command in a running container + inspect Display information about one or more containers + kill Kill one or more running containers + list, ls List containers + logs Fetch container stdio or boot logs + run Run a container + start Start a container + stop Stop one or more running containers + +IMAGE SUBCOMMANDS: + build Build an image from a Dockerfile + images, image, i Manage images + registry, r Manage registry configurations + +SYSTEM SUBCOMMANDS: + builder Manage an image builder instance + system, s Manage system components + +% +``` + +#### Abbreviations + +You can save keystrokes by abbreviating commands and options. For example, abbreviate the `container list` command to `container ls`, and the `--all` option to `-a`: + +```shellsession +% container ls -a +ID IMAGE OS ARCH STATE ADDR +% +``` + +Use the `--help` flag to see which abbreviations exist. + +#### Set up a local DNS domain (optional) + +`container` includes an embedded DNS service that simplifies access to your containerized applications. If you want to configure a local DNS domain named `test` for this tutorial, run: + +```bash +sudo container system dns create test +``` + +Enter your administrator password when prompted. The command requires administrator privileges to create a file containing the domain configuration under the `/etc/resolver` directory, and to tell the macOS DNS resolver to reload its configuration files. + +### Build an image + +Set up a `Dockerfile` for a basic Python web server, and use it to build a container image named `web-test`. + +#### Set up a simple project + +Start a terminal, create a directory named `web-test` for the files needed to create the container image: + +```bash +mkdir web-test +cd web-test +``` + +Download an image file for your web server can use (TODO: substitute the container logo): + +```bash +curl -L -o logo.jpg https://github.com/apple-uat/container/tree/main/docs/assets/logo.jpg +``` + +In the `web-test` directory, create a file named `Dockerfile` with this content: + +```docker +FROM docker.io/python:slim +WORKDIR /content +COPY logo.jpg ./ +RUN echo 'Hello

Hello, world!

' > index.html +CMD ["python3", "-m", "http.server", "80", "--bind", "0.0.0.0"] +``` + +The `FROM` line instructs the `container` builder to start with a base image containing the latest production version of Python 3. + +The `WORKDIR` line creates a directory `/content` in the image, and makes it the current directory. + +The `COPY` command copies the image file `logo.jpg` from your build context to the image. See the following section for a description of the build context. + +The `RUN` line creates a simple HTML landing page named `/content/index.html`. + +The `CMD` line configures the container to run a simple web server in Python on port 80. Since the working directory is `/content`, the web server runs in that directory and delivers the content of the file `/content/index.html` when a user requests the index page URL. + +The server binds to the wildcard address `0.0.0.0` to allow connections from the host and other containers. To ensure security, the virtual network used by the containers is not accessible by external systems. + +#### Build the web server image + +Run the `container build` command to create an image with the name `web-test` from your `Dockerfile`: + +```bash +container build --tag web-test --file Dockerfile . +``` + +The last argument `.` tells the builder to use the current directory (`web-test`) as the root of the build context. You can copy files within the build context into your image using the `COPY` command in your Dockerfile. + +After the build completes, list the images. You should see both the base image and the image that you built in the results: + +```shellsession +% container images list +NAME TAG DIGEST +docker.io/library/python slim 56a11364ffe0fee3bd60af6d... +web-test latest bf91dc9d42f0110d3aac41dd... +% +``` + +### Run containers + +Using your container image, run a web server and try out different ways of interacting with it. + +#### Start the webserver + +Use `container run` to start a container named `my-web-server` that runs your webserver: + +```bash +container run --name my-web-server --dns-domain test --detach --rm web-test +``` + +The `--detach` flag runs the container in the background, so that you can continue running commands in the same terminal. The `--rm` flag causes the container to be removed automatically after it stops. + +When you list containers now, `my-web-server` is present, along with the container that `container` started to build your image. Note that its IP address, shown in the `ADDR` column, is `192.168.64.3`: + +```shellsession +% container ls +ID IMAGE OS ARCH STATE ADDR +buildkit ghcr.io/apple-uat/container-builder-shim/builder:2.1.1 linux arm64 running 192.168.64.2 +my-web-server web-test:latest linux arm64 running 192.168.64.3 +% +``` + +Open the website, using the container's IP address in the URL: + +```bash +open http://192.168.64.3 +``` + +If you configured the local domain `test` earlier in the tutorial, you can also open the page the full hostname for the container: + +```bash +open http://my-web-server.test +``` + +#### Run other commands in the container + +You can run other commands in `my-web-server` by using the `container exec` command. To list the files under the content directory, run an `ls` command: + +```shellsession +% container exec my-web-server ls /content +index.html +logo.jpg +% +``` + +If you want to poke around in the container, run a shell and issue one or more commands: + +```shellsession +% container exec --tty --interactive my-web-server bash +root@my-web-server:/content# ls +index.html logo.jpg +root@my-web-server:/content# uname -a +Linux my-web-server 6.1.68 #1 SMP Mon Mar 31 18:27:51 UTC 2025 aarch64 GNU/Linux +root@my-web-server:/content# exit +exit% +``` + +The `--tty` and `--interactive` flag allow you to interact with the shell from your host terminal. The `--tty` flag tells the shell in the container that its input is a terminal device, and the `--interacive` flag connects what you input in your host terminal to the input of the shell in the container. + +You will often see these two options abbreviated and specified together as `-ti` or `-it`. + +#### Access the web server from another container + +Your web server is accessible from other containers as well as from your host. Launch a second container using your `web-test` image, and this time, specify a `curl` command to retrieve the `index.html` content from the first container. + +```shellsession +% container run -it --rm web-test curl http://192.168.64.3 +Hello

Hello, world!

+% +``` + +If you set up the `test` domain earlier, you can achieve the same result with: + +```bash +container run -it --rm web-test curl http://my-web-server.test +``` + +### Run a published image + +Push your image to a container registry, publishing it so that you and others can use it. + +#### Publish the web server image + +To publish your image, you need push images to a registry service that stores the image for future use. Typically, you need to authenticate with a registry to push an image. This example assumes that you have an account at a hypothetical registry named `registry.example.com` with username `fido` and a password or token `my-secret`, and that your personal repository name is the same as your username. + +To sign into a secure registry with your login credentials, enter your username and password at the prompts after running: + +```bash +container registry login registry.example.com +``` + +Create another name for your image that includes the registry name, your repository name, and the image name, with the tag `latest`: + +```bash +container images tag web-test registry.example.com/fido/web-test:latest +``` + +Then, push the image: + +```bash +container images push registry.example.com/fido/web-test:latest +``` + +#### Pull and run your image + +To validate your published image, remove your existing web server image, and then run using the remote image: + +```bash +container images delete web-test registry.example.com/fido/web-test:latest +container run --name my-web-server --dns-domain test --detach --rm registry.example.com/fido/web-test:latest +``` + +### Clean up + +Stop your container and shut down the application. + +#### Shut down the web server + +Stop your web server container with: + +```bash +container stop my-web-server +``` + +If you list all running and stopped containers, you will see that the `--rm` flag you supplied with the `container run` command caused the container to be removed: + +```bash +% container ls --all +ID IMAGE OS ARCH STATE ADDR +buildkit ghcr.io/apple-uat/container-builder-shim/builder:2.1.1 linux arm64 running 192.168.64.2 +% +``` + +To shut down and remove all containers, run: + +```bash +container rm --all --force +``` + +#### Stop the container service + +When you want to stop `container` completely, run: + +```bash +container system stop +``` + +## How-to + +How to use the features of `container`. + +### Configure memory and CPUs for your containers + +Since the containers created by `container` are lightweight virtual machines, you need to consider the needs of your containerized application when you `container run` a container. The `--memory` and `--cpus` options allow you to override the default memory and CPU limits for the virtual machine. The default values are 1 gigabyte of RAM and 4 CPUs. You can use abbreviations for memory units; for example, to run a container for image `big` with 8 CPUs and 32 gigabytes of memory, use: + +```bash +container run --rm --cpus 8 --memory 32g big +``` + +### Configure memory and CPUs for large builds + +When you first run `container build`, `container` starts a *builder*, which is a utility container that performs image build. As with anything you run with `container run`, the builder runs in a lightweight virtual machine, so for resource-intensive builds, you may need to increase the memory and CPU limits for the builder VM. + +By default, the builder VM receives 2 gigabytes of RAM and 2 CPUs. You can change these limits by starting the builder container before running `container build`: + +```bash +container builder start --cpus 8 --memory 32g +``` + +If your builder is already running and you need to modify the limits, just stop, delete, and restart the builder: + +```bash +container builder stop +container builder delete +container builder start --cpus 8 --memory 32g +``` + +### Share host files with your container + +With the `--volume` option of `container run`, you can share data between the host system and one or more containers, and you can persist data across multiple container runs. The volume option allows you to mount a folder on your host to a filesystem path in the container. + +This example mounts a folder named `assets` on your Desktop to the directory `/content/assets` in a container: + +```shellsession +% ls -l ~/Desktop/assets +total 8 +-rw-r--r--@ 1 fido staff 2410 May 13 18:36 link.svg +% container run --volume ${HOME}/Desktop/assets:/content/assets docker.io/python:slim ls -l /content/assets +total 4 +-rw-r--r-- 1 root root 2410 May 14 01:36 link.svg +% +``` + +The argument to `--volume` in the example consists of the full pathname for the host folder and the full pathname for the mount point in the container, separated by a colon. + +The `--mount` option uses a comma separated `key=value` syntax to achieve the same result: + +```shellsession +% container run --mount source=${HOME}/Desktop/assets,target=/content/assets docker.io/python:slim ls -l /content/assets +total 4 +-rw-r--r-- 1 root root 2410 May 14 01:36 link.svg +% +``` + +### Build and run a multiplatform image + +Using the [project from the tutorial example](/documentation/tutorial/#set-up-a-simple-project), you can create an image to use both on Apple silicon Macs and on AMD64 servers. + +When building the image, just add `--arch` options that directs the builder to create an image supporting both the `arm64` and `amd64` architectures: + +```bash +container build --arch arm64 --arch amd64 --tag registry.example.com/fido/web-test:latest --file Dockerfile . +``` + +Try running the command `uname -a` with the `arm64` variant of the image to see the system information that the virtual machine reports: + +```shellsession +% container run --arch arm64 --rm registry.example.com/fido/web-test:latest uname -a +Linux 7932ce5f-ec10-4fbe-a2dc-f29129a86b64 6.1.68 #1 SMP Mon Mar 31 18:27:51 UTC 2025 aarch64 GNU/Linux +% +``` + +When you run the command with the `amd64` architecture, the AMD64 version of `uname` of Python using Rosetta translation, so that you will see information for an AMD64 system: + +```shellsession +container run --arch amd64 --rm registry.example.com/fido/web-test:latest uname -a +Linux c0376e0a-0bfd-4eea-9e9e-9f9a2c327051 6.1.68 #1 SMP Mon Mar 31 18:27:51 UTC 2025 x86_64 GNU/Linux +``` + +The command to push your multiplatform image to a registry is no different than that for a single-platform image: + +```bash +container images push registry.example.com/fido/web-test:latest +``` + +### Get container or image details + +`container images list` and `container list` provide basic information for all of your images and containers. You can also use `list` and `inspect` commands to print detailed JSON output for one or more resources. + +Use the `inspect` command and send the result to the `jq` command to get pretty-printed JSON for the images or containers that you specify: + +```shellsession +% container images inspect web-test | jq +[ + { + "name": "web-test:latest", + "variants": [ + { + "platform": { + "os": "linux", + "architecture": "arm64" + }, + "config": { + "created": "2025-05-08T22:27:23Z", + "architecture": "arm64", +... +% container inspect my-web-server | jq +[ + { + "status": "running", + "networks": [ + { + "address": "192.168.64.3/24", + "gateway": "192.168.64.1", + "hostname": "my-web-server.test.", + "network": "default" + } + ], + "configuration": { + "mounts": [], + "hostname": "my-web-server", + "id": "my-web-server", + "resources": { + "cpus": 4, + "memoryInBytes": 1073741824, + }, +... +``` + +Use the `list` command with the `--format` option to display information for all images or containers. In this example, the `--all` option shows stopped as well as running containers, and `jq` selects the IP address for each running container: + +```shellsession +% container ls --format json --all | jq '.[] | select ( .status == "running" ) | [ .configuration.id, .networks[0].address ]' +[ + "my-web-server", + "192.168.64.3/24" +] +[ + "buildkit", + "192.168.64.2/24" +] +``` + +### View container logs + +The `container logs` command displays the output from your containerized application: + +```shellsession +% container run -d --dns-domain test --name my-web-server --rm registry.example.com/fido/web-test:latest +my-web-server +% curl http://my-web-server.test +Hello

Hello, world!

+% container logs my-web-server +192.168.64.1 - - [15/May/2025 03:00:03] "GET / HTTP/1.1" 200 - +% +``` + +Use the `--boot` option to see the logs for the virtual machine boot and init process: + +```shellsession +% container logs --boot my-web-server +[ 0.098284] cacheinfo: Unable to detect cache hierarchy for CPU 0 +[ 0.098466] random: crng init done +[ 0.099657] brd: module loaded +[ 0.100707] loop: module loaded +[ 0.100838] virtio_blk virtio2: 1/0/0 default/read/poll queues +[ 0.101051] virtio_blk virtio2: [vda] 1073741824 512-byte logical blocks (550 GB/512 GiB) +... +[ 0.127467] EXT4-fs (vda): mounted filesystem without journal. Quota mode: disabled. +[ 0.127525] VFS: Mounted root (ext4 filesystem) readonly on device 254:0. +[ 0.127635] devtmpfs: mounted +[ 0.127773] Freeing unused kernel memory: 2816K +[ 0.143252] Run /sbin/vminitd as init process +2025-05-15T02:24:08+0000 info vminitd : [vminitd] vminitd booting... +2025-05-15T02:24:08+0000 info vminitd : [vminitd] serve vminitd api +2025-05-15T02:24:08+0000 debug vminitd : [vminitd] starting process supervisor +2025-05-15T02:24:08+0000 debug vminitd : port=1024 [vminitd] booting grpc server on vsock +... +2025-05-15T02:24:08+0000 debug vminitd : exits=[362: 0] pid=363 [vminitd] checking for exit of managed process +2025-05-15T02:24:08+0000 debug vminitd : [vminitd] waiting on process my-web-server +[ 1.122742] IPv6: ADDRCONF(NETDEV_CHANGE): eth0: link becomes ready +2025-05-15T02:24:39+0000 debug vminitd : sec=1747275879 usec=478412 [vminitd] setTime +% +``` + +### View system logs + +The `container system logs` command allows you to look at the log messages that `container` writes: + +```shellsession +% container system logs | tail -8 +2025-06-02 16:46:11.560780-0700 0xf6dc5 Info 0x0 61684 0 container-apiserver: [com.apple.container:APIServer] Registering plugin [id=com.apple.container.container-runtime-linux.my-web-server] +2025-06-02 16:46:11.699095-0700 0xf6ea8 Info 0x0 61733 0 container-runtime-linux: [com.apple.container:RuntimeLinuxHelper] starting container-runtime-linux [uuid=my-web-server] +2025-06-02 16:46:11.699125-0700 0xf6ea8 Info 0x0 61733 0 container-runtime-linux: [com.apple.container:RuntimeLinuxHelper] configuring XPC server [uuid=my-web-server] +2025-06-02 16:46:11.700908-0700 0xf6ea8 Info 0x0 61733 0 container-runtime-linux: [com.apple.container:RuntimeLinuxHelper] starting XPC server [uuid=my-web-server] +2025-06-02 16:46:11.703028-0700 0xf6ea8 Info 0x0 61733 0 container-runtime-linux: [com.apple.container:RuntimeLinuxHelper] `bootstrap` xpc handler [uuid=my-web-server] +2025-06-02 16:46:11.720836-0700 0xf6dc3 Info 0x0 61689 0 container-network-vmnet: [com.apple.container:NetworkVmnetHelper] allocated attachment [hostname=my-web-server.test.] [address=192.168.64.2/24] [gateway=192.168.64.1] [id=default] +2025-06-02 16:46:12.293193-0700 0xf6eaa Info 0x0 61733 0 container-runtime-linux: [com.apple.container:RuntimeLinuxHelper] `start` xpc handler [uuid=my-web-server] +2025-06-02 16:46:12.368723-0700 0xf6e93 Info 0x0 61684 0 container-apiserver: [com.apple.container:APIServer] Handling container my-web-server Start. +% +``` + +## Technical Overview + +A brief description and technical overview of `container`. + +### What are containers? + +Containers are a way to package an application and its dependencies into a single unit. At runtime, containers provide isolation from the host machine as well as other colocated containers, allowing applications to run securely and efficiently in a wide variety of environments. + +Containerization is an important server-side technology that is used throughout the software lifecycle: + +- Backend developers use containers on their personal systems to create predictable execution environments for applications, and to develop and test their applications under conditions that better approximate how it runs in the datacenter. +- Continuous integration and deployment (CI/CD) systems use containerization to perform reproducible builds of applications, package the results as deployable images, and deploy them to the datacenter. +- Datacenters run container orchestration platforms that use the images to run containerized applications in a reliable, highly available computing cluster. + +None of this workflow would be practical without ensuring interoperability between different container implementations. The Open Container Initiative (OCI) creates and maintains these standards for container images and runtimes. + +### How does `container` run my container? + +Many operating systems support containers, but the most commonly encountered containers are those that run on the Linux operating system. On macOS, the typical way to run Linux containers is to launch a Linux virtual machine (VM) that hosts all of your containers. + +`container` runs containers differently: using the open source Containerization library, it runs a lightweight virtual machine for each container that you create. Running containers as individual VMs offers certain advantages compared to running them in a shared VM: + +- Security: Each container runs in its own Linux kernel environment, so that TODO. +- Privacy: To share host files easily with traditional containers, all of your user data needs to be mounted into the shared VM. With a `container`, you can choose exactly which data you want to give to each container. +- Performance: TODO something something + +[TODO: diagram showing shared vs discrete VMs] + +Since `container` consumes and produces standard OCI images, you can easily build with and run images produced by other container applications, and the images that you build will run everywhere. + +`container` and the underlying Containerization library integrate with many of the key technologies and frameworks of macOS: + +- The Virtualization framework for managing Linux virtual machines and their attached devices. +- The vmnet framework for managing the virtual network to which the containers attach. +- XPC for interprocess communication. +- Launchd for service management. +- Keychain services for access to registry credentials. + +[TODO: diagram showing `container` functional organization] + +The process `container-apiserver` is a launch agent that launches when you run the `container system start` command, and terminates when you run `container system stop`. It provides the client APIs for managing container, and network resources. + +When `container-apiserver` starts, it launches an XPC helper that exposes an API for image management, and another XPC helper for the virtual network. For each container that you create, `container-apiserver` launches a container runtime helper that exposes the management API for that specific container. + +You use the `container` command line interface (CLI) to start and manage your containers, and to build container images, and to pull images from and push images to container registries. The CLI uses a client library that communicates with `container-apiserver` and its helpers. + +See the design documents in the `container` and Containerization GitHub repositories for additional technical details. + +### What limitations does `container` have today? + +With the initial release of `container`, you get basic facilities for building and running containers, but many common containerization features remain to be implemented. Consider [contributing](/community) new features and bug fixes to `container` and the Containerization projects! + +#### Container to host networking + +In the initial release, there is no way to route traffic directly from a client in a container to an host-based application listening on the loopback loopback interface at 127.0.0.1. If you were to configure the application in your container to connect to 127.0.0.1 or `localhost`, requests will simply go to the loopback interface in the container, and not to your host-based service. + +You can work around this limitation configuring the host-based application to listen on the wildcard address 0.0.0.0, but this practice is insecure and not recommended because, without firewall rules, this opens up the application to external clients. + +A more secure approach is to use `socat` to redirect traffic from the container network gateway to the host-based service. For example, to forward traffic for port 8000, configure your containerized application to connect to `192.68.64.1:8000` instead of `127.0.0.1:8000`, and then run the following command in a terminal on your Mac to forward the port traffic from the gateway to the host: + +```bash +socat TCP-LISTEN:8000,fork,bind=192.168.64.1 TCP:127.0.0.1:8000 +``` + +#### Releasing container memory to macOS + +The macOS Virtualization framework implements only partial support for memory ballooning, which is a technology that allows virtual machines to dynamically receive and relinquish memory from the host. When you create a container, the underlying virtual machine only uses the amount of memory that the containerized application needs. So you might start a container using the option `--memory 16g`, but see that the application is only using 2 gigabytes of system memory. + +The current limitation, however, is that memory pages freed by the application to Linux in the container cannot be relinquished to the host. If you run many memory-intensive containers, you may need to occasionally restart them to reduce memory utilization. + +#### macOS Sequoia limitations + +`container` relies on the new features and enhancements present in the macOS 16 Developer Preview. You can run `container` on macOS Sequoia, but you will need to be aware of some user experience quirks and functional limitations. There is no plan to address issues found on Sequoia that cannot be reproduced in the Developer Preview. + +##### Network isolation + +The vmnet framework in Sequoia can only provide networks where the attached containers are isolated from one another. Container-to-container communication over the virtual network is not possible. + +##### Container IP addresses + +In Sequoia, limitations in the vmnet framework mean that the container network can only be created when the first container starts. Since the network XPC helper provides IP addresses to containers, and the helper has to start before the first container, it is possible for the network helper and vmnet to disagree on the subnet address, resulting in containers that are completely cut off from the network. + +Normally, vmnet creates the container network using the CIDR address 192.168.64.1/24, and on Sequoia, `container` defaults to using this CIDR address in the network helper. To diagnose and resolve issues where due to disagreement between vmnet and the network helper: + +- Before creating the first container, scan the output of the command `ifconfig` for all bridge interface named similarly to `bridge100`. +- After creating the first container, run `ifconfig` again, and locate the new bridge interface to determine container the subnet address. +- Run `container ls` to check the IP address given to the container by the network helper. If the address corresponds to a different network: + - Run `container system stop` to terminate the services for `container`. + - Using the macOS `defaults` command, update the default subnet value used by the network helper process. For example, if the bridge address shown by `ifconfig` is 192.168.66.1, run: + ```bash + defaults write com.apple.container.defaults default.subnet 192.168.66.1 + ``` + - Run `container system start` to launch services again. + - Try running the container again and verify that its IP address matches the current bridge interface value. + +## Build the application from source + +Build `container` and the background services from sources and run basic and integration tests: + +```bash +make all test integration +``` + +Copy the binaries to `/usr/local/bin` and `/usr/local/libexec` (requires entering the administrator's password): + +```bash +make install +``` + +### Protobufs + +`container` depends on specific versions of `grpc-swift` and `swift-protobuf`. You can install them and re-generate RPC interfaces with: + +```bash +make protos +``` + +## Included binaries + +- `container` is a command-line tool for managing images and containers. +- `container-apiserver` is an XPC service that provides an API for managing image and container resources. +- `container-core-images` is an XPC service for managing OCI images. +- `container-runtime-linux` is an XPC service that starts and manages the lifecycle of a single Linux container. +- `container-network-vmnet` is an XPC service for that starts and manages the lifecycle of a single vmnet network. + +## Contributing + +See [docs](./docs) for information on development and contribution to the container project. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..e9cb0239 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,11 @@ +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. 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.swift b/Sources/APIServer/APIServer.swift new file mode 100644 index 00000000..c771c946 --- /dev/null +++ b/Sources/APIServer/APIServer.swift @@ -0,0 +1,237 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 CVersion +import ContainerClient +import ContainerLog +import ContainerNetworkService +import ContainerPlugin +import ContainerXPC +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import DNSServer +import Foundation +import Logging + +@main +struct APIServer: AsyncParsableCommand { + static let listenAddress = "127.0.0.1" + static let dnsPort = 2053 + + static let configuration = CommandConfiguration( + commandName: "container-apiserver", + abstract: "Container management API server", + version: releaseVersion() + ) + + @Flag(name: .long, help: "Enable debug logging") + var debug = false + + @Option(name: .shortAndLong, help: "Daemon root directory") + var root = Self.appRoot.path + + static let appRoot: URL = { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("com.apple.container") + }() + + func run() async throws { + let commandName = Self.configuration.commandName ?? "container-apiserver" + let log = setupLogger() + log.info("starting \(commandName)") + defer { + log.info("stopping \(commandName)") + } + + do { + log.info("configuring XPC server") + let root = URL(filePath: root) + var routes = [XPCRoute: XPCServer.RouteHandler]() + let pluginLoader = try initializePluginLoader(log: log) + try await initializePlugins(pluginLoader: pluginLoader, log: log, routes: &routes) + try initializeContainerService(root: root, pluginLoader: pluginLoader, log: log, routes: &routes) + let networkService = try await initializeNetworkService( + root: root, + pluginLoader: pluginLoader, + log: log, + routes: &routes + ) + initializeHealthCheckService(log: log, routes: &routes) + try initializeKernelService(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 withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + log.info("starting XPC server") + try await server.listen() + } + // 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 host query resolver", + metadata: [ + "host": "\(Self.listenAddress)", + "port": "\(Self.dnsPort)", + ] + ) + try await dnsServer.run(host: Self.listenAddress, port: Self.dnsPort) + } + } + } catch { + log.error("\(commandName) failed", metadata: ["error": "\(error)"]) + APIServer.exit(withError: error) + } + } + + private func setupLogger() -> Logger { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "APIServer" + ) + } + var log = Logger(label: "com.apple.container") + if debug { + log.logLevel = .debug + } + return log + } + + private func initializePluginLoader(log: Logger) throws -> PluginLoader { + // create user-installed plugins directory if it doesn't exist + let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) + try FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) + + // 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 installRootPluginsURL = CommandLine.executableDirectoryUrl.appendingPathComponent("../libexec/container/plugins") + + let pluginDirectories = [ + pluginsURL, + appBundlePluginsURL, + installRootPluginsURL, + ].compactMap { $0 } + + let pluginFactories: [PluginFactory] = [ + DefaultPluginFactory(), + AppBundlePluginFactory(), + ] + + let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) + try FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) + return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, 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] + ) async throws { + let bootPlugins = pluginLoader.findPlugins().filter { $0.shouldBoot } + + let service = PluginsService(pluginLoader: pluginLoader, log: log) + try await service.loadAll(bootPlugins) + + let harness = PluginsHarness(service: service, log: log) + routes[XPCRoute.pluginGet] = harness.get + routes[XPCRoute.pluginList] = harness.list + routes[XPCRoute.pluginLoad] = harness.load + routes[XPCRoute.pluginUnload] = harness.unload + routes[XPCRoute.pluginRestart] = harness.restart + } + + private func initializeHealthCheckService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) { + let svc = HealthCheckHarness(log: log) + routes[XPCRoute.ping] = svc.ping + } + + private func initializeKernelService(log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { + let svc = try KernelService(log: log, appRoot: Self.appRoot) + let harnsess = KernelHarness(service: svc, log: log) + routes[XPCRoute.installKernel] = harnsess.install + routes[XPCRoute.getDefaultKernel] = harnsess.getDefaultKernel + } + + private func initializeContainerService(root: URL, pluginLoader: PluginLoader, log: Logger, routes: inout [XPCRoute: XPCServer.RouteHandler]) throws { + let service = try ContainersService( + root: root, + pluginLoader: pluginLoader, + log: log + ) + let harness = ContainersHarness(service: service, log: log) + + routes[XPCRoute.listContainer] = harness.list + routes[XPCRoute.createContainer] = harness.create + routes[XPCRoute.deleteContainer] = harness.delete + routes[XPCRoute.containerLogs] = harness.logs + routes[XPCRoute.containerEvent] = harness.eventHandler + } + + private func initializeNetworkService( + root: URL, + pluginLoader: PluginLoader, + log: Logger, + routes: inout [XPCRoute: XPCServer.RouteHandler] + ) async throws -> NetworksService { + let resourceRoot = root.appendingPathComponent("networks") + let service = try await NetworksService( + pluginLoader: pluginLoader, + resourceRoot: resourceRoot, + log: log + ) + + let defaultNetwork = try await service.list() + .filter { $0.id == ClientNetwork.defaultNetworkName } + .first + if defaultNetwork == nil { + let config = NetworkConfiguration(id: ClientNetwork.defaultNetworkName, mode: .nat) + _ = try await service.create(configuration: config) + } + + let harness = NetworksHarness(service: service, log: log) + + routes[XPCRoute.networkCreate] = harness.create + routes[XPCRoute.networkDelete] = harness.delete + routes[XPCRoute.networkList] = harness.list + return service + } + + private static func releaseVersion() -> String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + } +} diff --git a/Sources/APIServer/ContainerDNSHandler.swift b/Sources/APIServer/ContainerDNSHandler.swift new file mode 100644 index 00000000..cb4868b1 --- /dev/null +++ b/Sources/APIServer/ContainerDNSHandler.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import DNS +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? { + let question = query.questions[0] + let record: ResourceRecord? + switch question.type { + case ResourceRecordType.host: + record = try await answerHost(question: question) + case ResourceRecordType.nameServer, + ResourceRecordType.alias, + ResourceRecordType.startOfAuthority, + ResourceRecordType.pointer, + ResourceRecordType.mailExchange, + ResourceRecordType.text, + ResourceRecordType.host6, + ResourceRecordType.service, + ResourceRecordType.incrementalZoneTransfer, + ResourceRecordType.standardZoneTransfer, + ResourceRecordType.all: + return Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions, + answers: [] + ) + default: + return Message( + id: query.id, + type: .response, + returnCode: .formatError, + 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 components = ipAllocation.address.split(separator: "/") + guard components.count > 0 else { + return nil + } + guard let ip = IPv4(String(components[0])) else { + return nil + } + return HostRecord(name: question.name, ttl: ttl, ip: ip) + } +} diff --git a/Sources/APIServer/Containers/ContainersHarness.swift b/Sources/APIServer/Containers/ContainersHarness.swift new file mode 100644 index 00000000..1f17ad65 --- /dev/null +++ b/Sources/APIServer/Containers/ContainersHarness.swift @@ -0,0 +1,107 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging + +struct ContainersHarness { + let log: Logging.Logger + let service: ContainersService + + init(service: ContainersService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + func list(_ message: XPCMessage) async throws -> XPCMessage { + let containers = try await service.list() + let data = try JSONEncoder().encode(containers) + + let reply = message.reply() + reply.set(key: .containers, value: data) + return reply + } + + @Sendable + 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) + + try await service.create(configuration: config, kernel: kernel, options: options) + return message.reply() + } + + @Sendable + 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") + } + try await service.delete(id: id) + return message.reply() + } + + @Sendable + 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 + func eventHandler(_ message: XPCMessage) async throws -> XPCMessage { + let event = try message.containerEvent() + try await service.handleContainerEvents(event: event) + return message.reply() + } +} + +extension XPCMessage { + public func containerEvent() throws -> ContainerEvent { + guard let data = self.dataNoCopy(key: .containerEvent) else { + throw ContainerizationError(.invalidArgument, message: "Missing container event data") + } + let event = try JSONDecoder().decode(ContainerEvent.self, from: data) + return event + } +} diff --git a/Sources/APIServer/Containers/ContainersService.swift b/Sources/APIServer/Containers/ContainersService.swift new file mode 100644 index 00000000..a4692473 --- /dev/null +++ b/Sources/APIServer/Containers/ContainersService.swift @@ -0,0 +1,355 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerPlugin +import ContainerSandboxService +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import Logging + +actor ContainersService { + private static let machServicePrefix = "com.apple.container" + private static let launchdDomainString = try! ServiceManager.getDomainString() + + private let log: Logger + private let containerRoot: URL + private let pluginLoader: PluginLoader + private let runtimePlugins: [Plugin] + + private let lock = AsyncLock() + private var containers: [String: Item] + + struct Item: Sendable { + let bundle: ContainerClient.Bundle + var state: State + + enum State: Sendable { + case dead + case alive(SandboxClient) + case exited(Int32) + + func isDead() -> Bool { + switch self { + case .dead: return true + default: return false + } + } + } + } + + public init(root: URL, pluginLoader: PluginLoader, log: Logger) throws { + let containerRoot = root.appendingPathComponent("containers") + try FileManager.default.createDirectory(at: containerRoot, withIntermediateDirectories: true) + self.containerRoot = containerRoot + self.pluginLoader = pluginLoader + self.log = log + self.containers = try Self.loadAtBoot(root: containerRoot, log: log) + self.runtimePlugins = pluginLoader.findPlugins().filter { $0.hasType(.runtime) } + } + + static func loadAtBoot(root: URL, log: Logger) throws -> [String: Item] { + var directories = try FileManager.default.contentsOfDirectory( + at: root, + includingPropertiesForKeys: [.isDirectoryKey] + ) + directories = directories.filter { + $0.isDirectory + } + + var results = [String: Item]() + for dir in directories { + do { + let bundle = ContainerClient.Bundle(path: dir) + let config = try bundle.configuration + results[config.id] = .init(bundle: bundle, state: .dead) + } catch { + try? FileManager.default.removeItem(at: dir) + log.warning("failed to load container bundle at \(dir.path)") + } + } + return results + } + + private func setContainer(_ id: String, _ item: Item, context: AsyncLock.Context) async { + self.containers[id] = item + } + + /// List all containers registered with the service. + public func list() async throws -> [ContainerSnapshot] { + self.log.debug("\(#function)") + return await lock.withLock { context in + var snapshots = [ContainerSnapshot]() + + for (id, item) in await self.containers { + do { + let result = try await item.asSnapshot() + snapshots.append(result.0) + } catch { + self.log.error("unable to load bundle for \(id) \(error)") + } + } + return snapshots + } + } + + /// Create a new container from the provided id and configuration. + public func create(configuration: ContainerConfiguration, kernel: Kernel, options: ContainerCreateOptions) async throws { + self.log.debug("\(#function)") + + let runtimePlugin = self.runtimePlugins.filter { + $0.name == configuration.runtimeHandler + }.first + guard let runtimePlugin else { + throw ContainerizationError(.notFound, message: "unable to locate runtime plugin \(configuration.runtimeHandler)") + } + + let path = self.containerRoot.appendingPathComponent(configuration.id) + let systemPlatform = kernel.platform + let initFs = try await getInitBlock(for: systemPlatform.ociPlatform()) + + let bundle = try ContainerClient.Bundle.create( + path: path, + initialFilesystem: initFs, + kernel: kernel, + containerConfiguration: configuration + ) + do { + let containerImage = ClientImage(description: configuration.image) + let imageFs = try await containerImage.getCreateSnapshot(platform: configuration.platform) + try bundle.setContainerRootFs(cloning: imageFs) + try bundle.write(filename: "options.json", value: options) + + try self.registerService( + plugin: runtimePlugin, + configuration: configuration, + path: path + ) + } catch { + do { + try bundle.delete() + } catch { + self.log.error("failed to delete bundle for container \(configuration.id): \(error)") + } + throw error + } + self.containers[configuration.id] = Item(bundle: bundle, state: .dead) + } + + private func getInitBlock(for platform: Platform) async throws -> Filesystem { + let initImage = try await ClientImage.fetch(reference: ClientImage.initImageRef, platform: platform) + var fs = try await initImage.getCreateSnapshot(platform: platform) + fs.options = ["ro"] + return fs + } + + private func registerService( + plugin: Plugin, + configuration: ContainerConfiguration, + path: URL + ) throws { + let args = [ + "--root", path.path, + "--uuid", configuration.id, + "--debug", + ] + try pluginLoader.registerWithLaunchd( + plugin: plugin, + rootURL: path, + args: args, + instanceId: configuration.id + ) + } + + private func get(id: String, context: AsyncLock.Context) throws -> Item { + try self._get(id: id) + } + + private func _get(id: String) throws -> Item { + let item = self.containers[id] + guard let item else { + throw ContainerizationError( + .notFound, + message: "container with ID \(id) not found" + ) + } + return item + } + + /// Delete a container and its resources. + public func delete(id: String) async throws { + self.log.debug("\(#function)") + let item = try self._get(id: id) + switch item.state { + case .alive(let client): + let state = try await client.state() + if state.status == .running { + throw ContainerizationError( + .invalidState, + message: "container with ID \(id) is running" + ) + } + try self._cleanup(id: id, item: item) + case .dead, .exited(_): + try self._cleanup(id: id, item: item) + } + } + + private static func fullLaunchdServiceLabel(runtimeName: String, instanceId: String) -> String { + "\(Self.launchdDomainString)/\(Self.machServicePrefix).\(runtimeName).\(instanceId)" + } + + private func _cleanup(id: String, item: Item) throws { + self.log.debug("\(#function)") + let config = try item.bundle.configuration + let label = Self.fullLaunchdServiceLabel(runtimeName: config.runtimeHandler, instanceId: id) + try ServiceManager.deregister(fullServiceLabel: label) + try item.bundle.delete() + self.containers.removeValue(forKey: id) + } + + private func _shutdown(id: String, item: Item) throws { + let config = try item.bundle.configuration + let label = Self.fullLaunchdServiceLabel(runtimeName: config.runtimeHandler, instanceId: id) + try ServiceManager.kill(fullServiceLabel: label) + } + + private func cleanup(id: String, item: Item, context: AsyncLock.Context) async throws { + try self._cleanup(id: id, item: item) + } + + private func containerProcessExitHandler(_ id: String, _ exitCode: Int32, context: AsyncLock.Context) async { + self.log.info("Handling container \(id) exit. Code \(exitCode)") + do { + var item = try self.get(id: id, context: context) + switch item.state { + case .dead, .exited(_): + break + case .alive(_): + item.state = .exited(exitCode) + await self.setContainer(id, item, context: context) + } + let options: ContainerCreateOptions = try item.bundle.load(filename: "options.json") + if options.autoRemove { + try await self.cleanup(id: id, item: item, context: context) + } + } catch { + self.log.error( + "Failed to handle container exit", + metadata: [ + "id": .string(id), + "error": .string(String(describing: error)), + ]) + } + } + + private func containerStartHandler(_ id: String, context: AsyncLock.Context) async throws { + self.log.debug("\(#function)") + self.log.info("Handling container \(id) Start.") + do { + var item = try self.get(id: id, context: context) + let configuration = try item.bundle.configuration + let client = SandboxClient(id: configuration.id, runtime: configuration.runtimeHandler) + item.state = .alive(client) + await self.setContainer(id, item, context: context) + } catch { + self.log.error( + "Failed to handle container start", + metadata: [ + "id": .string(id), + "error": .string(String(describing: error)), + ]) + } + } +} + +extension ContainersService { + public func handleContainerEvents(event: ContainerEvent) async throws { + self.log.debug("\(#function)") + try await self.lock.withLock { context in + switch event { + case .containerExit(let id, let code): + await self.containerProcessExitHandler(id, Int32(code), context: context) + case .containerStart(let id): + try await self.containerStartHandler(id, context: context) + } + } + } + + /// 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 { + self.log.debug("\(#function)") + try await lock.withLock { context in + let item = try await self.get(id: id, context: context) + switch item.state { + case .dead, .exited(_): + return + case .alive(let client): + try await client.stop(options: options) + } + } + } + + public func logs(id: String) async throws -> [FileHandle] { + self.log.debug("\(#function)") + // Logs doesn't care if the container is running or not, just that + // the bundle is there, and that the files actually exist. + do { + let item = try self._get(id: id) + return [ + try FileHandle(forReadingFrom: item.bundle.containerLog), + try FileHandle(forReadingFrom: item.bundle.bootlog), + ] + } catch { + throw ContainerizationError( + .internalError, + message: "failed to open container logs: \(error)" + ) + } + } +} + +extension ContainersService.Item { + func asSnapshot() async throws -> (ContainerSnapshot, RuntimeStatus) { + let config = try self.bundle.configuration + + switch self.state { + case .dead, .exited(_): + return ( + .init( + configuration: config, + status: RuntimeStatus.stopped, + networks: [] + ), .stopped + ) + case .alive(let client): + let state = try await client.state() + return ( + .init( + configuration: config, + status: state.status, + networks: state.networks + ), state.status + ) + } + } +} diff --git a/Sources/APIServer/HealthCheck/HealthCheckHarness.swift b/Sources/APIServer/HealthCheck/HealthCheckHarness.swift new file mode 100644 index 00000000..77323232 --- /dev/null +++ b/Sources/APIServer/HealthCheck/HealthCheckHarness.swift @@ -0,0 +1,33 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import ContainerXPC +import Containerization +import Logging + +actor HealthCheckHarness { + private let log: Logger + + public init(log: Logger) { + self.log = log + } + + @Sendable + func ping(_ message: XPCMessage) async -> XPCMessage { + message.reply() + } +} diff --git a/Sources/APIServer/Kernel/FileDownloader.swift b/Sources/APIServer/Kernel/FileDownloader.swift new file mode 100644 index 00000000..366a054e --- /dev/null +++ b/Sources/APIServer/Kernel/FileDownloader.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +internal 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() + _ = try await client.execute(request: request, delegate: delegate).get() + try await client.shutdown() + } + + private static func createClient() -> HTTPClient { + var httpConfiguration = HTTPClient.Configuration() + let proxyConfig: HTTPClient.Configuration.Proxy? = { + let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"] + guard let proxyEnv else { + return nil + } + guard let url = URL(string: proxyEnv), let host = url.host(), let port = url.port else { + return nil + } + return .server(host: host, port: port) + }() + httpConfiguration.proxy = proxyConfig + return HTTPClient(eventLoopGroupProvider: .singleton, configuration: httpConfiguration) + } +} diff --git a/Sources/APIServer/Kernel/KernelHarness.swift b/Sources/APIServer/Kernel/KernelHarness.swift new file mode 100644 index 00000000..5de5460a --- /dev/null +++ b/Sources/APIServer/Kernel/KernelHarness.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import ContainerXPC +import Containerization +import ContainerizationError +import Foundation +import Logging + +struct KernelHarness { + private let log: Logging.Logger + private let service: KernelService + + init(service: KernelService, log: Logging.Logger) { + self.log = log + self.service = service + } + + public func install(_ message: XPCMessage) async throws -> XPCMessage { + let kernelFilePath = try message.kernelFilePath() + let platform = try message.platform() + + 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) + return message.reply() + } + + let progressUpdateService = ProgressUpdateService(message: message) + try await self.service.installKernelFrom(tar: kernelTarUrl, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progressUpdateService?.handler) + return message.reply() + } + + 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 + } +} diff --git a/Sources/APIServer/Kernel/KernelService.swift b/Sources/APIServer/Kernel/KernelService.swift new file mode 100644 index 00000000..c85929b1 --- /dev/null +++ b/Sources/APIServer/Kernel/KernelService.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import Containerization +import ContainerizationArchive +import ContainerizationError +import ContainerizationExtras +import Foundation +import Logging +import TerminalProgress + +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) throws { + self.log.info("KernelService: \(#function) - kernelFile: \(url), platform: \(String(describing: platform))") + let kFile = url.resolvingSymlinksInPath() + let destPath = self.kernelDirectory.appendingPathComponent(kFile.lastPathComponent) + try FileManager.default.copyItem(at: kFile, to: destPath) + try self.setDefaultKernel(name: kFile.lastPathComponent, platform: platform) + } + + /// 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?) async throws { + self.log.info("KernelService: \(#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: Downloading \(tar)") + tarFile = tempDir.appendingPathComponent(tar.lastPathComponent) + var downloadProgressUpdate: ProgressUpdateHandler? + if let progressUpdate { + downloadProgressUpdate = ProgressTaskCoordinator.handler(for: downloadTask, from: progressUpdate) + } + try await FileDownloader.downloadFile(url: tar, to: tarFile, progressUpdate: downloadProgressUpdate) + } + await taskManager.finish() + + await progressUpdate?([ + .setDescription("Unpacking kernel") + ]) + let archiveReader = try ArchiveReader(file: tarFile) + try archiveReader.extractContents(to: tempDir) + let kernelPath = tempDir.appendingPathComponent(kernelFilePath).resolvingSymlinksInPath() + try self.installKernel(kernelFile: kernelPath, platform: platform) + + if !FileManager.default.fileExists(atPath: tar.absoluteString) { + try FileManager.default.removeItem(at: tarFile) + } + } + + private func setDefaultKernel(name: String, platform: SystemPlatform) throws { + self.log.info("KernelService: \(#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 { + self.log.info("KernelService: \(#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) + } +} diff --git a/Sources/APIServer/Networks/NetworksHarness.swift b/Sources/APIServer/Networks/NetworksHarness.swift new file mode 100644 index 00000000..e8008c63 --- /dev/null +++ b/Sources/APIServer/Networks/NetworksHarness.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +import ContainerXPC +import ContainerizationError +import ContainerizationOS +import Foundation +import Logging + +struct NetworksHarness: Sendable { + let log: Logging.Logger + let service: NetworksService + + init(service: NetworksService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + func list(_ message: XPCMessage) async throws -> XPCMessage { + let containers = try await service.list() + let data = try JSONEncoder().encode(containers) + + let reply = message.reply() + reply.set(key: .networkStates, value: data) + return reply + } + + @Sendable + 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 networkState = try await service.create(configuration: config) + + let networkData = try JSONEncoder().encode(networkState) + + let reply = message.reply() + reply.set(key: .networkState, value: networkData) + return reply + } + + @Sendable + 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/APIServer/Networks/NetworksService.swift b/Sources/APIServer/Networks/NetworksService.swift new file mode 100644 index 00000000..85184c0d --- /dev/null +++ b/Sources/APIServer/Networks/NetworksService.swift @@ -0,0 +1,238 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import ContainerNetworkService +import ContainerPersistence +import ContainerPlugin +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOS +import Foundation +import Logging + +actor NetworksService { + private let resourceRoot: URL + // FIXME: remove qualifier once we can update Containerization dependency. + private let store: ContainerPersistence.FilesystemEntityStore + private let pluginLoader: PluginLoader + private let log: Logger + private let networkPlugin: Plugin + + private var networkStates = [String: NetworkState]() + private var busyNetworks = Set() + + public init(pluginLoader: PluginLoader, resourceRoot: URL, log: Logger) async throws { + try FileManager.default.createDirectory(at: resourceRoot, withIntermediateDirectories: true) + self.resourceRoot = resourceRoot + self.store = try FilesystemEntityStore(path: resourceRoot, type: "network", log: log) + self.pluginLoader = pluginLoader + self.log = log + + let networkPlugin = + pluginLoader + .findPlugins() + .filter { $0.hasType(.network) } + .first + guard let networkPlugin else { + throw ContainerizationError(.internalError, message: "cannot find network plugin") + } + self.networkPlugin = networkPlugin + + let configurations = try await store.list() + for configuration in configurations { + do { + try await registerService(configuration: configuration) + } catch { + log.error( + "failed to start network", + metadata: [ + "id": "\(configuration.id)" + ]) + } + + let client = NetworkClient(id: configuration.id) + let networkState = try await client.state() + networkStates[configuration.id] = networkState + guard case .running = networkState else { + log.error( + "network failed to start", + metadata: [ + "id": "\(configuration.id)", + "state": "\(networkState.state)", + ]) + return + } + } + } + + /// List all networks registered with the service. + public func list() async throws -> [NetworkState] { + log.info("network service: list") + return networkStates.reduce(into: [NetworkState]()) { + $0.append($1.value) + } + } + + /// Create a new network from the provided configuration. + public func create(configuration: NetworkConfiguration) async throws -> NetworkState { + 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) } + + log.info( + "network service: create", + metadata: [ + "id": "\(configuration.id)" + ]) + + // Ensure the network doesn't already exist. + guard networkStates[configuration.id] == nil else { + throw ContainerizationError(.exists, message: "network \(configuration.id) already exists") + } + + // Create and start the network. + try await registerService(configuration: configuration) + let client = NetworkClient(id: configuration.id) + let networkState = try await client.state() + networkStates[configuration.id] = networkState + + // Persist the configuration data. + do { + try await store.create(configuration) + return networkState + } catch { + networkStates.removeValue(forKey: configuration.id) + do { + try pluginLoader.deregisterWithLaunchd(plugin: networkPlugin, instanceId: configuration.id) + } catch { + log.error( + "failed to deregister network service after failed creation", + metadata: [ + "id": "\(configuration.id)", + "error": "\(error.localizedDescription)", + ]) + } + + throw error + } + } + + /// Delete a network. + public func delete(id: String) async throws { + guard !busyNetworks.contains(id) else { + throw ContainerizationError(.exists, message: "network \(id) has a pending operation") + } + + busyNetworks.insert(id) + defer { busyNetworks.remove(id) } + + log.info( + "network service: delete", + metadata: [ + "id": "\(id)" + ]) + if id == ClientNetwork.defaultNetworkName { + throw ContainerizationError(.invalidArgument, message: "cannot delete system subnet \(ClientNetwork.defaultNetworkName)") + } + + guard let networkState = networkStates[id] else { + throw ContainerizationError(.notFound, message: "no network for id \(id)") + } + + guard case .running = networkState else { + throw ContainerizationError(.invalidState, message: "cannot delete subnet \(id) in state \(networkState.state)") + } + + let client = NetworkClient(id: id) + guard try await client.disableAllocator() else { + throw ContainerizationError(.invalidState, message: "cannot delete subnet \(id) with containers attached") + } + + defer { networkStates.removeValue(forKey: id) } + do { + try pluginLoader.deregisterWithLaunchd(plugin: networkPlugin, instanceId: id) + } catch { + log.error( + "failed to deregister network service after failed creation", + metadata: [ + "id": "\(id)", + "error": "\(error.localizedDescription)", + ]) + } + + do { + try await store.delete(id) + } catch { + throw ContainerizationError(.notFound, message: error.localizedDescription) + } + } + + /// Perform a hostname lookup on all networks. + public func lookup(hostname: String) async throws -> Attachment? { + for id in networkStates.keys { + let client = NetworkClient(id: id) + guard let allocation = try await client.lookup(hostname: hostname) else { + continue + } + return allocation + } + return nil + } + + private func registerService(configuration: NetworkConfiguration) async throws { + guard configuration.mode == .nat else { + throw ContainerizationError(.invalidArgument, message: "unsupported network mode \(configuration.mode.rawValue)") + } + + 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, + ] + + if let subnet = (try configuration.subnet.map { try CIDRAddress($0) }) { + var existingCidrs: [CIDRAddress] = [] + for networkState in networkStates.values { + if case .running(_, let status) = networkState { + existingCidrs.append(try CIDRAddress(status.address)) + } + } + let overlap = existingCidrs.first { $0.overlaps(cidr: subnet) } + if let overlap { + throw ContainerizationError(.exists, message: "subnet \(subnet) overlaps an existing network with subnet \(overlap)") + } + + args += ["--subnet", subnet.description] + } + + try await pluginLoader.registerWithLaunchd( + plugin: networkPlugin, + rootURL: store.entityUrl(configuration.id), + args: args, + instanceId: configuration.id + ) + } +} diff --git a/Sources/APIServer/Plugin/PluginsHarness.swift b/Sources/APIServer/Plugin/PluginsHarness.swift new file mode 100644 index 00000000..47d170a2 --- /dev/null +++ b/Sources/APIServer/Plugin/PluginsHarness.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +struct PluginsHarness { + private let log: Logging.Logger + private let service: PluginsService + + init(service: PluginsService, log: Logging.Logger) { + self.log = log + self.service = service + } + + @Sendable + 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 + 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 + 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 + 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 + 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/APIServer/Plugin/PluginsService.swift b/Sources/APIServer/Plugin/PluginsService.swift new file mode 100644 index 00000000..a572810e --- /dev/null +++ b/Sources/APIServer/Plugin/PluginsService.swift @@ -0,0 +1,111 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +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, + ) throws { + let registerPlugins = plugins ?? pluginLoader.findPlugins() + for plugin in registerPlugins { + try pluginLoader.registerWithLaunchd(plugin: plugin) + 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) 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) + 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/CLI/Application.swift b/Sources/CLI/Application.swift new file mode 100644 index 00000000..f2633d8f --- /dev/null +++ b/Sources/CLI/Application.swift @@ -0,0 +1,296 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 CVersion +import ContainerClient +import ContainerLog +import ContainerPlugin +import ContainerizationOS +import Foundation +import Logging +import TerminalProgress + +// `log` is updated only once in the `validate()` method. +nonisolated(unsafe) var log = { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "CLI" + ) + } + var log = Logger(label: "com.apple.container") + log.logLevel = .debug + return log +}() + +@main +struct Application: AsyncParsableCommand { + @OptionGroup + var global: Flags.Global + + static let configuration = CommandConfiguration( + commandName: "container", + abstract: "A container platform for macOS", + version: releaseVersion(), + subcommands: [ + DefaultCommand.self + ], + groupedSubcommands: [ + CommandGroup( + name: "Container", + subcommands: [ + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerRunCommand.self, + ContainerStart.self, + ContainerStop.self, + ] + ), + CommandGroup( + name: "Image", + subcommands: [ + BuildCommand.self, + ImagesCommand.self, + RegistryCommand.self, + ] + ), + CommandGroup( + name: "System", + subcommands: [ + BuilderCommand.self, + SystemCommand.self, + ] + ), + ], + // Hidden command to handle plugins on unrecognized input. + defaultSubcommand: DefaultCommand.self + ) + + static let appRoot: URL = { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("com.apple.container") + }() + + static let pluginLoader: PluginLoader = { + // create user-installed plugins directory if it doesn't exist + let pluginsURL = PluginLoader.userPluginsDir(root: Self.appRoot) + try! FileManager.default.createDirectory(at: pluginsURL, withIntermediateDirectories: true) + let pluginDirectories = [ + pluginsURL + ] + let pluginFactories = [ + DefaultPluginFactory() + ] + + let statePath = PluginLoader.defaultPluginResourcePath(root: Self.appRoot) + try! FileManager.default.createDirectory(at: statePath, withIntermediateDirectories: true) + return PluginLoader(pluginDirectories: pluginDirectories, pluginFactories: pluginFactories, defaultResourcePath: statePath, log: log) + }() + + 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.global.debug || debugEnvVar != nil { + log.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 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 { + ProgressBar.resetCursor() + } + } + + public static func main() async throws { + restoreCursorAtExit() + + let fullArgs = CommandLine.arguments + let args = Array(fullArgs.dropFirst()) + + do { + // container -> defaultHelpCommand + var command = try Application.parseAsRoot(args) + if var asyncCommand = command as? AsyncParsableCommand { + try await asyncCommand.run() + } else { + try command.run() + } + } catch { + // Regular ol `command` with no args will get caught by DefaultCommand. --help + // on the root command will land here. + let containsHelp = fullArgs.contains("-h") || fullArgs.contains("--help") + if fullArgs.count <= 2 && containsHelp { + Self.printModifiedHelpText() + return + } + Application.exit(withError: error) + } + } + + static func handleProcess(io: ProcessIO, process: ClientProcess) async throws -> Int32 { + let signals = AsyncSignalHandler.create(notify: Application.signalSet) + return try await withThrowingTaskGroup(of: Int32?.self, returning: Int32.self) { group in + let waitAdded = group.addTaskUnlessCancelled { + try await process.wait() + } + + guard waitAdded else { + group.cancelAll() + return -1 + } + + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + + if let current = io.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 + } + } +} + +extension Application { + // Because we support plugins, we need to modify the help text to display + // any if we found some. + static func printModifiedHelpText() { + let altered = Self.pluginLoader.alterCLIHelpText( + original: Application.helpMessage(for: Application.self) + ) + print(altered) + } + + enum ListFormat: String, CaseIterable, ExpressibleByArgument { + case json + case table + } + + static let signalSet: [Int32] = [ + SIGTERM, + SIGINT, + SIGUSR1, + SIGUSR2, + SIGWINCH, + ] + + 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 + } + } + + private static func releaseVersion() -> String { + var extras = "release" + #if DEBUG + extras = "debug" + #endif + #if CURRENT_SDK + extras += " MacOS 15 SDK" + #endif + + let bundleVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) + let releaseVersion = bundleVersion ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + let scVersion = get_swift_containerization_version().map { String(cString: $0) } ?? "0.0.0" + let gitCommit = get_git_commit().map { String(cString: $0) } ?? "unspecified" + return "app \(releaseVersion) commit \(gitCommit) containerization \(scVersion) \(extras)" + } +} diff --git a/Sources/CLI/BuildCommand.swift b/Sources/CLI/BuildCommand.swift new file mode 100644 index 00000000..05426e1b --- /dev/null +++ b/Sources/CLI/BuildCommand.swift @@ -0,0 +1,313 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerBuild +import ContainerClient +import ContainerImagesServiceClient +import Containerization +import ContainerizationError +import ContainerizationOCI +import ContainerizationOS +import Foundation +import NIO +import TerminalProgress + +extension Application { + struct BuildCommand: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "build" + config.abstract = "Build an image from a Dockerfile" + config._superCommandName = "container" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + var memory: String = "2048MB" + + @Option(name: .long, help: ArgumentHelp("Set build-time variables", valueName: "key=val")) + var buildArg: [String] = [] + + @Argument(help: "Build directory") + var contextDir: String = "." + + @Option(name: .shortAndLong, help: ArgumentHelp("Path to Dockerfile", valueName: "path")) + var file: String = "Dockerfile" + + @Option(name: .shortAndLong, help: ArgumentHelp("Set a label", valueName: "key=val")) + var label: [String] = [] + + @Flag(name: .long, help: "Do not use cache") + var noCache: Bool = false + + @Option(name: .shortAndLong, help: ArgumentHelp("Output configuration for the build", valueName: "value")) + var output: [String] = { + ["type=oci"] + }() + + @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: .long, help: ArgumentHelp("set the build architecture", valueName: "value")) + var arch: [String] = { + ["arm64"] + }() + + @Option(name: .long, help: ArgumentHelp("set the build os", valueName: "value")) + var os: [String] = { + ["linux"] + }() + + @Option(name: .long, help: ArgumentHelp("Progress type - one of [auto|plain|tty]", valueName: "type")) + var progress: String = "auto" + + @Option(name: .long, help: ArgumentHelp("Builder-shim vsock port", valueName: "port")) + var vsockPort: UInt32 = 8088 + + @Option(name: [.customShort("t"), .customLong("tag")], help: ArgumentHelp("Name for the built image", valueName: "name")) + var targetImageName: String = UUID().uuidString.lowercased() + + @Option(name: .long, help: ArgumentHelp("Set the target build stage", valueName: "stage")) + var target: String = "" + + @Flag(name: .shortAndLong, help: "Suppress build output") + var quiet: Bool = false + + func run() async throws { + 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 builder: Builder? = try await withThrowingTaskGroup(of: Builder.self) { group in + defer { + group.cancelAll() + } + + group.addTask { + while true { + do { + let container = try await ClientContainer.get(id: "buildkit") + let fh = try await container.dial(self.vsockPort) + + let threadGroup: MultiThreadedEventLoopGroup = MultiThreadedEventLoopGroup(numberOfThreads: System.coreCount) + let b = try Builder(socket: fh, group: threadGroup) + + // 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: self.cpus, + memory: self.memory, + progressUpdate: progress.handler + ) + + // 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 dockerfile = try Data(contentsOf: URL(filePath: file)) + let exportPath = Application.appRoot.appendingPathComponent(".build") + + 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 imageName: String = try { + let parsedReference = try Reference.parse(targetImageName) + 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 + default: + throw ContainerizationError(.invalidArgument, message: "invalid progress mode \(self.progress)") + } + + 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: [Platform] = try { + var results: [Platform] = [] + for o in self.os { + for a in self.arch { + guard let platform = try? Platform(from: "\(o)/\(a)") else { + throw ValidationError("invalid os/architecture combination \(o)/\(a)") + } + results.append(platform) + } + } + return results + }() + group.addTask { [terminal] in + let config = ContainerBuild.Builder.BuildConfig( + buildID: buildID, + contentStore: RemoteContentStoreClient(), + buildArgs: buildArg, + contextDir: contextDir, + dockerfile: dockerfile, + labels: label, + noCache: noCache, + platforms: platforms, + terminal: terminal, + tag: imageName, + target: target, + quiet: quiet, + exports: exports, + cacheIn: cacheIn, + cacheOut: cacheOut + ) + progress.finish() + + try await builder.build(config) + } + + try await group.next() + } + + 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() + + 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 loaded = try await ClientImage.load(from: dest.absolutePath()) + + for image in loaded { + try Task.checkCancellation() + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: unpackProgress.handler)) + } + case "tar": + break + default: + throw ContainerizationError(.invalidArgument, message: "invalid exporter \(exp.rawValue)") + } + } + await taskManager.finish() + unpackProgress.finish() + print("Successfully built \(imageName)") + } catch { + throw NSError(domain: "Build", code: 1, userInfo: [NSLocalizedDescriptionKey: "\(error)"]) + } + } + + func validate() throws { + guard FileManager.default.fileExists(atPath: file) else { + throw ValidationError("Dockerfile does not exist at path: \(file)") + } + guard FileManager.default.fileExists(atPath: contextDir) else { + throw ValidationError("context dir does not exist \(contextDir)") + } + guard let _ = try? Reference.parse(targetImageName) else { + throw ValidationError("invalid reference \(targetImageName)") + } + } + } +} diff --git a/Sources/CLI/Builder/Builder.swift b/Sources/CLI/Builder/Builder.swift new file mode 100644 index 00000000..ad9eb6c9 --- /dev/null +++ b/Sources/CLI/Builder/Builder.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension Application { + struct BuilderCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "builder", + abstract: "Manage an image builder instance", + subcommands: [ + BuilderStart.self, + BuilderStatus.self, + BuilderStop.self, + BuilderDelete.self, + ]) + } +} diff --git a/Sources/CLI/Builder/BuilderDelete.swift b/Sources/CLI/Builder/BuilderDelete.swift new file mode 100644 index 00000000..e848da95 --- /dev/null +++ b/Sources/CLI/Builder/BuilderDelete.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Foundation + +extension Application { + struct BuilderDelete: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "delete" + config._superCommandName = "builder" + config.abstract = "Delete builder" + config.usage = "\n\t builder delete [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .shortAndLong, help: "Force delete builder even if it is running") + var force = false + + func run() async throws { + do { + let container = try await ClientContainer.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 container.stop() + } + try await container.delete() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + return + } + } + throw error + } + } + } +} diff --git a/Sources/CLI/Builder/BuilderStart.swift b/Sources/CLI/Builder/BuilderStart.swift new file mode 100644 index 00000000..f786b8be --- /dev/null +++ b/Sources/CLI/Builder/BuilderStart.swift @@ -0,0 +1,267 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerBuild +import ContainerClient +import ContainerNetworkService +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct BuilderStart: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "start" + config._superCommandName = "builder" + config.abstract = "Start builder" + config.usage = "\nbuilder start [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64 = 2 + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + public var memory: String = "2048MB" + + func run() async throws { + let progressConfig = try ProgressConfig( + showTasks: true, + showItems: true, + totalTasks: 4 + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + try await Self.start(cpus: self.cpus, memory: self.memory, progressUpdate: progress.handler) + progress.finish() + } + + static func start(cpus: Int64?, memory: String?, progressUpdate: @escaping ProgressUpdateHandler) async throws { + await progressUpdate([ + .setDescription("Fetching BuildKit image"), + .setItemsName("blobs"), + ]) + let taskManager = ProgressTaskCoordinator() + let fetchTask = await taskManager.startTask() + + let builderImage: String = ClientDefaults.get(key: .defaultBuilderImage) + let exportsMount: String = Application.appRoot.appendingPathComponent(".build").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") + + let existingContainer = try? await ClientContainer.get(id: "buildkit") + if let existingContainer { + let existingImage = existingContainer.configuration.image.reference + let existingResources = existingContainer.configuration.resources + + // Check if we need to recreate the builder due to different image + let imageChanged = existingImage != builderImage + let cpuChanged = { + if let cpus { + if existingResources.cpus != cpus { + return true + } + } + return false + }() + let memChanged = try { + if let memory { + let memoryInBytes = try Parser.resources(cpus: nil, memory: memory).memoryInBytes + if existingResources.memoryInBytes != memoryInBytes { + return true + } + } + return false + }() + + switch existingContainer.status { + case .running: + guard imageChanged || cpuChanged || memChanged else { + // If image, mem and cpu are the same, continue using the existing builder + return + } + // If they changed, stop and delete the existing builder + try await existingContainer.stop() + try await existingContainer.delete() + case .stopped: + // If the builder is stopped and matches our requirements, start it + // Otherwise, delete it and create a new one + if imageChanged || cpuChanged || memChanged { + try await existingContainer.delete() + } else { + try await existingContainer.startBuildKit(progressUpdate, nil) + return + } + case .unknown: + break + } + } + + let shimArguments: [String] = [ + "--debug", + "--vsock", + ] + + let id = "buildkit" + try ContainerClient.Utility.validEntityName(id) + + let processConfig = ProcessConfiguration( + executable: "/usr/local/bin/container-builder-shim", + arguments: shimArguments, + environment: [], + workingDirectory: "/", + terminal: false, + user: .id(uid: 0, gid: 0) + ) + + let resources = try Parser.resources( + cpus: cpus, + memory: memory + ) + + let image = try await ClientImage.fetch( + reference: builderImage, + platform: builderPlatform, + 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 imageConfig = ImageDescription( + reference: builderImage, + descriptor: image.descriptor + ) + + var config = ContainerConfiguration(id: id, image: imageConfig, process: processConfig) + config.resources = resources + config.mounts = [ + .init( + type: .tmpfs, + source: "", + destination: "/run", + options: [] + ), + .init( + type: .virtiofs, + source: exportsMount, + destination: "/var/lib/container-builder-shim/exports", + options: [] + ) + ] + config.rosetta = true + + let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) + guard case .running(_, let networkStatus) = network else { + throw ContainerizationError(.invalidState, message: "default network is not running") + } + config.networks = [network.id] + let subnet = try CIDRAddress(networkStatus.address) + let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description + let nameservers = [nameserver] + config.dns = ContainerConfiguration.DNSConfiguration(nameservers: nameservers) + + let kernel = try await { + await progressUpdate([ + .setDescription("Fetching kernel image"), + .setItemsName("blobs"), + ]) + + let s: SystemPlatform + let architecture = builderPlatform.architecture + switch architecture { + case "arm64": + s = .linuxArm + case "amd64": + s = .linuxAmd + default: + throw ContainerizationError.init(.unsupported, message: "platform architecture \(architecture)") + } + let kernel = try await ClientKernel.getDefaultKernel(for: s) + return kernel + }() + + await progressUpdate([ + .setDescription("Starting BuildKit container") + ]) + + let container = try await ClientContainer.create( + configuration: config, + options: .default, + kernel: kernel + ) + + try await container.startBuildKit(progressUpdate, taskManager) + } + } +} + +// MARK: - ClientContainer Extension for BuildKit + +fileprivate extension ClientContainer { + /// Starts the BuildKit process within the container + /// This method handles bootstrapping the container and starting the BuildKit process + func startBuildKit(_ progress: @escaping ProgressUpdateHandler, _ taskManager: ProgressTaskCoordinator? = nil) async throws { + do { + let io = try ProcessIO.create( + tty: false, + interactive: false, + detach: true + ) + defer { try? io.close() } + let process = try await bootstrap() + _ = try await process.start(io.stdio) + await taskManager?.finish() + try io.closeAfterStart() + log.debug("starting BuildKit and BuildKit-shim") + } catch { + try? await stop() + try? await delete() + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start BuildKit: \(error)") + } + } +} diff --git a/Sources/CLI/Builder/BuilderStatus.swift b/Sources/CLI/Builder/BuilderStatus.swift new file mode 100644 index 00000000..b1210a3d --- /dev/null +++ b/Sources/CLI/Builder/BuilderStatus.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Foundation + +extension Application { + struct BuilderStatus: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "status" + config._superCommandName = "builder" + config.abstract = "Print builder status" + config.usage = "\n\t builder status [command options]" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + @Flag(name: .long, help: ArgumentHelp("Display detailed status in json format")) + var json: Bool = false + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + if json { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys] + let jsonData = try encoder.encode(container) + + guard let jsonString = String(data: jsonData, encoding: .utf8) else { + throw ContainerizationError(.internalError, message: "failed to encode BuildKit container as json") + } + print(jsonString) + return + } + + let image = container.configuration.image.reference + let resources = container.configuration.resources + let cpus = resources.cpus + let memory = resources.memoryInBytes / (1024 * 1024) // bytes to MB + let addr = "" + + print("ID IMAGE STATE ADDR CPUS MEMORY") + print("\(container.id) \(image) \(container.status.rawValue.uppercased()) \(addr) \(cpus) \(memory) MB") + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/CLI/Builder/BuilderStop.swift b/Sources/CLI/Builder/BuilderStop.swift new file mode 100644 index 00000000..e7484c9c --- /dev/null +++ b/Sources/CLI/Builder/BuilderStop.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Foundation + +extension Application { + struct BuilderStop: AsyncParsableCommand { + public static var configuration: CommandConfiguration { + var config = CommandConfiguration() + config.commandName = "stop" + config._superCommandName = "builder" + config.abstract = "Stop builder" + config.usage = "\n\t builder stop" + config.helpNames = NameSpecification(arrayLiteral: .customShort("h"), .customLong("help")) + return config + } + + func run() async throws { + do { + let container = try await ClientContainer.get(id: "buildkit") + try await container.stop() + } catch { + if error is ContainerizationError { + if (error as? ContainerizationError)?.code == .notFound { + print("builder is not running") + return + } + } + throw error + } + } + } +} diff --git a/Sources/CLI/Codable+JSON.swift b/Sources/CLI/Codable+JSON.swift new file mode 100644 index 00000000..60cbd04d --- /dev/null +++ b/Sources/CLI/Codable+JSON.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 [any Codable] { + func jsonArray() throws -> String { + "[\(try self.map { String(data: try JSONEncoder().encode($0), encoding: .utf8)! }.joined(separator: ","))]" + } +} diff --git a/Sources/CLI/Container/ContainerCreate.swift b/Sources/CLI/Container/ContainerCreate.swift new file mode 100644 index 00000000..0eff9846 --- /dev/null +++ b/Sources/CLI/Container/ContainerCreate.swift @@ -0,0 +1,104 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct ContainerCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a new container") + + @Argument(help: "Image name") + var image: String + + @Argument(help: "Container init process arguments") + var arguments: [String] = [] + + @OptionGroup + var processFlags: Flags.Process + + @OptionGroup + var resourceFlags: Flags.Resource + + @OptionGroup + var managementFlags: Flags.Management + + @OptionGroup + var pullFlags: Flags.Pull + + @OptionGroup + var global: Flags.Global + + func run() async throws { + var progressConfig: ProgressConfig + if managementFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: managementFlags.disableProgressUpdates) + } else { + 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, + progressUpdate: progress.handler + ) + + let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let container = try await ClientContainer.create(configuration: ck.0, options: options, kernel: ck.1) + + if !self.managementFlags.cidfile.isEmpty { + let path = self.managementFlags.cidfile + let data = container.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(container.id) + } + } +} diff --git a/Sources/CLI/Container/ContainerDelete.swift b/Sources/CLI/Container/ContainerDelete.swift new file mode 100644 index 00000000..0da56377 --- /dev/null +++ b/Sources/CLI/Container/ContainerDelete.swift @@ -0,0 +1,127 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Foundation + +extension Application { + struct ContainerDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete one or more containers", + aliases: ["rm"]) + + @Flag(name: .shortAndLong, help: "Force the removal of one or more running containers") + var force = false + + @Flag(name: .shortAndLong, help: "Remove all containers") + var all = false + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Container IDs/names") + var containerIDs: [String] = [] + + 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 conflicts with the --all flag" + ) + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + + if all { + containers = try await ClientContainer.list() + } else { + let ctrs = try await ClientContainer.list() + containers = ctrs.filter { c in + set.contains(c.id) + } + // If one of the containers requested isn't present lets throw. We don't need to do + // this for --all as --all should be perfectly usable with no containers to remove, otherwise + // it'd be quite clunky. + if containers.count != set.count { + let missing = set.filter { id in + !containers.contains { c in + c.id == id + } + } + throw ContainerizationError( + .notFound, + message: "failed to delete one or more containers: \(missing)" + ) + } + } + + var failed = [String]() + let force = self.force + let all = self.all + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + // First we need to find if the container supports auto-remove + // and if so we need to skip deletion. + if container.status == .running { + if !force { + // We don't want to error if the user just wants all containers deleted. + // It's implied we'll skip containers we can't actually delete. + if all { + return nil + } + throw ContainerizationError(.invalidState, message: "container is running") + } + let stopOpts = ContainerStopOptions( + timeoutInSeconds: 5, + signal: SIGKILL + ) + try await container.stop(opts: stopOpts) + } + try await container.delete() + print(container.id) + return nil + } catch { + log.error("failed to delete container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "delete failed for one or more containers: \(failed)") + } + } + } +} diff --git a/Sources/CLI/Container/ContainerExec.swift b/Sources/CLI/Container/ContainerExec.swift new file mode 100644 index 00000000..5d8a0f19 --- /dev/null +++ b/Sources/CLI/Container/ContainerExec.swift @@ -0,0 +1,100 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerExec: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "exec", + abstract: "Run a new command in a running container") + + @OptionGroup + var processFlags: Flags.Process + + // FIXME: Add in detach keys support. + @OptionGroup(visibility: .hidden) + var detachFlags: Flags.Detach + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Running containers ID") + var containerID: String + + @Argument(parsing: .captureForPassthrough, help: "New process arguments") + var arguments: [String] + + func run() async throws { + var exitCode: Int32 = 127 + let container = try await ClientContainer.get(id: containerID) + try ensureRunning(container: container) + + let stdin = self.processFlags.interactive + let tty = self.processFlags.tty + + var config = container.configuration.initProcess + config.executable = arguments.first! + 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: false) + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + let process = try await container.createProcess( + id: UUID().uuidString.lowercased(), + configuration: config) + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to exec process \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/CLI/Container/ContainerInspect.swift b/Sources/CLI/Container/ContainerInspect.swift new file mode 100644 index 00000000..43bda51a --- /dev/null +++ b/Sources/CLI/Container/ContainerInspect.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Foundation +import SwiftProtobuf + +extension Application { + struct ContainerInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more containers") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Containers to inspect") + var containers: [String] + + func run() async throws { + let objects: [any Codable] = try await ClientContainer.list().filter { + containers.contains($0.id) + }.map { + PrintableContainer($0) + } + print(try objects.jsonArray()) + } + } +} diff --git a/Sources/CLI/Container/ContainerKill.swift b/Sources/CLI/Container/ContainerKill.swift new file mode 100644 index 00000000..9b9ef4ed --- /dev/null +++ b/Sources/CLI/Container/ContainerKill.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import ContainerizationOS +import Darwin + +extension Application { + struct ContainerKill: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kill", + abstract: "Kill one or more running containers") + + @Option(name: .shortAndLong, help: "Signal to send the container(s)") + var signal: String = "KILL" + + @Flag(name: .shortAndLong, help: "Kill all running containers") + var all = false + + @Argument(help: "Container IDs") + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + 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 conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + + var containers = try await ClientContainer.list().filter { c in + c.status == .running + } + if !self.all { + containers = containers.filter { c in + set.contains(c.id) + } + } + + let signalNumber = try Signals.parseSignal(signal) + + var failed: [String] = [] + for container in containers { + do { + try await container.kill(signalNumber) + print(container.id) + } catch { + log.error("failed to kill container \(container.id): \(error)") + failed.append(container.id) + } + } + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "kill failed for one or more containers") + } + } + } +} diff --git a/Sources/CLI/Container/ContainerList.swift b/Sources/CLI/Container/ContainerList.swift new file mode 100644 index 00000000..43e5a4ce --- /dev/null +++ b/Sources/CLI/Container/ContainerList.swift @@ -0,0 +1,110 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerNetworkService +import ContainerizationExtras +import Foundation +import SwiftProtobuf + +extension Application { + struct ContainerList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List containers", + aliases: ["ls"]) + + @Flag(name: .shortAndLong, help: "Show stopped containers as well") + var all = false + + @Flag(name: .shortAndLong, help: "Only output the container ID") + var quiet = false + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let containers = try await ClientContainer.list() + try printContainers(containers: containers, format: format) + } + + private func createHeader() -> [[String]] { + [["ID", "IMAGE", "OS", "ARCH", "STATE", "ADDR"]] + } + + private func printContainers(containers: [ClientContainer], format: ListFormat) throws { + if format == .json { + let printables = containers.map { + PrintableContainer($0) + } + let data = try JSONEncoder().encode(printables) + print(String(data: data, encoding: .utf8)!) + + return + } + + if self.quiet { + containers.forEach { + if !self.all && $0.status != .running { + return + } + print($0.id) + } + return + } + + var rows = createHeader() + for container in containers { + if !self.all && container.status != .running { + continue + } + rows.append(container.asRow) + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + } +} + +extension ClientContainer { + var asRow: [String] { + [ + self.id, + self.configuration.image.reference, + self.configuration.platform.os, + self.configuration.platform.architecture, + self.status.rawValue, + self.networks.compactMap { try? CIDRAddress($0.address).address.description }.joined(separator: ","), + ] + } +} + +struct PrintableContainer: Codable { + let status: RuntimeStatus + let configuration: ContainerConfiguration + let networks: [Attachment] + + init(_ container: ClientContainer) { + self.status = container.status + self.configuration = container.configuration + self.networks = container.networks + } +} diff --git a/Sources/CLI/Container/ContainerLogs.swift b/Sources/CLI/Container/ContainerLogs.swift new file mode 100644 index 00000000..cedf8105 --- /dev/null +++ b/Sources/CLI/Container/ContainerLogs.swift @@ -0,0 +1,139 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Dispatch +import Foundation + +extension Application { + struct ContainerLogs: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch container stdio or boot logs" + ) + + @OptionGroup + var global: Flags.Global + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + @Flag(name: .long, help: "Display the boot log for the container instead of stdio") + var boot: Bool = false + + @Option(name: [.customShort("n")], 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? + + @Argument(help: "Container to fetch logs for") + var container: String + + func run() async throws { + do { + let container = try await ClientContainer.get(id: container) + let fhs = try await container.logs() + let fileHandle = boot ? fhs[1] : fhs[0] + + try await Self.tail( + fh: fileHandle, + n: numLines, + follow: follow + ) + } catch { + throw ContainerizationError( + .invalidArgument, + message: "failed to fetch container logs for \(container): \(error)" + ) + } + } + + 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)) + } + + if follow { + 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 { + 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/CLI/Container/ContainerStart.swift b/Sources/CLI/Container/ContainerStart.swift new file mode 100644 index 00000000..d94cc718 --- /dev/null +++ b/Sources/CLI/Container/ContainerStart.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import ContainerizationOS +import TerminalProgress + +extension Application { + struct ContainerStart: AsyncParsableCommand { + 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 container's STDIN") + var interactive = false + + // FIXME: Add in detach keys support. + @OptionGroup(visibility: .hidden) + var detachFlags: Flags.Detach + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Container's ID") + var containerID: String + + 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 container = try await ClientContainer.get(id: containerID) + let process = try await container.bootstrap() + + progress.set(description: "Starting init process") + let detach = !self.attach && !self.interactive + do { + let io = try ProcessIO.create( + tty: container.configuration.initProcess.terminal, + interactive: self.interactive, + detach: detach + ) + progress.finish() + if detach { + try await process.start(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(self.containerID) + return + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + try? await container.stop() + + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to start container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} diff --git a/Sources/CLI/Container/ContainerStop.swift b/Sources/CLI/Container/ContainerStop.swift new file mode 100644 index 00000000..78f69090 --- /dev/null +++ b/Sources/CLI/Container/ContainerStop.swift @@ -0,0 +1,102 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + struct ContainerStop: AsyncParsableCommand { + 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 the container(s)") + var signal: String = "SIGTERM" + + @Option(name: .shortAndLong, help: "Seconds to wait before killing the container(s)") + var time: Int32 = 5 + + @Argument + var containerIDs: [String] = [] + + @OptionGroup + var global: Flags.Global + + 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 conflicts with the --all flag") + } + } + + mutating func run() async throws { + let set = Set(containerIDs) + var containers = [ClientContainer]() + if self.all { + containers = try await ClientContainer.list() + } else { + containers = try await ClientContainer.list().filter { c in + set.contains(c.id) + } + } + + let opts = ContainerStopOptions( + timeoutInSeconds: self.time, + signal: try Signals.parseSignal(self.signal) + ) + let failed = try await Self.stopContainers(containers: containers, stopOptions: opts) + if failed.count > 0 { + throw ContainerizationError(.internalError, message: "stop failed for one or more containers \(failed.joined(separator: ","))") + } + } + + static func stopContainers(containers: [ClientContainer], stopOptions: ContainerStopOptions) async throws -> [String] { + var failed: [String] = [] + try await withThrowingTaskGroup(of: ClientContainer?.self) { group in + for container in containers { + group.addTask { + do { + try await container.stop(opts: stopOptions) + print(container.id) + return nil + } catch { + log.error("failed to stop container \(container.id): \(error)") + return container + } + } + } + + for try await ctr in group { + guard let ctr else { + continue + } + failed.append(ctr.id) + } + } + + return failed + } + } +} diff --git a/Sources/CLI/Container/ContainersCommand.swift b/Sources/CLI/Container/ContainersCommand.swift new file mode 100644 index 00000000..ef6aff93 --- /dev/null +++ b/Sources/CLI/Container/ContainersCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension Application { + struct ContainersCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "containers", + abstract: "Manage containers", + subcommands: [ + ContainerCreate.self, + ContainerDelete.self, + ContainerExec.self, + ContainerInspect.self, + ContainerKill.self, + ContainerList.self, + ContainerLogs.self, + ContainerStart.self, + ContainerStop.self, + ], + aliases: ["container", "c"] + ) + } +} diff --git a/Sources/CLI/Container/ProcessUtils.swift b/Sources/CLI/Container/ProcessUtils.swift new file mode 100644 index 00000000..d4dda6a2 --- /dev/null +++ b/Sources/CLI/Container/ProcessUtils.swift @@ -0,0 +1,31 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation + +extension Application { + static func ensureRunning(container: ClientContainer) throws { + if container.status != .running { + throw ContainerizationError(.invalidState, message: "container \(container.id) is not running") + } + } +} diff --git a/Sources/CLI/DefaultCommand.swift b/Sources/CLI/DefaultCommand.swift new file mode 100644 index 00000000..ef88aaaa --- /dev/null +++ b/Sources/CLI/DefaultCommand.swift @@ -0,0 +1,54 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerPlugin + +struct DefaultCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: nil, + shouldDisplay: false + ) + + @OptionGroup(visibility: .hidden) + var global: Flags.Global + + @Argument(parsing: .captureForPassthrough) + var remaining: [String] = [] + + func run() async throws { + // See if we have a possible plugin command. + guard let command = remaining.first else { + Application.printModifiedHelpText() + 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)'") + } + + let pluginLoader = Application.pluginLoader + guard let plugin = pluginLoader.findPlugin(name: command), plugin.config.isCLI else { + throw ValidationError("failed to find plugin named container-\(command)") + } + // Exec performs execvp (with no fork). + try plugin.exec(args: remaining) + } +} diff --git a/Sources/CLI/Image/ImageInspect.swift b/Sources/CLI/Image/ImageInspect.swift new file mode 100644 index 00000000..cea35686 --- /dev/null +++ b/Sources/CLI/Image/ImageInspect.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Foundation +import SwiftProtobuf + +extension Application { + struct ImageInspect: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display information about one or more images") + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Images to inspect") + var images: [String] + + func run() async throws { + var printable = [any Codable]() + let result = try await ClientImage.get(names: images) + let notFound = result.error + for image in result.images { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + printable.append(try await image.details()) + } + if printable.count > 0 { + print(try printable.jsonArray()) + } + if notFound.count > 0 { + throw ContainerizationError(.notFound, message: "Images: \(notFound.joined(separator: "\n"))") + } + } + } +} diff --git a/Sources/CLI/Image/ImageList.swift b/Sources/CLI/Image/ImageList.swift new file mode 100644 index 00000000..8b7bec57 --- /dev/null +++ b/Sources/CLI/Image/ImageList.swift @@ -0,0 +1,175 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation +import SwiftProtobuf + +extension Application { + struct ListImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Only output the image name") + var quiet = false + + @Flag(name: .shortAndLong, help: "Verbose output") + var verbose = false + + @Option(name: .long, help: "Format of the output") + var format: ListFormat = .table + + @OptionGroup + var global: Flags.Global + } + + struct ListImageImplementation { + static private func createHeader() -> [[String]] { + [["NAME", "TAG", "DIGEST"]] + } + + static private func createVerboseHeader() -> [[String]] { + [["NAME", "TAG", "INDEX DIGEST", "OS", "ARCH", "VARIANT", "SIZE", "CREATED", "MANIFEST DIGEST"]] + } + + static private func printImagesVerbose(images: [ClientImage]) async throws { + + var rows = createVerboseHeader() + for image in images { + let formatter = ByteCountFormatter() + for descriptor in try await image.index().manifests { + // Don't list attestation manifests + if let referenceType = descriptor.annotations?["vnd.docker.reference.type"], + referenceType == "attestation-manifest" + { + continue + } + + guard let platform = descriptor.platform else { + continue + } + + let os = platform.os + let arch = platform.architecture + let variant = platform.variant ?? "" + + var config: ContainerizationOCI.Image + var manifest: ContainerizationOCI.Manifest + do { + config = try await image.config(for: platform) + manifest = try await image.manifest(for: platform) + } catch { + continue + } + + let created = config.created ?? "" + let size = descriptor.size + manifest.config.size + manifest.layers.reduce(0, { (l, r) in l + r.size }) + let formattedSize = formatter.string(fromByteCount: size) + + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + let row = [ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + os, + arch, + variant, + formattedSize, + created, + Utility.trimDigest(digest: descriptor.digest), + ] + rows.append(row) + } + } + + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static private func printImages(images: [ClientImage], format: ListFormat, options: ListImageOptions) async throws { + var images = images + images.sort { + $0.reference < $1.reference + } + + if format == .json { + let data = try JSONEncoder().encode(images.map { $0.description }) + print(String(data: data, encoding: .utf8)!) + return + } + + if options.quiet { + try images.forEach { image in + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + print(processedReferenceString) + } + return + } + + if options.verbose { + try await Self.printImagesVerbose(images: images) + return + } + + var rows = createHeader() + for image in images { + let processedReferenceString = try ClientImage.denormalizeReference(image.reference) + let reference = try ContainerizationOCI.Reference.parse(processedReferenceString) + rows.append([ + reference.name, + reference.tag ?? "", + Utility.trimDigest(digest: image.descriptor.digest), + ]) + } + let formatter = TableOutput(rows: rows) + print(formatter.format()) + } + + static func validate(options: ListImageOptions) throws { + if options.quiet && options.verbose { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite and --verbose together") + } + let modifer = options.quiet || options.verbose + if modifer && options.format == .json { + throw ContainerizationError(.invalidArgument, message: "Cannot use flag --quite or --verbose along with --format json") + } + } + + static func listImages(options: ListImageOptions) async throws { + let images = try await ClientImage.list().filter { img in + !Utility.isInfraImage(name: img.reference) + } + try await printImages(images: images, format: options.format, options: options) + } + } + + struct ImageList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List images", + aliases: ["ls"]) + + @OptionGroup + var options: ListImageOptions + + mutating func run() async throws { + try ListImageImplementation.validate(options: options) + try await ListImageImplementation.listImages(options: options) + } + } +} diff --git a/Sources/CLI/Image/ImageLoad.swift b/Sources/CLI/Image/ImageLoad.swift new file mode 100644 index 00000000..719fd19e --- /dev/null +++ b/Sources/CLI/Image/ImageLoad.swift @@ -0,0 +1,76 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct ImageLoad: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "load", + abstract: "Load images from an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .shortAndLong, help: "Path to the tar archive to load images from", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var input: String + + func run() async throws { + guard FileManager.default.fileExists(atPath: input) else { + print("File does not exist \(input)") + Application.exit(withError: ArgumentParser.ExitCode(1)) + } + + 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 loaded = try await ClientImage.load(from: input) + + let taskManager = ProgressTaskCoordinator() + let unpackTask = await taskManager.startTask() + progress.set(description: "Unpacking image") + progress.set(itemsName: "entries") + for image in loaded { + try await image.unpack(platform: nil, progressUpdate: ProgressTaskCoordinator.handler(for: unpackTask, from: progress.handler)) + } + await taskManager.finish() + progress.finish() + print("Loaded images:") + for image in loaded { + print(image.reference) + } + } + } +} diff --git a/Sources/CLI/Image/ImagePrune.swift b/Sources/CLI/Image/ImagePrune.swift new file mode 100644 index 00000000..d233247f --- /dev/null +++ b/Sources/CLI/Image/ImagePrune.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Foundation + +extension Application { + struct ImagePrune: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "prune", + abstract: "Remove unreferenced and dangling images") + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + print("Cleaned unreferenced images and snapshots") + print("Reclaimed \(freed) in disk space") + } + } +} diff --git a/Sources/CLI/Image/ImagePull.swift b/Sources/CLI/Image/ImagePull.swift new file mode 100644 index 00000000..c8d7fcd4 --- /dev/null +++ b/Sources/CLI/Image/ImagePull.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePull: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "pull", + abstract: "Pull an image" + ) + + @OptionGroup + var global: Flags.Global + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Flag(help: "Pull using plain-text http") var http: Bool = false + + @Argument var reference: String + + init() {} + + init(platform: String? = nil, http: Bool = false, reference: String) { + self.global = Flags.Global() + self.platform = platform + self.http = http + self.reference = reference + } + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let processedReference = try ClientImage.normalizeReference(reference) + let progressConfig = try ProgressConfig( + 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, progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progress.handler) + ) + + 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/CLI/Image/ImagePush.swift b/Sources/CLI/Image/ImagePush.swift new file mode 100644 index 00000000..379ae196 --- /dev/null +++ b/Sources/CLI/Image/ImagePush.swift @@ -0,0 +1,63 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationOCI +import TerminalProgress + +extension Application { + struct ImagePush: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "push", + abstract: "Push an image" + ) + + @OptionGroup + var global: Flags.Global + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Flag(help: "Push using plain-text http") var http: Bool = false + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let image = try await ClientImage.get(reference: reference) + + let progressConfig = try ProgressConfig( + 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, insecure: http, progressUpdate: progress.handler) + progress.finish() + } + } +} diff --git a/Sources/CLI/Image/ImageRemove.swift b/Sources/CLI/Image/ImageRemove.swift new file mode 100644 index 00000000..2f0c86c2 --- /dev/null +++ b/Sources/CLI/Image/ImageRemove.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationError +import Foundation + +extension Application { + struct RemoveImageOptions: ParsableArguments { + @Flag(name: .shortAndLong, help: "Remove all images") + var all: Bool = false + + @Argument + var images: [String] = [] + + @OptionGroup + var global: Flags.Global + } + + struct RemoveImageImplementation { + static func validate(options: RemoveImageOptions) throws { + if options.images.count == 0 && !options.all { + throw ContainerizationError(.invalidArgument, message: "no image 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) 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) + }() + var failures: [String] = notFound + var didDeleteAnyImage = false + for image in found { + guard !Utility.isInfraImage(name: image.reference) else { + continue + } + do { + try await ClientImage.delete(reference: image.reference, garbageCollect: false) + print(image.reference) + didDeleteAnyImage = true + } catch { + log.error("failed to remove \(image.reference): \(error)") + failures.append(image.reference) + } + } + let (_, size) = try await ClientImage.pruneImages() + let formatter = ByteCountFormatter() + let freed = formatter.string(fromByteCount: Int64(size)) + + if didDeleteAnyImage { + print("Reclaimed \(freed) in disk space") + } + if failures.count > 0 { + throw ContainerizationError(.internalError, message: "failed to delete one or more images: \(failures)") + } + } + } + + struct ImageRemove: AsyncParsableCommand { + @OptionGroup + var options: RemoveImageOptions + + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Remove one or more images", + aliases: ["rm"]) + + func validate() throws { + try RemoveImageImplementation.validate(options: options) + } + + mutating func run() async throws { + try await RemoveImageImplementation.removeImage(options: options) + } + } +} diff --git a/Sources/CLI/Image/ImageSave.swift b/Sources/CLI/Image/ImageSave.swift new file mode 100644 index 00000000..8c0b6eac --- /dev/null +++ b/Sources/CLI/Image/ImageSave.swift @@ -0,0 +1,67 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct ImageSave: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "save", + abstract: "Save an image as an OCI compatible tar archive" + ) + + @OptionGroup + var global: Flags.Global + + @Option(help: "Platform string in the form 'os/arch/variant'. Example 'linux/arm64/v8', 'linux/amd64'") var platform: String? + + @Option( + name: .shortAndLong, help: "Path to save the image tar archive", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + var output: String + + @Argument var reference: String + + func run() async throws { + var p: Platform? + if let platform { + p = try Platform(from: platform) + } + + let progressConfig = try ProgressConfig( + description: "Saving image" + ) + let progress = ProgressBar(config: progressConfig) + defer { + progress.finish() + } + progress.start() + + let image = try await ClientImage.get(reference: reference) + try await image.save(out: output, platform: p) + + progress.finish() + print("Image saved") + } + } +} diff --git a/Sources/CLI/Image/ImageTag.swift b/Sources/CLI/Image/ImageTag.swift new file mode 100644 index 00000000..01a76190 --- /dev/null +++ b/Sources/CLI/Image/ImageTag.swift @@ -0,0 +1,42 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient + +extension Application { + struct ImageTag: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "tag", + abstract: "Tag an image") + + @Argument(help: "SOURCE_IMAGE[:TAG]") + var source: String + + @Argument(help: "TARGET_IMAGE[:TAG]") + var target: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let existing = try await ClientImage.get(reference: source) + let targetReference = try ClientImage.normalizeReference(target) + try await existing.tag(new: targetReference) + print("Image \(source) tagged as \(target)") + } + } +} diff --git a/Sources/CLI/Image/ImagesCommand.swift b/Sources/CLI/Image/ImagesCommand.swift new file mode 100644 index 00000000..968dfd23 --- /dev/null +++ b/Sources/CLI/Image/ImagesCommand.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension Application { + struct ImagesCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "images", + abstract: "Manage images", + subcommands: [ + ImageInspect.self, + ImageList.self, + ImageLoad.self, + ImagePrune.self, + ImagePull.self, + ImagePush.self, + ImageRemove.self, + ImageSave.self, + ImageTag.self, + ], + aliases: ["image", "i"] + ) + } +} diff --git a/Sources/CLI/Registry/Login.swift b/Sources/CLI/Registry/Login.swift new file mode 100644 index 00000000..a1b721b3 --- /dev/null +++ b/Sources/CLI/Registry/Login.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation + +extension Application { + struct Login: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Login to a registry" + ) + + @Option(name: .shortAndLong, help: "Username") + var username: String = "" + + @Flag(help: "Take the password from stdin") + var passwordStdin: Bool = false + + @Flag(help: "Login using plain-text http") var http: Bool = false + + @Argument(help: "Registry server name") + var server: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + 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(id: Constants.keychainID) + if username == "" { + username = try keychain.userPrompt(domain: server) + } + if password == "" { + password = try keychain.passwordPrompt() + print() + } + + let scheme = http ? "http" : "https" + let server = Reference.resolveDomain(domain: server) + let client = RegistryClient( + host: server, + scheme: scheme, + 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(domain: server, username: username, password: password) + print("Login succeeded") + } + } +} diff --git a/Sources/CLI/Registry/Logout.swift b/Sources/CLI/Registry/Logout.swift new file mode 100644 index 00000000..a24996e1 --- /dev/null +++ b/Sources/CLI/Registry/Logout.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationOCI + +extension Application { + struct Logout: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Log out from a registry") + + @Argument(help: "Registry server name") + var registry: String + + @OptionGroup + var global: Flags.Global + + func run() async throws { + let keychain = KeychainHelper(id: Constants.keychainID) + let r = Reference.resolveDomain(domain: registry) + try keychain.delete(domain: r) + } + } +} diff --git a/Sources/CLI/Registry/RegistryCommand.swift b/Sources/CLI/Registry/RegistryCommand.swift new file mode 100644 index 00000000..c160c946 --- /dev/null +++ b/Sources/CLI/Registry/RegistryCommand.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension Application { + struct RegistryCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "registry", + abstract: "Manage registry configurations", + subcommands: [ + Login.self, + Logout.self, + RegistryDefault.self, + ], + aliases: ["r"] + ) + } +} diff --git a/Sources/CLI/Registry/RegistryDefault.swift b/Sources/CLI/Registry/RegistryDefault.swift new file mode 100644 index 00000000..4aea27e0 --- /dev/null +++ b/Sources/CLI/Registry/RegistryDefault.swift @@ -0,0 +1,94 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import ContainerizationOCI +import Foundation + +extension Application { + struct RegistryDefault: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "default", + abstract: "Manage the default image registry", + subcommands: [ + DefaultSetCommand.self, + DefaultUnsetCommand.self, + DefaultInspectCommand.self, + ] + ) + } + + struct DefaultSetCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set the default registry" + ) + + @Flag(help: "Try to connect to the registry using plain-text http") + var http: Bool = false + + @Argument + var host: String + + func run() async throws { + let scheme = http ? "http" : "https" + let _url = "\(scheme)://\(host)" + guard let url = URL(string: _url), let domain = url.host() else { + throw ContainerizationError(.invalidArgument, message: "Cannot convert \(_url) to URL") + } + let resolvedDomain = Reference.resolveDomain(domain: domain) + let client = RegistryClient(host: resolvedDomain, scheme: scheme, port: url.port) + do { + try await client.ping() + } catch let err as RegistryClient.Error { + switch err { + case .invalidStatus(url: _, .unauthorized), .invalidStatus(url: _, .forbidden): + break + default: + throw err + } + } + ClientDefaults.set(value: host, key: .defaultRegistryDomain) + print("Set default registry to \(host)") + } + } + + struct DefaultUnsetCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "unset", + abstract: "Unset the default registry", + aliases: ["clear"] + ) + + func run() async throws { + ClientDefaults.unset(key: .defaultRegistryDomain) + print("Unset the default registry domain") + } + } + + struct DefaultInspectCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display the default registry domain" + ) + + func run() async throws { + print(ClientDefaults.get(key: .defaultRegistryDomain)) + } + } +} diff --git a/Sources/CLI/RunCommand.swift b/Sources/CLI/RunCommand.swift new file mode 100644 index 00000000..80333ee9 --- /dev/null +++ b/Sources/CLI/RunCommand.swift @@ -0,0 +1,275 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationOS +import Foundation +import NIOCore +import NIOPosix +import TerminalProgress + +extension Application { + struct ContainerRunCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "run", + abstract: "Run a container") + + @OptionGroup + var processFlags: Flags.Process + + // FIXME: Add in detach keys support. + @OptionGroup(visibility: .hidden) + var detachFlags: Flags.Detach + + @OptionGroup + var resourceFlags: Flags.Resource + + @OptionGroup + var managementFlags: Flags.Management + + @OptionGroup + var pullFlags: Flags.Pull + + @OptionGroup + var global: Flags.Global + + @Argument(help: "Image name") + var image: String + + @Argument(parsing: .captureForPassthrough, help: "Container init process arguments") + var arguments: [String] = [] + + func run() async throws { + var exitCode: Int32 = 127 + let id = Utility.createContainerID(name: self.managementFlags.name) + + var progressConfig: ProgressConfig + if managementFlags.disableProgressUpdates { + progressConfig = try ProgressConfig(disableProgressUpdates: managementFlags.disableProgressUpdates) + } else { + progressConfig = try ProgressConfig( + 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 existing = try? await ClientContainer.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, + progressUpdate: progress.handler + ) + + progress.set(description: "Starting container") + + let options = ContainerCreateOptions(autoRemove: managementFlags.remove) + let container = try await ClientContainer.create( + configuration: ck.0, + options: options, + kernel: ck.1 + ) + + let detach = self.managementFlags.detach + + let process = try await container.bootstrap() + progress.finish() + + do { + let io = try ProcessIO.create( + tty: self.processFlags.tty, + interactive: self.processFlags.interactive, + detach: detach + ) + + 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(io.stdio) + defer { + try? io.close() + } + try io.closeAfterStart() + print(id) + return + } + + if !self.processFlags.tty { + var handler = SignalThreshold(threshold: 3, signals: [SIGINT, SIGTERM]) + handler.start { + print("Received 3 SIGINT/SIGTERM's, forcefully exiting.") + Darwin.exit(1) + } + } + + exitCode = try await Application.handleProcess(io: io, process: process) + } catch { + if error is ContainerizationError { + throw error + } + throw ContainerizationError(.internalError, message: "failed to run container: \(error)") + } + throw ArgumentParser.ExitCode(exitCode) + } + } +} + +struct ProcessIO { + let stdin: Pipe? + let stdout: Pipe? + let stderr: Pipe? + + let stdio: [FileHandle?] + + let console: Terminal? + + func closeAfterStart() throws { + try stdin?.fileHandleForReading.close() + try stdout?.fileHandleForWriting.close() + try stderr?.fileHandleForWriting.close() + } + + func close() throws { + try console?.reset() + } + + static func create(tty: Bool, interactive: Bool, detach: Bool) throws -> ProcessIO { + let current: Terminal? = try { + if !tty { + return nil + } + let current = try Terminal.current + try current.setraw() + return current + }() + + var stdio = [FileHandle?](repeating: nil, count: 3) + + let stdin: Pipe? = { + if !interactive && !tty { + return nil + } + return Pipe() + }() + + if let stdin { + if interactive { + let pin = FileHandle.standardInput + pin.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + pin.readabilityHandler = nil + return + } + try! stdin.fileHandleForWriting.write(contentsOf: data) + } + } + stdio[0] = stdin.fileHandleForReading + } + + let stdout: Pipe? = { + if detach { + return nil + } + return Pipe() + }() + if let stdout { + let pout: FileHandle = { + if let current { + return current.handle + } + return .standardOutput + }() + + let rout = stdout.fileHandleForReading + rout.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rout.readabilityHandler = nil + return + } + try! pout.write(contentsOf: data) + } + stdio[1] = stdout.fileHandleForWriting + } + + let stderr: Pipe? = { + if detach || tty { + return nil + } + return Pipe() + }() + if let stderr { + let perr: FileHandle = .standardError + let rerr = stderr.fileHandleForReading + rerr.readabilityHandler = { handle in + let data = handle.availableData + if data.isEmpty { + rerr.readabilityHandler = nil + return + } + try! perr.write(contentsOf: data) + } + stdio[2] = stderr.fileHandleForWriting + } + + return .init( + stdin: stdin, + stdout: stdout, + stderr: stderr, + stdio: stdio, + console: current + ) + } +} diff --git a/Sources/CLI/System/DNS/DNSCreate.swift b/Sources/CLI/System/DNS/DNSCreate.swift new file mode 100644 index 00000000..2dbe2d8a --- /dev/null +++ b/Sources/CLI/System/DNS/DNSCreate.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import ContainerizationExtras +import Foundation + +extension Application { + struct DNSCreate: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "create", + abstract: "Create a local DNS domain for containers (must run as an administrator)" + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + do { + try resolver.createDomain(name: domainName) + print(domainName) + } catch let error as ContainerizationError { + throw error + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/CLI/System/DNS/DNSDefault.swift b/Sources/CLI/System/DNS/DNSDefault.swift new file mode 100644 index 00000000..5a746eab --- /dev/null +++ b/Sources/CLI/System/DNS/DNSDefault.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient + +extension Application { + struct DNSDefault: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "default", + abstract: "Set or unset the default local DNS domain", + subcommands: [ + DefaultSetCommand.self, + DefaultUnsetCommand.self, + DefaultInspectCommand.self, + ] + ) + + struct DefaultSetCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set the default local DNS domain" + + ) + + @Argument(help: "the default `--domain-name` to use for the `create` or `run` command") + var domainName: String + + func run() async throws { + ClientDefaults.set(value: domainName, key: .defaultDNSDomain) + print(domainName) + } + } + + struct DefaultUnsetCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "unset", + abstract: "Unset the default local DNS domain", + aliases: ["clear"] + ) + + func run() async throws { + ClientDefaults.unset(key: .defaultDNSDomain) + print("Unset the default local DNS domain") + } + } + + struct DefaultInspectCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "inspect", + abstract: "Display the default local DNS domain" + ) + + func run() async throws { + print(ClientDefaults.getOptional(key: .defaultDNSDomain) ?? "") + } + } + } +} diff --git a/Sources/CLI/System/DNS/DNSDelete.swift b/Sources/CLI/System/DNS/DNSDelete.swift new file mode 100644 index 00000000..b3360bb5 --- /dev/null +++ b/Sources/CLI/System/DNS/DNSDelete.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import Foundation + +extension Application { + struct DNSDelete: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "delete", + abstract: "Delete a local DNS domain (must run as an administrator)", + aliases: ["rm"] + ) + + @Argument(help: "the local domain name") + var domainName: String + + func run() async throws { + let resolver = HostDNSResolver() + do { + try resolver.deleteDomain(name: domainName) + print(domainName) + } catch { + throw ContainerizationError(.invalidState, message: "cannot create domain (try sudo?)") + } + + do { + try HostDNSResolver.reinitialize() + } catch { + throw ContainerizationError(.invalidState, message: "mDNSResponder restart failed, run `sudo killall -HUP mDNSResponder` to deactivate domain") + } + } + } +} diff --git a/Sources/CLI/System/DNS/DNSList.swift b/Sources/CLI/System/DNS/DNSList.swift new file mode 100644 index 00000000..61641577 --- /dev/null +++ b/Sources/CLI/System/DNS/DNSList.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Foundation + +extension Application { + struct DNSList: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "list", + abstract: "List local DNS domains", + aliases: ["ls"] + ) + + func run() async throws { + let resolver: HostDNSResolver = HostDNSResolver() + let domains = resolver.listDomains() + print(domains.joined(separator: "\n")) + } + + } +} diff --git a/Sources/CLI/System/Kernel/KernelSet.swift b/Sources/CLI/System/Kernel/KernelSet.swift new file mode 100644 index 00000000..ff57a339 --- /dev/null +++ b/Sources/CLI/System/Kernel/KernelSet.swift @@ -0,0 +1,113 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +extension Application { + struct KernelSet: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "set", + abstract: "Set the default kernel" + ) + + @Option(name: .customLong("binary"), help: "Path to the binary to set as the default kernel. If used with --tar, this points to a location inside the tar") + var binaryPath: String? = nil + + @Option(name: .customLong("tar"), help: "Filesystem path or remote URL to a tar ball that contains the kernel to use") + var tarPath: String? = nil + + @Option(name: .customLong("arch"), help: "The architecture of the kernel binary. One of (amd64, arm64)") + var architecture: String = ContainerizationOCI.Platform.current.architecture.description + + @Flag(name: .customLong("install-recommended"), help: "Download and install the recommended kernel as the default. This flag ignores any other arguments") + var installRecommended: Bool = false + + func run() async throws { + if installRecommended { + let url = ClientDefaults.get(key: .defaultKernelURL) + let path = ClientDefaults.get(key: .defaultKernelBinaryPath) + try await Self.downloadAndInstallWithProgressBar(tarRemoteURL: url, kernelFilePath: path) + 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) + } + + 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()).absoluteString + let fm = FileManager.default + if fm.fileExists(atPath: localTarPath) { + try await ClientKernel.installKernelFromTar(tarFile: localTarPath, kernelFilePath: binaryPath, platform: platform) + 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.absoluteString, kernelFilePath: binaryPath, platform: platform) + } + + private func getSystemPlatform() throws -> SystemPlatform { + switch architecture { + case "arm64": + return .linuxArm + case "amd64": + return .linuxAmd + default: + throw ContainerizationError(.unsupported, message: "Unsupported architecture \(architecture)") + } + } + + public static func downloadAndInstallWithProgressBar(tarRemoteURL: String, kernelFilePath: String, platform: SystemPlatform = .current) 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, kernelFilePath: kernelFilePath, platform: platform, progressUpdate: progress.handler) + progress.finish() + } + + } +} diff --git a/Sources/CLI/System/SystemCommand.swift b/Sources/CLI/System/SystemCommand.swift new file mode 100644 index 00000000..efeac1ff --- /dev/null +++ b/Sources/CLI/System/SystemCommand.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension Application { + struct SystemCommand: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "system", + abstract: "Manage system components", + subcommands: [ + SystemDNS.self, + SystemLogs.self, + SystemRestart.self, + SystemStart.self, + SystemStop.self, + SystemKernel.self, + ], + aliases: ["s"] + ) + } +} diff --git a/Sources/CLI/System/SystemDNS.swift b/Sources/CLI/System/SystemDNS.swift new file mode 100644 index 00000000..4f9b3e3b --- /dev/null +++ b/Sources/CLI/System/SystemDNS.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension Application { + struct SystemDNS: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "dns", + abstract: "Manage local DNS domains", + subcommands: [ + DNSCreate.self, + DNSDelete.self, + DNSList.self, + DNSDefault.self, + ] + ) + } +} diff --git a/Sources/CLI/System/SystemKernel.swift b/Sources/CLI/System/SystemKernel.swift new file mode 100644 index 00000000..942bd696 --- /dev/null +++ b/Sources/CLI/System/SystemKernel.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension Application { + struct SystemKernel: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "kernel", + abstract: "Manage the default kernel configuration", + subcommands: [ + KernelSet.self + ] + ) + } +} diff --git a/Sources/CLI/System/SystemLogs.swift b/Sources/CLI/System/SystemLogs.swift new file mode 100644 index 00000000..e2b87ffb --- /dev/null +++ b/Sources/CLI/System/SystemLogs.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationError +import ContainerizationOS +import Foundation +import OSLog + +extension Application { + struct SystemLogs: AsyncParsableCommand { + static let subsystem = "com.apple.container" + + static let configuration = CommandConfiguration( + commandName: "logs", + abstract: "Fetch system logs for `container` services" + ) + + @OptionGroup + var global: Flags.Global + + @Option( + name: .long, + help: "Fetch logs starting from the specified time period (minus the current time); supported formats: m, h, d" + ) + var last: String = "5m" + + @Flag(name: .shortAndLong, help: "Follow log output") + var follow: Bool = false + + 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", "--debug"]) + 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/CLI/System/SystemRestart.swift b/Sources/CLI/System/SystemRestart.swift new file mode 100644 index 00000000..91307be1 --- /dev/null +++ b/Sources/CLI/System/SystemRestart.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerPlugin +import ContainerizationError +import Foundation +import Logging + +extension Application { + struct SystemRestart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "restart", + abstract: "Restart API server for `container`" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + func run() async throws { + let launchdDomainString = try ServiceManager.getDomainString() + let fullLabel = "\(launchdDomainString)/\(prefix)apiserver" + try ServiceManager.kickstart(fullServiceLabel: fullLabel) + // Now ping our friendly daemon. Fail after 10 seconds with no response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + print("Done") + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from apiserver after 10 seconds: \(error)" + ) + } + } + } +} diff --git a/Sources/CLI/System/SystemStart.swift b/Sources/CLI/System/SystemStart.swift new file mode 100644 index 00000000..757b81ca --- /dev/null +++ b/Sources/CLI/System/SystemStart.swift @@ -0,0 +1,168 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerPlugin +import ContainerizationError +import Foundation +import TerminalProgress + +extension Application { + struct SystemStart: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Start `container` services" + ) + + @Option(name: .shortAndLong, help: "Path to the `container-apiserver` binary") + var path: String = Bundle.main.executablePath ?? "" + + @Flag(name: .long, help: "Enable debug logging for the runtime daemon.") + var debug = false + + @Flag(name: .long, help: "Do not prompt for confirmation before installing runtime dependencies") + var installDependencies: Bool = false + + func run() async throws { + // Without the true path to the binary in the plist, `container-apiserver` won't launch properly. + let executableUrl = URL(filePath: path) + .resolvingSymlinksInPath() + .deletingLastPathComponent() + .appendingPathComponent("container-apiserver") + + var args = [executableUrl.absolutePath()] + if debug { + args.append("--debug") + } + + let apiServerDataUrl = appRoot.appending(path: "apiserver") + try! FileManager.default.createDirectory(at: apiServerDataUrl, withIntermediateDirectories: true) + let env = ProcessInfo.processInfo.environment.filter { key, _ in + key.hasPrefix("CONTAINER_") + } + + let logURL = apiServerDataUrl.appending(path: "apiserver.log") + let plist = LaunchPlist( + label: "com.apple.container.apiserver", + arguments: args, + environment: env, + limitLoadToSessionType: [.Aqua, .Background, .System], + runAtLoad: true, + stdout: logURL.path, + stderr: logURL.path, + machServices: ["com.apple.container.apiserver"] + ) + + let plistURL = apiServerDataUrl.appending(path: "apiserver.plist") + let data = try plist.encode() + try data.write(to: plistURL) + + try ServiceManager.register(plistPath: plistURL.path) + + // Now ping our friendly daemon. Fail if we don't get a response. + do { + print("Verifying apiserver is running...") + try await ClientHealthCheck.ping(timeout: .seconds(10)) + print("Done") + } catch { + throw ContainerizationError( + .internalError, + message: "failed to get a response from apiserver: \(error)" + ) + } + + var kernelConfigured: Bool = false + var missingDependencies: [Dependencies] = [] + if await !initImageExists() { + missingDependencies.append(.initFs) + } + if await !kernelExists() { + kernelConfigured = true + missingDependencies.append(.kernel) + } + guard missingDependencies.count > 0 else { + return + } + + print("Missing required runtime dependencies:") + for (idx, dependency) in missingDependencies.enumerated() { + print(" \(idx+1). \(dependency.rawValue)") + } + + if !installDependencies { + print("Would like to install them now? [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 { + if !kernelConfigured { + print("Please use the `container system kernel set` command to configure the kernel") + } + return + } + } + try await installDeps(deps: missingDependencies) + } + + private func installDeps(deps: [Dependencies]) async throws { + if deps.contains(.kernel) { + try await installDefaultKernel() + } + if deps.contains(.initFs) { + try await installInitialFilesystem() + } + } + + private func installInitialFilesystem() async throws { + let reference = ClientDefaults.get(key: .defaultInitImage) + let pullCommand = ImagePull(reference: reference) + print("Installing initial filesystem from [\(reference)]...") + try await pullCommand.run() + } + + private func installDefaultKernel() async throws { + let defaultKernelURL = ClientDefaults.get(key: .defaultKernelURL) + let defaultKernelBinaryPath = ClientDefaults.get(key: .defaultKernelBinaryPath) + print("Installing default kernel from [\(defaultKernelURL)]...") + try await KernelSet.downloadAndInstallWithProgressBar(tarRemoteURL: defaultKernelURL, kernelFilePath: defaultKernelBinaryPath) + } + + private func initImageExists() async -> Bool { + do { + let img = try await ClientImage.get(reference: ClientDefaults.get(key: .defaultInitImage)) + 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 + } + } + } + + private enum Dependencies: String { + case kernel = "Kernel" + case initFs = "Initial filesystem" + } +} diff --git a/Sources/CLI/System/SystemStop.swift b/Sources/CLI/System/SystemStop.swift new file mode 100644 index 00000000..32824dd0 --- /dev/null +++ b/Sources/CLI/System/SystemStop.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerPlugin +import ContainerizationOS +import Foundation +import Logging + +extension Application { + struct SystemStop: AsyncParsableCommand { + private static let stopTimeoutSeconds: Int32 = 5 + private static let shutdownTimeoutSeconds: Int32 = 20 + + static let configuration = CommandConfiguration( + commandName: "stop", + abstract: "Stop all `container` services" + ) + + @Option(name: .shortAndLong, help: "Launchd prefix for `container` services") + var prefix: String = "com.apple.container." + + 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" + + log.info("stopping containers", metadata: ["stopTimeoutSeconds": "\(Self.stopTimeoutSeconds)"]) + do { + let containers = try await ClientContainer.list() + let signal = try Signals.parseSignal("SIGTERM") + let opts = ContainerStopOptions(timeoutInSeconds: Self.stopTimeoutSeconds, signal: signal) + let failed = try await ContainerStop.stopContainers(containers: containers, stopOptions: opts) + if !failed.isEmpty { + log.warning("some containers could not be stopped gracefully", metadata: ["ids": "\(failed)"]) + } + + } catch { + log.warning("failed to stop all containers", metadata: ["error": "\(error)"]) + } + + log.info("waiting for containers to exit") + do { + for _ in 0.. 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/BuildExporter.swift b/Sources/ContainerBuild/BuildExporter.swift new file mode 100644 index 00000000..8089bc01 --- /dev/null +++ b/Sources/ContainerBuild/BuildExporter.swift @@ -0,0 +1,129 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 GRPC +import NIO + +actor BuildExporter: BuildPipelineHandler { + let output: OutputStream + let channel: AsyncThrowingStream<(AsyncStream.Continuation, ServerStream), Swift.Error>.Continuation + + public init(output: URL) throws { + guard let output = OutputStream(toFileAtPath: output.absolutePath(), append: true) else { + throw Error.couldNotInitializeOutput(output.absolutePath()) + } + self.output = output + self.output.open() + var c: AsyncThrowingStream<(AsyncStream.Continuation, ServerStream), Swift.Error>.Continuation? + let writeStream: AsyncThrowingStream<(AsyncStream.Continuation, ServerStream), Swift.Error> = AsyncThrowingStream { continuation in + c = continuation + } + guard let c else { + throw Builder.Error.invalidContinuation + } + self.channel = c + Task.detached { + for try await packet in writeStream { + try await self.write(packet.0, packet.1) + } + } + } + + nonisolated func accept(_ packet: ServerStream) throws -> Bool { + guard let buildTransfer = packet.getBuildTransfer() else { + return false + } + guard buildTransfer.stage() == "exporter" else { + return false + } + return true + } + + func handle(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws { + self.channel.yield((sender, packet)) // guarantees ordering while being non-blocking + } + + func write(_ sender: AsyncStream.Continuation, _ packet: ServerStream) async throws { + guard let buildTransfer = packet.getBuildTransfer() else { + throw Error.buildTransferMissing + } + guard buildTransfer.stage() == "exporter" else { + throw Error.invalidStage(buildTransfer.stage() ?? "") + } + let buildID = packet.buildID + if buildTransfer.complete { + var transfer = BuildTransfer() + transfer.id = buildTransfer.id + transfer.direction = .outof + transfer.metadata = [ + "os": "linux", + "stage": "exporter", + ] + var response = ClientStream() + response.buildID = buildID + response.buildTransfer = transfer + response.packetType = .buildTransfer(transfer) + sender.yield(response) + + self.output.close() + return + } + try buildTransfer.data.withUnsafeBytes { rawBuf in + let bufPointer = rawBuf.bindMemory(to: UInt8.self) + if let baseAddr = bufPointer.baseAddress, bufPointer.count > 0 { + let n = self.output.write(baseAddr, maxLength: bufPointer.count) + if n < 0 || n < bufPointer.count { + throw Error.writeError + } + } + } + var transfer = BuildTransfer() + transfer.id = buildTransfer.id + transfer.direction = .outof + transfer.metadata = [ + "os": "linux", + "stage": "exporter", + ] + var response = ClientStream() + response.buildID = buildID + response.buildTransfer = transfer + response.packetType = .buildTransfer(transfer) + sender.yield(response) + } +} + +extension BuildExporter { + enum Error: Swift.Error, CustomStringConvertible { + case buildTransferMissing + case invalidStage(String) + case couldNotInitializeOutput(String) + case writeError + var description: String { + switch self { + case .buildTransferMissing: + return "buildTransfer field missing in packet" + case .invalidStage(let stage): + return "stage \(stage) is invalid, expected 'exporter'" + case .couldNotInitializeOutput(let output): + return "could not open \(output) for writing" + case .writeError: + return "write failed" + } + } + } +} diff --git a/Sources/ContainerBuild/BuildFSSync.swift b/Sources/ContainerBuild/BuildFSSync.swift new file mode 100644 index 00000000..c921c5c2 --- /dev/null +++ b/Sources/ContainerBuild/BuildFSSync.swift @@ -0,0 +1,498 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerClient +import ContainerizationArchive +import ContainerizationOCI +import Foundation +import GRPC + +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 = URL(filePath: packet.source.cleanPathComponent) + 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 { + var path = self.contextDir + path.append(components: packet.source.cleanPathComponent) + 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.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) + + 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)) + } + + for await chunk in try tarURL.zeroCopyReader() { + 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/BuildImageResolver.swift b/Sources/ContainerBuild/BuildImageResolver.swift new file mode 100644 index 00000000..487f7924 --- /dev/null +++ b/Sources/ContainerBuild/BuildImageResolver.swift @@ -0,0 +1,152 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import Containerization +import ContainerizationOCI +import Foundation +import GRPC +import Logging + +struct BuildImageResolver: BuildPipelineHandler { + let contentStore: ContentStore + + public init(_ contentStore: ContentStore) throws { + self.contentStore = contentStore + } + + 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 { + guard let img = try? await ClientImage.pull(reference: ref, platform: platform) else { + return try await ClientImage.fetch(reference: ref, platform: platform) + } + return img + }() + + 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 00000000..dea3d4bd --- /dev/null +++ b/Sources/ContainerBuild/BuildPipelineHandler.swift @@ -0,0 +1,198 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 GRPC +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 { + let exporters: [BuildPipelineHandler] = try config.exports.map { export in + guard let destination = export.destination else { + throw Builder.Error.invalidExport(export.rawValue, "dest is required") + } + return try BuildExporter(output: destination) + } + self.handlers = + exporters + [ + try BuildFSSync(URL(filePath: config.contextDir)), + try BuildRemoteContentProxy(config.contentStore), + try BuildImageResolver(config.contentStore), + try BuildStdio(quiet: config.quiet, output: config.terminal?.handle ?? FileHandle.standardError), + ] + } + + public func run( + sender: AsyncStream.Continuation, + receiver: GRPCAsyncResponseStream + ) async throws { + 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 00000000..cf16e985 --- /dev/null +++ b/Sources/ContainerBuild/BuildRemoteContentProxy.swift @@ -0,0 +1,188 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import Containerization +import ContainerizationArchive +import ContainerizationOCI +import Foundation +import GRPC + +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 00000000..27852db6 --- /dev/null +++ b/Sources/ContainerBuild/BuildStdio.swift @@ -0,0 +1,70 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 GRPC +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 00000000..eac5bfcc --- /dev/null +++ b/Sources/ContainerBuild/Builder.grpc.swift @@ -0,0 +1,881 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 +// +// Generated by the protocol buffer compiler. +// Source: Builder.proto +// +import GRPC +import NIO +import NIOConcurrencyHelpers +import SwiftProtobuf + + +/// 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 exection 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 coninues 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 abadon 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. +/// +/// Usage: instantiate `Com_Apple_Container_Build_V1_BuilderClient`, then call methods of this protocol to make API calls. +public protocol Com_Apple_Container_Build_V1_BuilderClientProtocol: GRPCClient { + var serviceName: String { get } + var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { get } + + func createBuild( + _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, + callOptions: CallOptions? + ) -> UnaryCall + + func performBuild( + callOptions: CallOptions?, + handler: @escaping (Com_Apple_Container_Build_V1_ServerStream) -> Void + ) -> BidirectionalStreamingCall + + func info( + _ request: Com_Apple_Container_Build_V1_InfoRequest, + callOptions: CallOptions? + ) -> UnaryCall +} + +extension Com_Apple_Container_Build_V1_BuilderClientProtocol { + public var serviceName: String { + return "com.apple.container.build.v1.Builder" + } + + /// Create a build request. + /// + /// - Parameters: + /// - request: Request to send to CreateBuild. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func createBuild( + _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [] + ) + } + + /// Perform the build. + /// Executes the entire build sequence with attaching input/output + /// to handling data exchange with the server during the build. + /// + /// Callers should use the `send` method on the returned object to send messages + /// to the server. The caller should send an `.end` after the final message has been sent. + /// + /// - Parameters: + /// - callOptions: Call options. + /// - handler: A closure called when each response is received from the server. + /// - Returns: A `ClientStreamingCall` with futures for the metadata and status. + public func performBuild( + callOptions: CallOptions? = nil, + handler: @escaping (Com_Apple_Container_Build_V1_ServerStream) -> Void + ) -> BidirectionalStreamingCall { + return self.makeBidirectionalStreamingCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [], + handler: handler + ) + } + + /// Unary call to Info + /// + /// - Parameters: + /// - request: Request to send to Info. + /// - callOptions: Call options. + /// - Returns: A `UnaryCall` with futures for the metadata, status and response. + public func info( + _ request: Com_Apple_Container_Build_V1_InfoRequest, + callOptions: CallOptions? = nil + ) -> UnaryCall { + return self.makeUnaryCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeInfoInterceptors() ?? [] + ) + } +} + +@available(*, deprecated) +extension Com_Apple_Container_Build_V1_BuilderClient: @unchecked Sendable {} + +@available(*, deprecated, renamed: "Com_Apple_Container_Build_V1_BuilderNIOClient") +public final class Com_Apple_Container_Build_V1_BuilderClient: Com_Apple_Container_Build_V1_BuilderClientProtocol { + private let lock = Lock() + private var _defaultCallOptions: CallOptions + private var _interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? + public let channel: GRPCChannel + public var defaultCallOptions: CallOptions { + get { self.lock.withLock { return self._defaultCallOptions } } + set { self.lock.withLockVoid { self._defaultCallOptions = newValue } } + } + public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { + get { self.lock.withLock { return self._interceptors } } + set { self.lock.withLockVoid { self._interceptors = newValue } } + } + + /// Creates a client for the com.apple.container.build.v1.Builder service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self._defaultCallOptions = defaultCallOptions + self._interceptors = interceptors + } +} + +public struct Com_Apple_Container_Build_V1_BuilderNIOClient: Com_Apple_Container_Build_V1_BuilderClientProtocol { + public var channel: GRPCChannel + public var defaultCallOptions: CallOptions + public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? + + /// Creates a client for the com.apple.container.build.v1.Builder service. + /// + /// - Parameters: + /// - channel: `GRPCChannel` to the service host. + /// - defaultCallOptions: Options to use for each service call if the user doesn't provide them. + /// - interceptors: A factory providing interceptors for each RPC. + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +/// 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 exection 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 coninues 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 abadon 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. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public protocol Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol: GRPCClient { + static var serviceDescriptor: GRPCServiceDescriptor { get } + var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { get } + + func makeCreateBuildCall( + _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall + + func makePerformBuildCall( + callOptions: CallOptions? + ) -> GRPCAsyncBidirectionalStreamingCall + + func makeInfoCall( + _ request: Com_Apple_Container_Build_V1_InfoRequest, + callOptions: CallOptions? + ) -> GRPCAsyncUnaryCall +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol { + public static var serviceDescriptor: GRPCServiceDescriptor { + return Com_Apple_Container_Build_V1_BuilderClientMetadata.serviceDescriptor + } + + public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? { + return nil + } + + public func makeCreateBuildCall( + _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [] + ) + } + + public func makePerformBuildCall( + callOptions: CallOptions? = nil + ) -> GRPCAsyncBidirectionalStreamingCall { + return self.makeAsyncBidirectionalStreamingCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [] + ) + } + + public func makeInfoCall( + _ request: Com_Apple_Container_Build_V1_InfoRequest, + callOptions: CallOptions? = nil + ) -> GRPCAsyncUnaryCall { + return self.makeAsyncUnaryCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeInfoInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol { + public func createBuild( + _ request: Com_Apple_Container_Build_V1_CreateBuildRequest, + callOptions: CallOptions? = nil + ) async throws -> Com_Apple_Container_Build_V1_CreateBuildResponse { + return try await self.performAsyncUnaryCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [] + ) + } + + public func performBuild( + _ requests: RequestStream, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream where RequestStream: Sequence, RequestStream.Element == Com_Apple_Container_Build_V1_ClientStream { + return self.performAsyncBidirectionalStreamingCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, + requests: requests, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [] + ) + } + + public func performBuild( + _ requests: RequestStream, + callOptions: CallOptions? = nil + ) -> GRPCAsyncResponseStream where RequestStream: AsyncSequence & Sendable, RequestStream.Element == Com_Apple_Container_Build_V1_ClientStream { + return self.performAsyncBidirectionalStreamingCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild.path, + requests: requests, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [] + ) + } + + public func info( + _ request: Com_Apple_Container_Build_V1_InfoRequest, + callOptions: CallOptions? = nil + ) async throws -> Com_Apple_Container_Build_V1_InfoResponse { + return try await self.performAsyncUnaryCall( + path: Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info.path, + request: request, + callOptions: callOptions ?? self.defaultCallOptions, + interceptors: self.interceptors?.makeInfoInterceptors() ?? [] + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public struct Com_Apple_Container_Build_V1_BuilderAsyncClient: Com_Apple_Container_Build_V1_BuilderAsyncClientProtocol { + public var channel: GRPCChannel + public var defaultCallOptions: CallOptions + public var interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? + + public init( + channel: GRPCChannel, + defaultCallOptions: CallOptions = CallOptions(), + interceptors: Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol? = nil + ) { + self.channel = channel + self.defaultCallOptions = defaultCallOptions + self.interceptors = interceptors + } +} + +public protocol Com_Apple_Container_Build_V1_BuilderClientInterceptorFactoryProtocol: Sendable { + + /// - Returns: Interceptors to use when invoking 'createBuild'. + func makeCreateBuildInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'performBuild'. + func makePerformBuildInterceptors() -> [ClientInterceptor] + + /// - Returns: Interceptors to use when invoking 'info'. + func makeInfoInterceptors() -> [ClientInterceptor] +} + +public enum Com_Apple_Container_Build_V1_BuilderClientMetadata { + public static let serviceDescriptor = GRPCServiceDescriptor( + name: "Builder", + fullName: "com.apple.container.build.v1.Builder", + methods: [ + Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.createBuild, + Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.performBuild, + Com_Apple_Container_Build_V1_BuilderClientMetadata.Methods.info, + ] + ) + + public enum Methods { + public static let createBuild = GRPCMethodDescriptor( + name: "CreateBuild", + path: "/com.apple.container.build.v1.Builder/CreateBuild", + type: GRPCCallType.unary + ) + + public static let performBuild = GRPCMethodDescriptor( + name: "PerformBuild", + path: "/com.apple.container.build.v1.Builder/PerformBuild", + type: GRPCCallType.bidirectionalStreaming + ) + + public static let info = GRPCMethodDescriptor( + name: "Info", + path: "/com.apple.container.build.v1.Builder/Info", + type: GRPCCallType.unary + ) + } +} + +/// 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 exection 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 coninues 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 abadon 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. +/// +/// To build a server, implement a class that conforms to this protocol. +public protocol Com_Apple_Container_Build_V1_BuilderProvider: CallHandlerProvider { + var interceptors: Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol? { get } + + /// Create a build request. + func createBuild(request: Com_Apple_Container_Build_V1_CreateBuildRequest, context: StatusOnlyCallContext) -> EventLoopFuture + + /// Perform the build. + /// Executes the entire build sequence with attaching input/output + /// to handling data exchange with the server during the build. + func performBuild(context: StreamingResponseCallContext) -> EventLoopFuture<(StreamEvent) -> Void> + + func info(request: Com_Apple_Container_Build_V1_InfoRequest, context: StatusOnlyCallContext) -> EventLoopFuture +} + +extension Com_Apple_Container_Build_V1_BuilderProvider { + public var serviceName: Substring { + return Com_Apple_Container_Build_V1_BuilderServerMetadata.serviceDescriptor.fullName[...] + } + + /// Determines, calls and returns the appropriate request handler, depending on the request's method. + /// Returns nil for methods not handled by this service. + public func handle( + method name: Substring, + context: CallHandlerContext + ) -> GRPCServerHandlerProtocol? { + switch name { + case "CreateBuild": + return UnaryServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [], + userFunction: self.createBuild(request:context:) + ) + + case "PerformBuild": + return BidirectionalStreamingServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [], + observerFactory: self.performBuild(context:) + ) + + case "Info": + return UnaryServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeInfoInterceptors() ?? [], + userFunction: self.info(request:context:) + ) + + default: + return nil + } + } +} + +/// 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 exection 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 coninues 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 abadon 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. +/// +/// To implement a server, implement an object which conforms to this protocol. +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +public protocol Com_Apple_Container_Build_V1_BuilderAsyncProvider: CallHandlerProvider, Sendable { + static var serviceDescriptor: GRPCServiceDescriptor { get } + var interceptors: Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol? { get } + + /// Create a build request. + func createBuild( + request: Com_Apple_Container_Build_V1_CreateBuildRequest, + context: GRPCAsyncServerCallContext + ) async throws -> Com_Apple_Container_Build_V1_CreateBuildResponse + + /// Perform the build. + /// Executes the entire build sequence with attaching input/output + /// to handling data exchange with the server during the build. + func performBuild( + requestStream: GRPCAsyncRequestStream, + responseStream: GRPCAsyncResponseStreamWriter, + context: GRPCAsyncServerCallContext + ) async throws + + func info( + request: Com_Apple_Container_Build_V1_InfoRequest, + context: GRPCAsyncServerCallContext + ) async throws -> Com_Apple_Container_Build_V1_InfoResponse +} + +@available(macOS 10.15, iOS 13, tvOS 13, watchOS 6, *) +extension Com_Apple_Container_Build_V1_BuilderAsyncProvider { + public static var serviceDescriptor: GRPCServiceDescriptor { + return Com_Apple_Container_Build_V1_BuilderServerMetadata.serviceDescriptor + } + + public var serviceName: Substring { + return Com_Apple_Container_Build_V1_BuilderServerMetadata.serviceDescriptor.fullName[...] + } + + public var interceptors: Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol? { + return nil + } + + public func handle( + method name: Substring, + context: CallHandlerContext + ) -> GRPCServerHandlerProtocol? { + switch name { + case "CreateBuild": + return GRPCAsyncServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [], + wrapping: { try await self.createBuild(request: $0, context: $1) } + ) + + case "PerformBuild": + return GRPCAsyncServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [], + wrapping: { try await self.performBuild(requestStream: $0, responseStream: $1, context: $2) } + ) + + case "Info": + return GRPCAsyncServerHandler( + context: context, + requestDeserializer: ProtobufDeserializer(), + responseSerializer: ProtobufSerializer(), + interceptors: self.interceptors?.makeInfoInterceptors() ?? [], + wrapping: { try await self.info(request: $0, context: $1) } + ) + + default: + return nil + } + } +} + +public protocol Com_Apple_Container_Build_V1_BuilderServerInterceptorFactoryProtocol: Sendable { + + /// - Returns: Interceptors to use when handling 'createBuild'. + /// Defaults to calling `self.makeInterceptors()`. + func makeCreateBuildInterceptors() -> [ServerInterceptor] + + /// - Returns: Interceptors to use when handling 'performBuild'. + /// Defaults to calling `self.makeInterceptors()`. + func makePerformBuildInterceptors() -> [ServerInterceptor] + + /// - Returns: Interceptors to use when handling 'info'. + /// Defaults to calling `self.makeInterceptors()`. + func makeInfoInterceptors() -> [ServerInterceptor] +} + +public enum Com_Apple_Container_Build_V1_BuilderServerMetadata { + public static let serviceDescriptor = GRPCServiceDescriptor( + name: "Builder", + fullName: "com.apple.container.build.v1.Builder", + methods: [ + Com_Apple_Container_Build_V1_BuilderServerMetadata.Methods.createBuild, + Com_Apple_Container_Build_V1_BuilderServerMetadata.Methods.performBuild, + Com_Apple_Container_Build_V1_BuilderServerMetadata.Methods.info, + ] + ) + + public enum Methods { + public static let createBuild = GRPCMethodDescriptor( + name: "CreateBuild", + path: "/com.apple.container.build.v1.Builder/CreateBuild", + type: GRPCCallType.unary + ) + + public static let performBuild = GRPCMethodDescriptor( + name: "PerformBuild", + path: "/com.apple.container.build.v1.Builder/PerformBuild", + type: GRPCCallType.bidirectionalStreaming + ) + + public static let info = GRPCMethodDescriptor( + name: "Info", + path: "/com.apple.container.build.v1.Builder/Info", + type: GRPCCallType.unary + ) + } +} diff --git a/Sources/ContainerBuild/Builder.pb.swift b/Sources/ContainerBuild/Builder.pb.swift new file mode 100644 index 00000000..be94f62d --- /dev/null +++ b/Sources/ContainerBuild/Builder.pb.swift @@ -0,0 +1,1501 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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/ + +import Foundation +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 {return _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: @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 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 {return _source ?? String()} + set {_source = newValue} + } + /// Returns true if `source` has been explicitly set. + public var hasSource: Bool {return 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 {return _destination ?? String()} + set {_destination = newValue} + } + /// Returns true if `destination` has been explicitly set. + public var hasDestination: Bool {return 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: @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 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 {return _descriptor ?? Com_Apple_Container_Build_V1_Descriptor()} + set {_descriptor = newValue} + } + /// Returns true if `descriptor` has been explicitly set. + public var hasDescriptor: Bool {return 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 {return _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: @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. + + /// 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 {return _platform ?? Com_Apple_Container_Build_V1_Platform()} + set {_platform = newValue} + } + /// Returns true if `platform` has been explicitly set. + public var hasPlatform: Bool {return 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 = [ + 0: .same(proto: "INTO"), + 1: .same(proto: "OUTOF"), + ] +} + +extension Com_Apple_Container_Build_V1_Stdio: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "STDIN"), + 1: .same(proto: "STDOUT"), + 2: .same(proto: "STDERR"), + ] +} + +extension Com_Apple_Container_Build_V1_BuildErrorType: SwiftProtobuf._ProtoNameProviding { + public static let _protobuf_nameMap: SwiftProtobuf._NameMap = [ + 0: .same(proto: "BUILD_FAILED"), + 1: .same(proto: "INTERNAL"), + ] +} + +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 = [ + 1: .standard(proto: "stage_name"), + 2: .same(proto: "tag"), + 3: .same(proto: "metadata"), + 4: .standard(proto: "build_args"), + 5: .same(proto: "debug"), + ] + + 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 = [ + 1: .standard(proto: "build_id"), + 2: .same(proto: "metadata"), + ] + + 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 = [ + 1: .standard(proto: "build_id"), + 2: .same(proto: "signal"), + 3: .same(proto: "command"), + 4: .standard(proto: "build_transfer"), + 5: .standard(proto: "image_transfer"), + ] + + fileprivate class _StorageClass { + var _buildID: String = String() + var _packetType: Com_Apple_Container_Build_V1_ClientStream.OneOf_PacketType? + + #if swift(>=5.10) + // 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() + #else + static let defaultInstance = _StorageClass() + #endif + + 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 = [ + 1: .same(proto: "signal"), + ] + + 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 = [ + 1: .same(proto: "id"), + 2: .same(proto: "command"), + ] + + 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 = [ + 1: .same(proto: "id"), + ] + + 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 = [ + 1: .same(proto: "id"), + 2: .same(proto: "direction"), + 3: .same(proto: "source"), + 4: .same(proto: "destination"), + 5: .same(proto: "data"), + 6: .same(proto: "complete"), + 7: .standard(proto: "is_directory"), + 8: .same(proto: "metadata"), + ] + + 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 = [ + 1: .same(proto: "id"), + 2: .same(proto: "direction"), + 3: .same(proto: "tag"), + 4: .same(proto: "descriptor"), + 5: .same(proto: "data"), + 6: .same(proto: "complete"), + 7: .same(proto: "metadata"), + ] + + 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 = [ + 1: .standard(proto: "build_id"), + 2: .same(proto: "io"), + 3: .standard(proto: "build_error"), + 4: .standard(proto: "command_complete"), + 5: .standard(proto: "build_transfer"), + 6: .standard(proto: "image_transfer"), + ] + + fileprivate class _StorageClass { + var _buildID: String = String() + var _packetType: Com_Apple_Container_Build_V1_ServerStream.OneOf_PacketType? + + #if swift(>=5.10) + // 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() + #else + static let defaultInstance = _StorageClass() + #endif + + 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 = [ + 1: .same(proto: "type"), + 2: .same(proto: "data"), + ] + + 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 = [ + 1: .same(proto: "type"), + 2: .same(proto: "message"), + ] + + 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 = [ + 1: .same(proto: "architecture"), + 2: .same(proto: "os"), + 3: .standard(proto: "os_version"), + 4: .standard(proto: "os_features"), + 5: .same(proto: "variant"), + ] + + 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 = [ + 1: .standard(proto: "media_type"), + 2: .same(proto: "digest"), + 3: .same(proto: "size"), + 4: .same(proto: "urls"), + 5: .same(proto: "annotations"), + 6: .same(proto: "platform"), + ] + + 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 00000000..8b152802 --- /dev/null +++ b/Sources/ContainerBuild/Builder.swift @@ -0,0 +1,383 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import Containerization +import ContainerizationOCI +import ContainerizationOS +import Foundation +import GRPC +import NIO +import NIOHPACK +import NIOHTTP2 + +public struct Builder: Sendable { + let client: BuilderClientProtocol + let clientAsync: BuilderClientAsyncProtocol + let group: EventLoopGroup + let builderShimSocket: FileHandle + let channel: GRPCChannel + + public init(socket: FileHandle, group: EventLoopGroup) throws { + try socket.setSendBufSize(4 << 20) + try socket.setRecvBufSize(2 << 20) + var config = ClientConnection.Configuration.default( + target: .connectedSocket(socket.fileDescriptor), + eventLoopGroup: group + ) + config.connectionIdleTimeout = TimeAmount(.seconds(600)) + config.connectionKeepalive = .init( + interval: TimeAmount(.seconds(600)), + timeout: TimeAmount(.seconds(500)), + permitWithoutCalls: true + ) + config.connectionBackoff = .init( + initialBackoff: TimeInterval(1), + maximumBackoff: TimeInterval(10) + ) + config.callStartBehavior = .fastFailure + config.httpMaxFrameSize = 8 << 10 + config.maximumReceiveMessageLength = 512 << 20 + config.httpTargetWindowSize = 16 << 10 + + let channel = ClientConnection(configuration: config) + self.channel = channel + self.clientAsync = BuilderClientAsync(channel: channel) + self.client = BuilderClient(channel: channel) + self.group = group + self.builderShimSocket = socket + } + + public func info() throws -> InfoResponse { + let resp = self.client.info(InfoRequest(), callOptions: CallOptions()) + return try resp.response.wait() + } + + public func info() async throws -> InfoResponse { + let opts = CallOptions(timeLimit: .timeout(.seconds(30))) + return try await self.clientAsync.info(InfoRequest(), callOptions: 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 respStream = self.clientAsync.performBuild(reqStream, callOptions: try CallOptions(config)) + let pipeline = try await BuildPipeline(config) + do { + try await pipeline.run(sender: continuation, receiver: respStream) + } catch Error.buildComplete { + _ = channel.close() + 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") + } + 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": + 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 contextDir: String + public let dockerfile: Data + public let labels: [String] + public let noCache: Bool + public let platforms: [Platform] + public let terminal: Terminal? + public let tag: String + public let target: String + public let quiet: Bool + public let exports: [BuildExport] + public let cacheIn: [String] + public let cacheOut: [String] + + public init( + buildID: String, + contentStore: ContentStore, + buildArgs: [String], + contextDir: String, + dockerfile: Data, + labels: [String], + noCache: Bool, + platforms: [Platform], + terminal: Terminal?, + tag: String, + target: String, + quiet: Bool, + exports: [BuildExport], + cacheIn: [String], + cacheOut: [String], + ) { + self.buildID = buildID + self.contentStore = contentStore + self.buildArgs = buildArgs + self.contextDir = contextDir + self.dockerfile = dockerfile + self.labels = labels + self.noCache = noCache + self.platforms = platforms + self.terminal = terminal + self.tag = tag + self.target = target + self.quiet = quiet + self.exports = exports + self.cacheIn = cacheIn + self.cacheOut = cacheOut + } + } +} + +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 CallOptions { + public init(_ config: Builder.BuildConfig) throws { + var headers: [(String, String)] = [ + ("build-id", config.buildID), + ("context", URL(filePath: config.contextDir).path(percentEncoded: false)), + ("dockerfile", config.dockerfile.base64EncodedString()), + ("progress", config.terminal != nil ? "tty" : "plain"), + ("tag", config.tag), + ("target", config.target), + ] + for platform in config.platforms { + headers.append(("platforms", platform.description)) + } + if config.noCache { + headers.append(("no-cache", "")) + } + for label in config.labels { + headers.append(("labels", label)) + } + for buildArg in config.buildArgs { + headers.append(("build-args", buildArg)) + } + for output in config.exports { + headers.append(("outputs", try output.stringValue)) + } + for cacheIn in config.cacheIn { + headers.append(("cache-in", cacheIn)) + } + for cacheOut in config.cacheOut { + headers.append(("cache-out", cacheOut)) + } + + self.init( + customMetadata: HPACKHeaders(headers) + ) + } +} + +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 00000000..1e1d8857 --- /dev/null +++ b/Sources/ContainerBuild/Globber.swift @@ -0,0 +1,121 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 00000000..39d6264b --- /dev/null +++ b/Sources/ContainerBuild/TerminalCommand.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 00000000..1f6ff564 --- /dev/null +++ b/Sources/ContainerBuild/URL+Extensions.swift @@ -0,0 +1,136 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 URL { + func parentOf(_ url: URL) -> Bool { + // if self is a relative path + guard self.cleanPath.hasPrefix("/") else { + return true + } + let pathItems = self.standardizedFileURL.absoluteURL.pathComponents.map { $0.cleanPathComponent } + let urlItems = url.standardizedFileURL.absoluteURL.pathComponents.map { $0.cleanPathComponent } + + if pathItems.count > urlItems.count { + return false + } + for (index, pathItem) in pathItems.enumerated() { + if urlItems[index] != pathItem { + return false + } + } + return true + } + + func relativeChildPath(to context: URL) throws -> String { + if !context.parentOf(self.absoluteURL.standardizedFileURL) { + throw BuildFSSync.Error.pathIsNotChild(self.cleanPath, context.cleanPath) + } + + let pathItems = context.standardizedFileURL.pathComponents.map { $0.cleanPathComponent } + let urlItems = self.standardizedFileURL.pathComponents.map { $0.cleanPathComponent } + + return String(urlItems.dropFirst(pathItems.count).joined(separator: "/").trimming { $0 == "/" }) + } + + var cleanPath: String { + let pathStr = self.path(percentEncoded: false) + if let cleanPath = pathStr.removingPercentEncoding { + return cleanPath + } + return pathStr + } + + func relativePathFrom(from base: URL) -> String { + let destComponents = self.standardizedFileURL.pathComponents.map { $0.cleanPathComponent } + let baseComponents = base.standardizedFileURL.pathComponents.map { $0.cleanPathComponent } + + // Find the last common path between the two + var lastCommon: Int = 0 + while lastCommon < baseComponents.count && lastCommon < destComponents.count && baseComponents[lastCommon] == destComponents[lastCommon] { + lastCommon += 1 + } + + if lastCommon == 0 { + return self.path + } + + var relPath: [String] = [] + + // Add "../" for each component that's a directory after the common prefix + for i in lastCommon...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() + } + } + } + } +} diff --git a/Sources/ContainerClient/Arch.swift b/Sources/ContainerClient/Arch.swift new file mode 100644 index 00000000..d0b098e4 --- /dev/null +++ b/Sources/ContainerClient/Arch.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 static func hostArchitecture() -> Arch { + #if arch(arm64) + return .arm64 + #elseif arch(x86_64) + return .amd64 + #endif + } +} diff --git a/Sources/ContainerClient/Archiver.swift b/Sources/ContainerClient/Archiver.swift new file mode 100644 index 00000000..68baf10e --- /dev/null +++ b/Sources/ContainerClient/Archiver.swift @@ -0,0 +1,293 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 Foundation + +public final class Archiver: Sendable { + public struct ArchiveEntryInfo: Sendable { + let pathOnHost: URL + let pathInArchive: URL + + public init(pathOnHost: URL, pathInArchive: URL) { + self.pathOnHost = pathOnHost + self.pathInArchive = pathInArchive + } + } + + public static func compress( + source: URL, + destination: URL, + followSymlinks: Bool = false, + writerConfiguration: ArchiveWriterConfiguration = ArchiveWriterConfiguration(format: .paxRestricted, filter: .gzip), + closure: (URL) -> ArchiveEntryInfo? + ) throws { + let source = source.standardizedFileURL + let destination = destination.standardizedFileURL + + let fileManager = FileManager.default + try? fileManager.removeItem(at: destination) + + do { + let directory = destination.deletingLastPathComponent() + try fileManager.createDirectory(at: directory, withIntermediateDirectories: true) + + guard + let enumerator = FileManager.default.enumerator( + at: source, + includingPropertiesForKeys: [.isDirectoryKey, .isRegularFileKey, .isSymbolicLinkKey] + ) + else { + throw Error.fileDoesNotExist(source) + } + + var entryInfo = [ArchiveEntryInfo]() + if !source.isDirectory { + if let info = closure(source) { + entryInfo.append(info) + } + } else { + while let url = enumerator.nextObject() as? URL { + guard let info = closure(url) else { + continue + } + entryInfo.append(info) + } + } + + let archiver = try ArchiveWriter( + configuration: writerConfiguration + ) + try archiver.open(file: destination) + + for info in entryInfo { + guard let entry = try Self._createEntry(entryInfo: info) else { + throw Error.failedToCreateEntry + } + try Self._compressFile(item: info.pathOnHost, entry: entry, archiver: archiver) + } + try archiver.finishEncoding() + } catch { + try? fileManager.removeItem(at: destination) + throw error + } + } + + public static func uncompress(source: URL, destination: URL) throws { + let source = source.standardizedFileURL + let destination = destination.standardizedFileURL + + // TODO: ArchiveReader needs some enhancement to support buffered uncompression + let reader = try ArchiveReader( + format: .paxRestricted, + filter: .gzip, + file: source + ) + + for (entry, data) in reader { + guard let path = entry.path else { + continue + } + let uncompressPath = destination.appendingPathComponent(path) + + let fileManager = FileManager.default + switch entry.fileType { + case .blockSpecial, .characterSpecial, .socket: + continue + case .directory: + try fileManager.createDirectory( + at: uncompressPath, + withIntermediateDirectories: true, + attributes: [ + FileAttributeKey.posixPermissions: entry.permissions + ] + ) + case .regular: + try fileManager.createDirectory( + at: uncompressPath.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [ + FileAttributeKey.posixPermissions: 0o755 + ] + ) + let success = fileManager.createFile( + atPath: uncompressPath.path, + contents: data, + attributes: [ + FileAttributeKey.posixPermissions: entry.permissions + ] + ) + if !success { + throw POSIXError.fromErrno() + } + try data.write(to: uncompressPath) + case .symbolicLink: + guard let target = entry.symlinkTarget else { + continue + } + try fileManager.createDirectory( + at: uncompressPath.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: [ + FileAttributeKey.posixPermissions: 0o755 + ] + ) + try fileManager.createSymbolicLink(atPath: uncompressPath.path, withDestinationPath: target) + continue + default: + continue + } + + // FIXME: uid/gid for compress. + try fileManager.setAttributes( + [.posixPermissions: NSNumber(value: entry.permissions)], + ofItemAtPath: uncompressPath.path + ) + + if let creationDate = entry.creationDate { + try fileManager.setAttributes( + [.creationDate: creationDate], + ofItemAtPath: uncompressPath.path + ) + } + + if let modificationDate = entry.modificationDate { + try fileManager.setAttributes( + [.modificationDate: modificationDate], + ofItemAtPath: uncompressPath.path + ) + } + } + } + + // MARK: private functions + private static func _compressFile(item: URL, entry: WriteEntry, archiver: ArchiveWriter) throws { + guard let stream = InputStream(url: item) else { + return + } + + let writer = archiver.makeTransactionWriter() + + let bufferSize = Int(1.mib()) + let readBuffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + + stream.open() + try writer.writeHeader(entry: entry) + while true { + let byteRead = stream.read(readBuffer, maxLength: bufferSize) + if byteRead <= 0 { + break + } else { + let data = Data(bytes: readBuffer, count: byteRead) + try data.withUnsafeBytes { pointer in + try writer.writeChunk(data: pointer) + } + } + } + stream.close() + 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 + } + + 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 + } + + private static func _isSymbolicLink(_ path: URL) throws -> Bool { + let resourceValues = try path.resourceValues(forKeys: [.isSymbolicLinkKey]) + if let isSymbolicLink = resourceValues.isSymbolicLink { + if isSymbolicLink { + return true + } + } + return false + } +} + +extension Archiver { + public enum Error: Swift.Error, CustomStringConvertible { + case failedToCreateEntry + case fileDoesNotExist(_ url: URL) + + public var description: String { + switch self { + case .failedToCreateEntry: + return "failed to create entry" + case .fileDoesNotExist(let url): + return "file \(url.path) does not exist" + } + } + } +} diff --git a/Sources/ContainerClient/Array+Dedupe.swift b/Sources/ContainerClient/Array+Dedupe.swift new file mode 100644 index 00000000..fd0268c0 --- /dev/null +++ b/Sources/ContainerClient/Array+Dedupe.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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/ContainerClient/ContainerEvents.swift b/Sources/ContainerClient/ContainerEvents.swift new file mode 100644 index 00000000..e7fc5193 --- /dev/null +++ b/Sources/ContainerClient/ContainerEvents.swift @@ -0,0 +1,22 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 ContainerEvent: Sendable, Codable { + case containerStart(id: String) + case containerExit(id: String, exitCode: Int64) +} diff --git a/Sources/ContainerClient/ContainerizationProgressAdapter.swift b/Sources/ContainerClient/ContainerizationProgressAdapter.swift new file mode 100644 index 00000000..40a544cc --- /dev/null +++ b/Sources/ContainerClient/ContainerizationProgressAdapter.swift @@ -0,0 +1,49 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/ContainerClient/Core/Bundle.swift b/Sources/ContainerClient/Core/Bundle.swift new file mode 100644 index 00000000..3c45c1ed --- /dev/null +++ b/Sources/ContainerClient/Core/Bundle.swift @@ -0,0 +1,153 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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") + } + + private 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 + ) 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) + } + 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(cloning fs: Filesystem) throws { + let cloned = try fs.clone(to: self.containerRootfsBlock.absolutePath()) + let fsData = try JSONEncoder().encode(cloned) + try fsData.write(to: self.containerRootfsConfig) + } + + /// 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/ContainerClient/Core/ClientContainer.swift b/Sources/ContainerClient/Core/ClientContainer.swift new file mode 100644 index 00000000..91744443 --- /dev/null +++ b/Sources/ContainerClient/Core/ClientContainer.swift @@ -0,0 +1,251 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationOCI +import Foundation +import TerminalProgress + +public struct ClientContainer: Sendable, Codable { + static let serviceIdentifier = "com.apple.container.apiserver" + + private var sandboxClient: SandboxClient { + SandboxClient(id: configuration.id, runtime: configuration.runtimeHandler) + } + + /// Identifier of the container. + public var id: String { + configuration.id + } + + public let status: RuntimeStatus + + /// Configured platform for the container. + public var platform: ContainerizationOCI.Platform { + configuration.platform + } + + /// Configuration for the container. + public let configuration: ContainerConfiguration + + /// Network allocated to the container. + public let networks: [Attachment] + + package init(configuration: ContainerConfiguration) { + self.configuration = configuration + self.status = .stopped + self.networks = [] + } + + init(snapshot: ContainerSnapshot) { + self.configuration = snapshot.configuration + self.status = snapshot.status + self.networks = snapshot.networks + } + + public var initProcess: ClientProcess { + ClientProcessImpl(containerId: self.id, client: self.sandboxClient) + } +} + +extension ClientContainer { + private static func newClient() -> XPCClient { + XPCClient(service: serviceIdentifier) + } + + @discardableResult + private static func xpcSend( + client: XPCClient, + message: XPCMessage, + timeout: Duration? = .seconds(15) + ) async throws -> XPCMessage { + try await client.send(message, responseTimeout: timeout) + } + + public static func create( + configuration: ContainerConfiguration, + options: ContainerCreateOptions = .default, + kernel: Kernel + ) async throws -> ClientContainer { + do { + let client = Self.newClient() + let request = XPCMessage(route: .createContainer) + + 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) + + try await xpcSend(client: client, message: request) + return ClientContainer(configuration: configuration) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to create container", + cause: error + ) + } + } + + public static func list() async throws -> [ClientContainer] { + do { + let client = Self.newClient() + let request = XPCMessage(route: .listContainer) + + let response = try await xpcSend( + client: client, + message: request, + timeout: .seconds(10) + ) + let data = response.dataNoCopy(key: .containers) + guard let data else { + return [] + } + let configs = try JSONDecoder().decode([ContainerSnapshot].self, from: data) + return configs.map { ClientContainer(snapshot: $0) } + } catch { + throw ContainerizationError( + .internalError, + message: "failed to list containers", + cause: error + ) + } + } + + /// Get the container for the provided id. + public static func get(id: String) async throws -> ClientContainer { + let containers = try await list() + guard let container = containers.first(where: { $0.id == id }) else { + throw ContainerizationError( + .notFound, + message: "get failed: container \(id) not found" + ) + } + return container + } +} + +extension ClientContainer { + public func bootstrap() async throws -> ClientProcess { + let client = self.sandboxClient + try await client.bootstrap() + return ClientProcessImpl(containerId: self.id, client: self.sandboxClient) + } + + /// Stop the container and all processes currently executing inside. + public func stop(opts: ContainerStopOptions = ContainerStopOptions.default) async throws { + do { + let client = self.sandboxClient + try await client.stop(options: opts) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to stop container", + cause: error + ) + } + } + + /// Delete the container along with any resources. + public func delete() async throws { + do { + let client = XPCClient(service: Self.serviceIdentifier) + let request = XPCMessage(route: .deleteContainer) + request.set(key: .id, value: self.id) + try await client.send(request) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to delete container", + cause: error + ) + } + } +} + +extension ClientContainer { + /// Execute a new process inside a running container. + public func createProcess(id: String, configuration: ProcessConfiguration) async throws -> ClientProcess { + do { + let client = self.sandboxClient + try await client.createProcess(id, config: configuration) + return ClientProcessImpl(containerId: self.id, processId: id, client: client) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to exec in container", + cause: error + ) + } + } + + /// Send or "kill" a signal to the initial process of the container. + /// Kill does not wait for the process to exit, it only delivers the signal. + public func kill(_ signal: Int32) async throws { + do { + let client = self.sandboxClient + try await client.kill(self.id, signal: Int64(signal)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to kill container \(self.id)", + cause: error + ) + } + } + + public func logs() async throws -> [FileHandle] { + do { + let client = XPCClient(service: Self.serviceIdentifier) + let request = XPCMessage(route: .containerLogs) + request.set(key: .id, value: self.id) + + let response = try await client.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 \(self.id)", + cause: error + ) + } + } + + public func dial(_ port: UInt32) async throws -> FileHandle { + do { + let client = self.sandboxClient + return try await client.dial(port) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to dial \(port) in container \(self.id)", + cause: error + ) + } + } +} diff --git a/Sources/ContainerClient/Core/ClientDefaults.swift b/Sources/ContainerClient/Core/ClientDefaults.swift new file mode 100644 index 00000000..9bba90ba --- /dev/null +++ b/Sources/ContainerClient/Core/ClientDefaults.swift @@ -0,0 +1,79 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerizationError +import Foundation + +public enum ClientDefaults { + private static let userDefaultDomain = "com.apple.container.defaults" + + public enum Keys: String { + case defaultBuilderImage = "image.builder" + case defaultDNSDomain = "dns.domain" + case defaultRegistryDomain = "registry.domain" + case defaultInitImage = "image.init" + case defaultKernelURL = "kernel.url" + case defaultKernelBinaryPath = "kernel.binaryPath" + } + + public static func set(value: String, key: ClientDefaults.Keys) { + udSuite.set(value, forKey: key.rawValue) + } + + public static func unset(key: ClientDefaults.Keys) { + udSuite.removeObject(forKey: key.rawValue) + } + + public static func get(key: ClientDefaults.Keys) -> String { + let current = udSuite.string(forKey: key.rawValue) + return current ?? key.defaultValue + } + + public static func getOptional(key: ClientDefaults.Keys) -> String? { + udSuite.string(forKey: key.rawValue) + } + + private static var udSuite: UserDefaults { + guard let ud = UserDefaults.init(suiteName: self.userDefaultDomain) else { + fatalError("Failed to initialize UserDefaults for domain \(self.userDefaultDomain)") + } + return ud + } +} + +extension ClientDefaults.Keys { + fileprivate var defaultValue: String { + switch self { + case .defaultKernelURL: + return "https://github.com/kata-containers/kata-containers/releases/download/3.17.0/kata-static-3.17.0-arm64.tar.xz" + case .defaultKernelBinaryPath: + return "opt/kata/share/kata-containers/vmlinux-6.12.28-153" + case .defaultBuilderImage: + return "ghcr.io/apple-uat/container-builder-shim/builder:2.1.3" + case .defaultDNSDomain: + return "test" + case .defaultRegistryDomain: + return "docker.io" + case .defaultInitImage: + let tag = String(cString: get_swift_containerization_version()) + guard tag != "latest" else { + return "vminit:latest" + } + return "ghcr.io/apple-uat/containerization/vminit:\(tag)" + } + } +} diff --git a/Sources/ContainerClient/Core/ClientHealthCheck.swift b/Sources/ContainerClient/Core/ClientHealthCheck.swift new file mode 100644 index 00000000..d485938f --- /dev/null +++ b/Sources/ContainerClient/Core/ClientHealthCheck.swift @@ -0,0 +1,35 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 Foundation + +public struct ClientHealthCheck { + static let serviceIdentifier = "com.apple.container.apiserver" + +} + +extension ClientHealthCheck { + private static func newClient() -> XPCClient { + XPCClient(service: serviceIdentifier) + } + + public static func ping(timeout: Duration? = .seconds(5)) async throws { + let client = Self.newClient() + let request = XPCMessage(route: .ping) + try await client.send(request, responseTimeout: timeout) + } +} diff --git a/Sources/ContainerClient/Core/ClientImage.swift b/Sources/ContainerClient/Core/ClientImage.swift new file mode 100644 index 00000000..29dbd8d7 --- /dev/null +++ b/Sources/ContainerClient/Core/ClientImage.swift @@ -0,0 +1,432 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 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() + } +} + +// MARK: ClientImage constants + +extension ClientImage { + private static let serviceIdentifier = "com.apple.container.core.container-core-images" + public static let initImageRef = ClientDefaults.get(key: .defaultInitImage) + + private static func newXPCClient() -> XPCClient { + XPCClient(service: Self.serviceIdentifier) + } + + private static func newRequest(_ route: ImagesServiceXPCRoute) -> XPCMessage { + XPCMessage(route: route) + } + + private static var defaultRegistryDomain: String { + ClientDefaults.get(key: .defaultRegistryDomain) + } +} + +// 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) throws -> String { + guard ref != Self.initImageRef else { + // Don't modify the 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 = "\(Self.defaultRegistryDomain)/\(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) throws -> String { + var updatedRawReference: String = ref + let r = try Reference.parse(ref) + let defaultRegistry = Self.defaultRegistryDomain + 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]) 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) else { + errors.append(name) + continue + } + found.append(img) + } catch { + errors.append(name) + } + } + return (found, errors) + } + + public static func get(reference: String) async throws -> ClientImage { + let all = try await self.list() + guard let found = try self._search(reference: reference, in: all) else { + throw ContainerizationError(.notFound, message: "Image with reference \(reference)") + } + return found + } + + private static func _search(reference: String, in all: [ClientImage]) 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) + return all.first(where: { image in + image.reference == reference || image.reference == normalizedReference + }) + } + + public static func pull(reference: String, platform: Platform? = nil, insecure: Bool = false, progressUpdate: ProgressUpdateHandler? = nil) async throws -> ClientImage { + let client = newXPCClient() + let request = newRequest(.imagePull) + + let reference = try self.normalizeReference(reference) + + request.set(key: .imageReference, value: reference) + try request.set(platform: platform) + + 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 load(from tarFile: String) async throws -> [ClientImage] { + let client = newXPCClient() + let request = newRequest(.imageLoad) + request.set(key: .filePath, value: tarFile) + let reply = try await client.send(request) + + let loaded = try reply.imageDescriptions() + return loaded.map { desc in + ClientImage(description: desc) + } + } + + public static func pruneImages() async throws -> ([String], UInt64) { + let client = newXPCClient() + let request = newRequest(.imagePrune) + let response = try await client.send(request) + let digests = try response.digests() + let size = response.uint64(key: .size) + return (digests, size) + } + + public static func fetch(reference: String, platform: Platform? = nil, progressUpdate: ProgressUpdateHandler? = nil) async throws -> ClientImage { + do { + let match = try await self.get(reference: reference) + 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, progressUpdate: progressUpdate) + } + } +} + +// MARK: Instance methods + +extension ClientImage { + public func push(platform: Platform? = nil, insecure: Bool = false, progressUpdate: ProgressUpdateHandler?) async throws { + let client = Self.newXPCClient() + let request = Self.newRequest(.imagePush) + request.set(key: .imageReference, value: self.description.reference) + 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 save(out: String, platform: Platform? = nil) async throws { + let client = Self.newXPCClient() + let request = Self.newRequest(.imageSave) + try request.set(description: self.description) + request.set(key: .filePath, value: out) + try request.set(platform: platform) + let _ = try await client.send(request) + } + + 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 responseData = self.dataNoCopy(key: .imageDescriptions) + guard let responseData else { + throw ContainerizationError(.empty, message: "imageDescriptions not received") + } + let descriptions = try JSONDecoder().decode([ImageDescription].self, from: responseData) + return descriptions + } + + 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/ContainerClient/Core/ClientKernel.swift b/Sources/ContainerClient/Core/ClientKernel.swift new file mode 100644 index 00000000..ccfd2011 --- /dev/null +++ b/Sources/ContainerClient/Core/ClientKernel.swift @@ -0,0 +1,99 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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) async throws { + let client = newClient() + let message = XPCMessage(route: .installKernel) + + message.set(key: .kernelFilePath, value: kernelFilePath) + + 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) async throws { + let client = newClient() + let message = XPCMessage(route: .installKernel) + + message.set(key: .kernelTarURL, value: tarFile) + message.set(key: .kernelFilePath, value: kernelFilePath) + + 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` 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/ContainerClient/Core/ClientNetwork.swift b/Sources/ContainerClient/Core/ClientNetwork.swift new file mode 100644 index 00000000..0c83b5f9 --- /dev/null +++ b/Sources/ContainerClient/Core/ClientNetwork.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +import ContainerXPC +import ContainerizationError +import ContainerizationOS +import Foundation + +public struct ClientNetwork { + static let serviceIdentifier = "com.apple.container.apiserver" + + public static let defaultNetworkName = "default" +} + +extension ClientNetwork { + private static func newClient() -> XPCClient { + XPCClient(service: serviceIdentifier) + } + + private static func xpcSend( + client: XPCClient, + message: XPCMessage, + timeout: Duration? = .seconds(15) + ) async throws -> XPCMessage { + try await client.send(message, responseTimeout: timeout) + } + + public static func create(configuration: NetworkConfiguration) async throws -> NetworkState { + let client = Self.newClient() + 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(client: client, message: request) + let responseData = response.dataNoCopy(key: .networkState) + guard let responseData else { + throw ContainerizationError(.invalidArgument, message: "network configuration not received") + } + let state = try JSONDecoder().decode(NetworkState.self, from: responseData) + return state + } + + public static func list() async throws -> [NetworkState] { + let client = Self.newClient() + let request = XPCMessage(route: .networkList) + + let response = try await xpcSend(client: client, message: request, timeout: .seconds(1)) + let responseData = response.dataNoCopy(key: .networkStates) + guard let responseData else { + return [] + } + let states = try JSONDecoder().decode([NetworkState].self, from: responseData) + return states + } + + /// Get the network for the provided id. + public static func get(id: String) async throws -> NetworkState { + 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 + } + + /// Delete the network with the given id. + public static func delete(id: String) async throws { + let client = XPCClient(service: Self.serviceIdentifier) + let request = XPCMessage(route: .networkDelete) + request.set(key: .networkId, value: id) + try await client.send(request) + } +} diff --git a/Sources/ContainerClient/Core/ClientProcess.swift b/Sources/ContainerClient/Core/ClientProcess.swift new file mode 100644 index 00000000..d38a6868 --- /dev/null +++ b/Sources/ContainerClient/Core/ClientProcess.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +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(_ stdio: [FileHandle?]) async throws + /// Send a terminal resize request to the process `id`. + func resize(_ size: Terminal.Size) async throws + /// Send or "kill" 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" + /// Identifier of the container. + public let containerId: String + + private let client: SandboxClient + + /// 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? + + public var id: String { + processId ?? containerId + } + + init(containerId: String, processId: String? = nil, client: SandboxClient) { + self.containerId = containerId + self.processId = processId + self.client = client + } + + /// Start the container and return the initial process. + public func start(_ stdio: [FileHandle?]) async throws { + do { + let client = self.client + try await client.startProcess(self.id, stdio: stdio) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to start container", + cause: error + ) + } + } + + public func kill(_ signal: Int32) async throws { + do { + + let client = self.client + try await client.kill(self.id, signal: Int64(signal)) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to kill process", + cause: error + ) + } + } + + public func resize(_ size: ContainerizationOS.Terminal.Size) async throws { + do { + + let client = self.client + try await client.resize(self.id, size: size) + + } catch { + throw ContainerizationError( + .internalError, + message: "failed to resize process", + cause: error + ) + } + } + + public func wait() async throws -> Int32 { + do { + let client = self.client + return try await client.wait(self.id) + } catch { + throw ContainerizationError( + .internalError, + message: "failed to wait on process", + cause: error + ) + } + } +} diff --git a/Sources/ContainerClient/Core/Constants.swift b/Sources/ContainerClient/Core/Constants.swift new file mode 100644 index 00000000..db321d93 --- /dev/null +++ b/Sources/ContainerClient/Core/Constants.swift @@ -0,0 +1,19 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 Constants { + public static let keychainID = "com.apple.container" +} diff --git a/Sources/ContainerClient/Core/ContainerConfiguration.swift b/Sources/ContainerClient/Core/ContainerConfiguration.swift new file mode 100644 index 00000000..86d82328 --- /dev/null +++ b/Sources/ContainerClient/Core/ContainerConfiguration.swift @@ -0,0 +1,89 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +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] = [] + /// 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: [String] = [] + /// 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 + /// The hostname for the container. + public var hostname: String? = nil + /// 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" + + 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? + + public init() {} + } + + public init( + id: String, + image: ImageDescription, + process: ProcessConfiguration + ) { + self.id = id + self.image = image + self.initProcess = process + } +} diff --git a/Sources/ContainerClient/Core/ContainerCreateOptions.swift b/Sources/ContainerClient/Core/ContainerCreateOptions.swift new file mode 100644 index 00000000..8bb5a32c --- /dev/null +++ b/Sources/ContainerClient/Core/ContainerCreateOptions.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 { + public let autoRemove: Bool + + public init(autoRemove: Bool) { + self.autoRemove = autoRemove + } + + public static let `default` = ContainerCreateOptions(autoRemove: false) + +} diff --git a/Sources/ContainerClient/Core/ContainerSnapshot.swift b/Sources/ContainerClient/Core/ContainerSnapshot.swift new file mode 100644 index 00000000..ebd6f1ca --- /dev/null +++ b/Sources/ContainerClient/Core/ContainerSnapshot.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService + +/// 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 let configuration: ContainerConfiguration + /// The runtime status of the container. + public let status: RuntimeStatus + /// Network interfaces attached to the sandbox that are provided to the container. + public let networks: [Attachment] + + public init( + configuration: ContainerConfiguration, + status: RuntimeStatus, + networks: [Attachment] + ) { + self.configuration = configuration + self.status = status + self.networks = networks + } +} diff --git a/Sources/ContainerClient/Core/ContainerStopOptions.swift b/Sources/ContainerClient/Core/ContainerStopOptions.swift new file mode 100644 index 00000000..f01f3b73 --- /dev/null +++ b/Sources/ContainerClient/Core/ContainerStopOptions.swift @@ -0,0 +1,32 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 let timeoutInSeconds: Int32 + public let signal: Int32 + + public static let `default` = ContainerStopOptions( + timeoutInSeconds: 5, + signal: SIGTERM + ) + + public init(timeoutInSeconds: Int32, signal: Int32) { + self.timeoutInSeconds = timeoutInSeconds + self.signal = signal + } +} diff --git a/Sources/ContainerClient/Core/Filesystem.swift b/Sources/ContainerClient/Core/Filesystem.swift new file mode 100644 index 00000000..e7e59e41 --- /dev/null +++ b/Sources/ContainerClient/Core/Filesystem.swift @@ -0,0 +1,151 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +/// 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 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 + } + + /// A block based filesystem. + public static func block( + format: String, source: String, destination: String, options: MountOptions, cache: CacheMode = .auto, + sync: SyncMode = .full + ) -> Filesystem { + .init( + type: .block(format: format, cache: cache, sync: sync), + source: URL(fileURLWithPath: source).absolutePath(), + 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: URL(fileURLWithPath: source).absolutePath(), + 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 + default: false + } + } + + /// 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) + } +} diff --git a/Sources/ContainerClient/Core/ImageDescription.swift b/Sources/ContainerClient/Core/ImageDescription.swift new file mode 100644 index 00000000..20fa0cbb --- /dev/null +++ b/Sources/ContainerClient/Core/ImageDescription.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/ContainerClient/Core/ImageDetail.swift b/Sources/ContainerClient/Core/ImageDetail.swift new file mode 100644 index 00000000..da2851aa --- /dev/null +++ b/Sources/ContainerClient/Core/ImageDetail.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 struct ImageDetail: Codable { + let name: String + let index: Descriptor + let variants: [Variants] + + struct Variants: Codable { + let platform: Platform + let config: ContainerizationOCI.Image + let size: Int64 + + init(platform: Platform, size: Int64, config: ContainerizationOCI.Image) { + self.platform = platform + self.config = config + self.size = size + } + } + + init(name: String, index: Descriptor, variants: [Variants]) { + self.name = name + self.index = index + self.variants = variants + } +} + +extension ClientImage { + public func details() async throws -> ImageDetail { + let indexDescriptor = self.descriptor + let reference = self.reference + var variants: [ImageDetail.Variants] = [] + 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, { (l, r) in l + r.size }) + variants.append(.init(platform: platform, size: size, config: config)) + } + return ImageDetail(name: reference, index: indexDescriptor, variants: variants) + } +} diff --git a/Sources/ContainerClient/Core/ProcessConfiguration.swift b/Sources/ContainerClient/Core/ProcessConfiguration.swift new file mode 100644 index 00000000..80f42358 --- /dev/null +++ b/Sources/ContainerClient/Core/ProcessConfiguration.swift @@ -0,0 +1,92 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 { + /// 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/ContainerClient/Core/RuntimeStatus.swift b/Sources/ContainerClient/Core/RuntimeStatus.swift new file mode 100644 index 00000000..0d42496e --- /dev/null +++ b/Sources/ContainerClient/Core/RuntimeStatus.swift @@ -0,0 +1,27 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 +} diff --git a/Sources/ContainerClient/Flags.swift b/Sources/ContainerClient/Flags.swift new file mode 100644 index 00000000..82587127 --- /dev/null +++ b/Sources/ContainerClient/Flags.swift @@ -0,0 +1,155 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +public struct Flags { + public struct Global: ParsableArguments { + public init() {} + + @Flag(name: .long, help: "Enable debug output [environment: CONTAINER_DEBUG]") + public var debug = false + } + + public struct Process: ParsableArguments { + public init() {} + + @Option( + name: [.customLong("cwd"), .customShort("w"), .customLong("workdir")], + help: "Current working directory for the container") + public var cwd: String? + + // FIXME: Implement. + // @Option(name: .customLong("shm-size"), help: "Size of /dev/shm") + // public var shmSize: String = "" + + @Option(name: [.customLong("env"), .customShort("e")], help: "Set environment variables") + public var env: [String] = [] + + @Option(name: .customLong("env-file"), help: "Read in a file of environment variables") + public var envFile: [String] = [] + + @Option(name: .customLong("uid"), help: "Set the uid for the process") + public var uid: UInt32? + + @Option(name: .customLong("gid"), help: "Set the gid for the process") + public var gid: UInt32? + + @Flag(name: [.customLong("interactive"), .customShort("i")], help: "Keep Stdin open even if not attached") + public var interactive = false + + @Flag(name: [.customLong("tty"), .customShort("t")], help: "Open a tty with the process") + public var tty = false + + @Option(name: [.customLong("user"), .customShort("u")], help: "Set the user for the process") + public var user: String? + } + + public struct Resource: ParsableArguments { + public init() {} + + @Option(name: [.customLong("cpus"), .customShort("c")], help: "Number of CPUs to allocate to the container") + public var cpus: Int64? + + @Option( + name: [.customLong("memory"), .customShort("m")], + help: + "Amount of memory in bytes, kilobytes (K), megabytes (M), or gigabytes (G) for the container, with MB granularity (for example, 1024K will result in 1MB being allocated for the container)" + ) + public var memory: String? + } + + public struct Pull: ParsableArguments { + public init() {} + + } + + public struct Detach: ParsableArguments { + public init() {} + + // FIXME: Implement. + // @Option(name: .customLong("detach-keys"), help: "Override the key sequence for detaching a container") + // public var detachKeys: String? + } + + public struct Management: ParsableArguments { + public init() {} + + @Flag(name: [.customLong("detach"), .customShort("d")], help: "Run the container and detach from the process") + public var detach = false + + @Option(name: .customLong("entrypoint"), help: "Override the entrypoint of the image") + public var entryPoint: String? + + @Option(name: .customLong("mount"), help: "Add a mount to the container (type=<>,source=<>,target=<>,readonly)") + public var mounts: [String] = [] + + @Option(name: .customLong("tmpfs"), help: "Add a tmpfs mount to the container at the given path") + public var tmpFs: [String] = [] + + @Option(name: .customLong("name"), help: "Assign a name to the container. If excluded will be a generated UUID") + public var name: String? + + @Flag(name: [.customLong("remove"), .customLong("rm")], help: "Remove the container after it stops") + public var remove = false + + @Option(name: .customLong("os"), help: "Set OS if image can target multiple operating systems") + public var os = "linux" + + @Option( + name: [.customLong("arch"), .customShort("a")], help: "Set arch if image can target multiple architectures") + public var arch: String = Arch.hostArchitecture().rawValue + + // FIXME: Implement + // @Option(name: [.customLong("publish"), .customShort("p")], help: "Publish a container's port(s) to the host") + // public var ports: [String] = [] + + @Option(name: [.customLong("volume"), .customShort("v")], help: "Bind mount a volume into the container") + public var volumes: [String] = [] + + @Option( + name: [.customLong("kernel"), .customShort("k")], help: "Set a custom kernel path", completion: .file(), + transform: { str in + URL(fileURLWithPath: str, relativeTo: .currentDirectory()).absoluteURL.path(percentEncoded: false) + }) + public var kernel: String? + + @Option(name: .customLong("cidfile"), help: "Write the container ID to the path provided") + public var cidfile = "" + + @Flag(name: [.customLong("no-dns")], help: "Do not configure DNS in the container") + public var dnsDisabled = false + + @Option(name: .customLong("dns"), help: "DNS nameserver IP address") + public var dnsNameservers: [String] = [] + + @Option(name: .customLong("dns-domain"), help: "Default DNS domain") + public var dnsDomain: String? = nil + + @Option(name: .customLong("dns-search"), help: "DNS search domains") + public var dnsSearchDomains: [String] = [] + + @Option(name: .customLong("dns-option"), help: "DNS options") + public var dnsOptions: [String] = [] + + @Option(name: [.customLong("label"), .customShort("l")], help: "Add a key=value label to the container") + public var labels: [String] = [] + + @Flag(name: .customLong("disable-progress-updates"), help: "Disable progress bar updates") + public var disableProgressUpdates = false + } +} diff --git a/Sources/ContainerClient/HostDNSResolver.swift b/Sources/ContainerClient/HostDNSResolver.swift new file mode 100644 index 00000000..d63a75a9 --- /dev/null +++ b/Sources/ContainerClient/HostDNSResolver.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +/// Functions for managing local DNS domains for containers. +public struct HostDNSResolver { + public static let defaultConfigPath = URL(filePath: "/etc/resolver") + + // prefix used to mark our files as /etc/resolver/{prefix}{domainName} + private static let containerizationPrefix = "containerization." + + private let configURL: URL + + public init(configURL: URL = Self.defaultConfigPath) { + self.configURL = configURL + } + + /// Creates a DNS resolver configuration file for domain resolved by the application. + public func createDomain(name: String) throws { + let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path + let fm: FileManager = FileManager.default + + if fm.fileExists(atPath: self.configURL.path) { + guard let isDir = try self.configURL.resourceValues(forKeys: [.isDirectoryKey]).isDirectory, isDir else { + throw ContainerizationError(.invalidState, message: "expected \(self.configURL.path) to be a directory, but found a file") + } + } else { + try fm.createDirectory(at: self.configURL, withIntermediateDirectories: true) + } + + guard !fm.fileExists(atPath: path) else { + throw ContainerizationError(.exists, message: "domain \(name) already exists") + } + + let resolverText = """ + domain \(name) + search \(name) + nameserver 127.0.0.1 + port 2053 + """ + + do { + try resolverText.write(toFile: path, atomically: true, encoding: .utf8) + } catch { + throw ContainerizationError(.invalidState, message: "failed to write resolver configuration for \(name)") + } + } + + /// Removes a DNS resolver configuration file for domain resolved by the application. + public func deleteDomain(name: String) throws { + let path = self.configURL.appending(path: "\(Self.containerizationPrefix)\(name)").path + let fm = FileManager.default + guard fm.fileExists(atPath: path) else { + throw ContainerizationError(.notFound, message: "domain \(name) at \(path) not found") + } + + do { + try fm.removeItem(atPath: path) + } catch { + throw ContainerizationError(.invalidState, message: "cannot delete domain (try sudo?)") + } + } + + /// Lists application-created local DNS domains. + public func listDomains() -> [String] { + let fm: FileManager = FileManager.default + guard + let resolverPaths = try? fm.contentsOfDirectory( + at: self.configURL, + includingPropertiesForKeys: [.isDirectoryKey] + ) + else { + return [] + } + + return + resolverPaths + .filter { $0.lastPathComponent.starts(with: Self.containerizationPrefix) } + .compactMap { try? getDomainFromResolver(url: $0) } + .sorted() + } + + /// 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(url: URL) throws -> String? { + let text = try String(contentsOf: url, 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 String(components[1]) + } + + return nil + } +} diff --git a/Sources/ContainerClient/Measurement+Parse.swift b/Sources/ContainerClient/Measurement+Parse.swift new file mode 100644 index 00000000..e28bd902 --- /dev/null +++ b/Sources/ContainerClient/Measurement+Parse.swift @@ -0,0 +1,80 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +private let units: [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)" + } + } + } + + /// parse the provided string into a measurement that is able to be converted to various byte sizes + public static func parse(parsing: String) throws -> Measurement { + let check = "01234567890. " + let i = parsing.lastIndex { + check.contains($0) + } + guard let i else { + throw ParseError.invalidSize + } + let after = parsing.index(after: i) + let rawValue = parsing[..(value: value, unit: unit) + } + + static func parseUnit(_ unit: String) throws -> Character { + let s = unit.dropFirst() + switch s { + case "", "b", "ib": + return unit.first ?? "b" + default: + throw ParseError.invalidSymbol(unit) + } + } +} diff --git a/Sources/ContainerClient/Parser.swift b/Sources/ContainerClient/Parser.swift new file mode 100644 index 00000000..9151f561 --- /dev/null +++ b/Sources/ContainerClient/Parser.swift @@ -0,0 +1,399 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerizationOCI +import ContainerizationOS +import Foundation + +public struct Parser { + public static func memoryString(_ memory: String) throws -> Int64 { + let ram = try Measurement.parse(parsing: memory) + let mb = ram.converted(to: .mebibytes) + return Int64(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 resources(cpus: Int64?, memory: String?) throws -> ContainerConfiguration.Resources { + var resource = ContainerConfiguration.Resources() + if let cpus { + resource.cpus = Int(cpus) + } + if let memory { + resource.memoryInBytes = try Parser.memoryString(memory).mib() + } + return resource + } + + public static func allEnv(imageEnvs: [String], envFiles: [String], envs: [String]) throws -> [String] { + var output: [String] = [] + output.append(contentsOf: Parser.env(envList: imageEnvs)) + for envFile in envFiles { + let content = try Parser.envFile(path: envFile) + output.append(contentsOf: content) + } + output.append(contentsOf: Parser.env(envList: envs)) + return output + } + + static func envFile(path: String) throws -> [String] { + guard FileManager.default.fileExists(atPath: path) else { + throw ContainerizationError(.notFound, message: "envfile at \(path) not found") + } + + let data = try String(contentsOfFile: path, encoding: .utf8) + let lines = data.components(separatedBy: .newlines) + var envVars: [String] = [] + for line in lines { + let line = line.trimmingCharacters(in: .whitespaces) + if line.isEmpty { + continue + } + if !line.hasPrefix("#") { + let keyVals = line.split(separator: "=") + if keyVals.count != 2 { + continue + } + let key = keyVals[0].trimmingCharacters(in: .whitespaces) + let val = keyVals[1].trimmingCharacters(in: .whitespaces) + if key.isEmpty || val.isEmpty { + continue + } + envVars.append("\(key)=\(val)") + } + } + return envVars + } + + static func env(envList: [String]) -> [String] { + var envVar: [String] = [] + for env in envList { + var env = env + let parts = env.split(separator: "=", maxSplits: 2) + if parts.count == 1 { + guard let val = ProcessInfo.processInfo.environment[env] else { + continue + } + env = "\(env)=\(val)" + } + envVar.append(env) + } + return envVar + } + + static func labels(_ rawLabels: [String]) throws -> [String: String] { + var result: [String: String] = [:] + 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 + } + + 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) + + return .init( + executable: commandToRun.first!, + arguments: [String](commandToRun.dropFirst()), + environment: envvars, + workingDirectory: workingDir, + terminal: processFlags.tty, + user: user, + supplementalGroups: additionalGroups + ) + } + + // MARK: Mounts + + static let mountTypes = [ + "virtiofs", + "bind", + "tmpfs", + ] + + static let defaultDirectives = ["type": "virtiofs"] + + static func tmpfsMounts(_ mounts: [String]) throws -> [Filesystem] { + var result: [Filesystem] = [] + let mounts = mounts.dedupe() + for tmpfs in mounts { + let fs = Filesystem.tmpfs(destination: tmpfs, options: []) + try validateMount(fs) + result.append(fs) + } + return result + } + + static func mounts(_ rawMounts: [String]) throws -> [Filesystem] { + var mounts: [Filesystem] = [] + let rawMounts = rawMounts.dedupe() + for mount in rawMounts { + let m = try Parser.mount(mount) + try validateMount(m) + mounts.append(m) + } + return mounts + } + + static func mount(_ mount: String) throws -> Filesystem { + 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: "unknwon 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() + 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 + 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.memoryString(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": + let absPath = URL(filePath: val).absoluteURL.path + switch type { + case "virtiofs", "bind": + fs.source = absPath + 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)") + } + } + return fs + } + + static func volumes(_ rawVolumes: [String]) throws -> [Filesystem] { + var mounts: [Filesystem] = [] + for volume in rawVolumes { + let m = try Parser.volume(volume) + try Parser.validateMount(m) + mounts.append(m) + } + return mounts + } + + private static func volume(_ volume: String) throws -> Filesystem { + var vol = volume + vol.trimLeft(char: ":") + + let parts = vol.split(separator: ":") + switch parts.count { + case 1: + throw ContainerizationError(.invalidArgument, message: "anonymous volumes are not supported") + case 2, 3: + // Bind / volume mounts. + let src = String(parts[0]) + let dst = String(parts[1]) + + let abs = URL(filePath: src).absoluteURL.path + if !FileManager.default.fileExists(atPath: abs) { + throw ContainerizationError(.invalidArgument, message: "named volumes are not supported") + } + + var fs = Filesystem.virtiofs( + source: URL(fileURLWithPath: src).absolutePath(), + destination: dst, + options: [] + ) + if parts.count == 3 { + fs.options = parts[2].split(separator: ",").map { String($0) } + } + return fs + default: + throw ContainerizationError(.invalidArgument, message: "invalid volume format \(volume)") + } + } + + static func validMountType(_ type: String) -> Bool { + mountTypes.contains(type) + } + + static func validateMount(_ mount: Filesystem) throws { + if !mount.isTmpfs { + if !mount.source.isAbsolutePath() { + throw ContainerizationError( + .invalidArgument, message: "\(mount.source) is not an absolute path on the host") + } + if !FileManager.default.fileExists(atPath: mount.source) { + throw ContainerizationError(.invalidArgument, message: "file path '\(mount.source)' does not exist") + } + } + + if mount.destination.isEmpty { + throw ContainerizationError(.invalidArgument, message: "mount destination cannot be empty") + } + } +} diff --git a/Sources/ContainerClient/ProgressUpdateClient.swift b/Sources/ContainerClient/ProgressUpdateClient.swift new file mode 100644 index 00000000..471fef57 --- /dev/null +++ b/Sources/ContainerClient/ProgressUpdateClient.swift @@ -0,0 +1,163 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/ContainerClient/ProgressUpdateService.swift b/Sources/ContainerClient/ProgressUpdateService.swift new file mode 100644 index 00000000..7f10392e --- /dev/null +++ b/Sources/ContainerClient/ProgressUpdateService.swift @@ -0,0 +1,82 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/ContainerClient/SandboxClient.swift b/Sources/ContainerClient/SandboxClient.swift new file mode 100644 index 00000000..d6a0c26e --- /dev/null +++ b/Sources/ContainerClient/SandboxClient.swift @@ -0,0 +1,182 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerizationOS +import Foundation +import TerminalProgress + +/// A client for interacting with a single sandbox. +public struct SandboxClient: Sendable, Codable { + 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 + + /// Create a container. + public init(id: String, runtime: String) { + self.id = id + self.runtime = runtime + } +} + +// Runtime Methods +extension SandboxClient { + public func bootstrap() async throws { + let request = XPCMessage(route: SandboxRoutes.bootstrap.rawValue) + let client = createClient() + defer { client.close() } + + try await client.send(request) + } + + public func state() async throws -> SandboxSnapshot { + let request = XPCMessage(route: SandboxRoutes.state.rawValue) + let client = createClient() + defer { client.close() } + + let response = try await client.send(request) + return try response.sandboxSnapshot() + } + + public func createProcess(_ id: String, config: ProcessConfiguration) async throws { + let request = XPCMessage(route: SandboxRoutes.createProcess.rawValue) + request.set(key: .id, value: id) + let data = try JSONEncoder().encode(config) + request.set(key: .processConfig, value: data) + + let client = createClient() + defer { client.close() } + try await client.send(request) + } + + public func startProcess(_ id: String, stdio: [FileHandle?]) async throws { + let request = XPCMessage(route: SandboxRoutes.start.rawValue) + for (i, h) in stdio.enumerated() { + let key: XPCKeys = { + switch i { + case 0: .stdin + case 1: .stdout + case 2: .stderr + default: + fatalError("invalid fd \(i)") + } + }() + + if let h { + request.set(key: key, value: h) + } + } + request.set(key: .id, value: id) + + let client = createClient() + defer { client.close() } + + try await client.send(request) + } + + public func stop(options: ContainerStopOptions) async throws { + let request = XPCMessage(route: SandboxRoutes.stop.rawValue) + + let data = try JSONEncoder().encode(options) + request.set(key: .stopOptions, value: data) + + let client = createClient() + defer { client.close() } + let responseTimeout = Duration(.seconds(Int64(options.timeoutInSeconds + 1))) + try await client.send(request, responseTimeout: responseTimeout) + } + + public func kill(_ id: String, signal: Int64) async throws { + let request = XPCMessage(route: SandboxRoutes.kill.rawValue) + request.set(key: .id, value: id) + request.set(key: .signal, value: signal) + + let client = createClient() + defer { client.close() } + try await client.send(request) + } + + public func resize(_ id: String, size: Terminal.Size) async throws { + let request = XPCMessage(route: SandboxRoutes.resize.rawValue) + request.set(key: .id, value: id) + request.set(key: .width, value: UInt64(size.width)) + request.set(key: .height, value: UInt64(size.height)) + + let client = createClient() + defer { client.close() } + try await client.send(request) + } + + public func wait(_ id: String) async throws -> Int32 { + let request = XPCMessage(route: SandboxRoutes.wait.rawValue) + request.set(key: .id, value: id) + + let client = createClient() + defer { client.close() } + let response = try await client.send(request) + let code = response.int64(key: .exitCode) + return Int32(code) + } + + public func dial(_ port: UInt32) async throws -> FileHandle { + let request = XPCMessage(route: SandboxRoutes.dial.rawValue) + request.set(key: .port, value: UInt64(port)) + + let client = createClient() + defer { client.close() } + + let response = try await client.send(request) + guard let fh = response.fileHandle(key: .fd) else { + throw ContainerizationError( + .internalError, + message: "failed to get fd for vsock port \(port)" + ) + } + return fh + } + + private func createClient() -> XPCClient { + XPCClient(service: machServiceLabel) + } +} + +extension XPCMessage { + public func id() throws -> String { + let id = self.string(key: .id) + guard let id else { + throw ContainerizationError(.invalidArgument, message: "No id") + } + return id + } + + func sandboxSnapshot() throws -> SandboxSnapshot { + let data = self.dataNoCopy(key: .snapshot) + guard let data else { + throw ContainerizationError(.invalidArgument, message: "No state data returned") + } + return try JSONDecoder().decode(SandboxSnapshot.self, from: data) + } +} diff --git a/Sources/ContainerClient/SandboxRoutes.swift b/Sources/ContainerClient/SandboxRoutes.swift new file mode 100644 index 00000000..e5296623 --- /dev/null +++ b/Sources/ContainerClient/SandboxRoutes.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 SandboxRoutes: String { + /// Bootstrap the sandbox instance and create the init process. + case bootstrap = "com.apple.container.sandbox/bootstrap" + /// Create a process in the sandbox. + case createProcess = "com.apple.container.sandbox/createProcess" + /// Start a process in the sandbox. + case start = "com.apple.container.sandbox/start" + /// Stop the sandbox. + case stop = "com.apple.container.sandbox/stop" + /// Return the current state of the sandbox. + case state = "com.apple.container.sandbox/state" + /// Kill a process in the sandbox. + case kill = "com.apple.container.sandbox/kill" + /// Resize the pty of a process in the sandbox. + case resize = "com.apple.container.sandbox/resize" + /// Wait on a process in the sandbox. + case wait = "com.apple.container.sandbox/wait" + /// Execute a new process in the sandbox. + case exec = "com.apple.container.sandbox/exec" + /// Dial a vsock port in the sandbox. + case dial = "com.apple.container.sandbox/dial" +} diff --git a/Sources/ContainerClient/SandboxSnapshot.swift b/Sources/ContainerClient/SandboxSnapshot.swift new file mode 100644 index 00000000..ba8b9356 --- /dev/null +++ b/Sources/ContainerClient/SandboxSnapshot.swift @@ -0,0 +1,37 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService + +/// A snapshot of a sandbox and its resources. +public struct SandboxSnapshot: Codable, Sendable { + /// The runtime status of the sandbox. + public let status: RuntimeStatus + /// Network attachments for the sandbox. + public let networks: [Attachment] + /// Containers placed in the sandbox. + public let containers: [ContainerSnapshot] + + package init( + status: RuntimeStatus, + networks: [Attachment], + containers: [ContainerSnapshot] + ) { + self.status = status + self.networks = networks + self.containers = containers + } +} diff --git a/Sources/ContainerClient/SignalThreshold.swift b/Sources/ContainerClient/SignalThreshold.swift new file mode 100644 index 00000000..1cc43e93 --- /dev/null +++ b/Sources/ContainerClient/SignalThreshold.swift @@ -0,0 +1,61 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/ContainerClient/String+Extensions.swift b/Sources/ContainerClient/String+Extensions.swift new file mode 100644 index 00000000..5e073c27 --- /dev/null +++ b/Sources/ContainerClient/String+Extensions.swift @@ -0,0 +1,57 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/ContainerClient/TableOutput.swift b/Sources/ContainerClient/TableOutput.swift new file mode 100644 index 00000000..ecadbc53 --- /dev/null +++ b/Sources/ContainerClient/TableOutput.swift @@ -0,0 +1,60 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 { + 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/ContainerClient/Utility.swift b/Sources/ContainerClient/Utility.swift new file mode 100644 index 00000000..c050efa5 --- /dev/null +++ b/Sources/ContainerClient/Utility.swift @@ -0,0 +1,209 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import TerminalProgress + +public struct Utility { + private static let infraImages = [ + ClientDefaults.get(key: .defaultBuilderImage), + ClientDefaults.get(key: .defaultInitImage), + ] + + public static func createContainerID(name: String?) -> String { + guard let name else { + return UUID().uuidString.lowercased() + } + return name + } + + public static func isInfraImage(name: String) -> Bool { + for infraImage in infraImages { + if name == infraImage { + return true + } + } + return false + } + + public static func trimDigest(digest: String) -> String { + var digest = digest + digest.trimPrefix("sha256:") + if digest.count > 24 { + digest = String(digest.prefix(24)) + "..." + } + return digest + } + + 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 containerConfigFromFlags( + id: String, + image: String, + arguments: [String], + process: Flags.Process, + management: Flags.Management, + resource: Flags.Resource, + progressUpdate: @escaping ProgressUpdateHandler + ) async throws -> (ContainerConfiguration, Kernel) { + let requestedPlatform = Parser.platform(os: management.os, arch: management.arch) + + 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, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchTask, from: progressUpdate) + ) + + // 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 image"), + .setItemsName("blobs"), + ]) + + let fetchKernelTask = await taskManager.startTask() + let kernel = try await self.getKernel( + management: management, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchKernelTask, from: progressUpdate)) + + // Pull and unpack the initial filesystem + await progressUpdate([ + .setDescription("Fetching init image"), + .setItemsName("blobs"), + ]) + let fetchInitTask = await taskManager.startTask() + let initImage = try await ClientImage.fetch( + reference: ClientImage.initImageRef, platform: .current, + progressUpdate: ProgressTaskCoordinator.handler(for: fetchInitTask, from: progressUpdate)) + + 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.hostname = id + + config.resources = try Parser.resources( + cpus: resource.cpus, + memory: resource.memory + ) + + let tmpfs = try Parser.tmpfsMounts(management.tmpFs) + let volumes = try Parser.volumes(management.volumes) + var mounts = try Parser.mounts(management.mounts) + mounts.append(contentsOf: tmpfs) + mounts.append(contentsOf: volumes) + config.mounts = mounts + + let network = try await ClientNetwork.get(id: ClientNetwork.defaultNetworkName) + guard case .running(_, let networkStatus) = network else { + throw ContainerizationError(.invalidState, message: "default network is not running") + } + let nameservers: [String] + config.networks = [network.id] + if management.dnsNameservers.isEmpty { + let subnet = try CIDRAddress(networkStatus.address) + let nameserver = IPv4Address(fromValue: subnet.lower.value + 1).description + nameservers = [nameserver] + } else { + nameservers = management.dnsNameservers + } + + if management.dnsDisabled { + config.dns = nil + } else { + let domain = management.dnsDomain ?? ClientDefaults.getOptional(key: .defaultDNSDomain) + config.dns = .init( + nameservers: nameservers, + domain: domain, + searchDomains: management.dnsSearchDomains, + options: management.dnsOptions + ) + } + + if Platform.current.architecture == "arm64" && requestedPlatform.architecture == "amd64" { + config.rosetta = true + } + + config.labels = try Parser.labels(management.labels) + + return (config, kernel) + } + + private static func getKernel(management: Flags.Management, progressUpdate: @escaping ProgressUpdateHandler) 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 + switch Platform.current.architecture { + case "arm64": + s = .linuxArm + case "amd64": + s = .linuxAmd + default: + throw ContainerizationError.init(.unsupported, message: "platform architecture \(Platform.current.architecture)") + } + 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) + } +} diff --git a/Sources/ContainerClient/XPC+.swift b/Sources/ContainerClient/XPC+.swift new file mode 100644 index 00000000..80fb95fa --- /dev/null +++ b/Sources/ContainerClient/XPC+.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 + /// Vsock port number key. + case port + /// Exit code for a process + case exitCode + /// An event that occured 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 + /// Plugins + case pluginName + case plugins + case plugin + + /// Health check request. + case ping + + /// 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 networkState + case networkStates + + /// Kernel + case kernel + case kernelTarURL + case kernelFilePath + case setDefault + case installedKernels + case systemPlatform + case kernelName +} + +public enum XPCRoute: String { + case listContainer + case createContainer + case deleteContainer + case containerLogs + case containerEvent + + case pluginLoad + case pluginGet + case pluginRestart + case pluginUnload + case pluginList + + case networkCreate + case networkDelete + case networkList + + 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 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/ContainerLog/OSLogHandler.swift b/Sources/ContainerLog/OSLogHandler.swift new file mode 100644 index 00000000..1d871458 --- /dev/null +++ b/Sources/ContainerLog/OSLogHandler.swift @@ -0,0 +1,106 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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: + return .default + case .error, .warning: + return .error + case .critical: + return .fault + } + } +} diff --git a/Sources/ContainerPersistence/EntityStore.swift b/Sources/ContainerPersistence/EntityStore.swift new file mode 100644 index 00000000..ab1b5ff1 --- /dev/null +++ b/Sources/ContainerPersistence/EntityStore.swift @@ -0,0 +1,126 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +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: URL + private let type: String + private var index: Index + private let log: Logger + private let encoder = JSONEncoder() + + public init(path: URL, 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 metadataUrl = metadataUrl(entity.id) + guard !FileManager.default.fileExists(atPath: metadataUrl.path) else { + throw ContainerizationError(.exists, message: "Entity \(entity.id) already exist") + } + + try FileManager.default.createDirectory(at: entityUrl(entity.id), withIntermediateDirectories: true) + let data = try encoder.encode(entity) + try data.write(to: metadataUrl) + index[entity.id] = entity + } + + public func retrieve(_ id: String) throws -> T? { + index[id] + } + + public func update(_ entity: T) async throws { + let metadataUrl: URL = metadataUrl(entity.id) + guard FileManager.default.fileExists(atPath: metadataUrl.path) else { + throw ContainerizationError(.notFound, message: "Entity \(entity.id) not found") + } + + let data = try encoder.encode(entity) + try data.write(to: metadataUrl) + index[entity.id] = entity + } + + public func upsert(_ entity: T) async throws { + let metadataUrl: URL = metadataUrl(entity.id) + let data = try encoder.encode(entity) + try data.write(to: metadataUrl) + index[entity.id] = entity + } + + public func delete(_ id: String) async throws { + let metadataUrl = entityUrl(id) + guard FileManager.default.fileExists(atPath: metadataUrl.path) else { + throw ContainerizationError(.notFound, message: "entity \(id) not found") + } + try FileManager.default.removeItem(at: metadataUrl) + index.removeValue(forKey: id) + } + + public func entityUrl(_ id: String) -> URL { + path.appendingPathComponent(id) + } + + private static func load(path: URL, log: Logger) throws -> Index { + let directories = try FileManager.default.contentsOfDirectory(at: path, includingPropertiesForKeys: nil) + var index: FilesystemEntityStore.Index = Index() + + for entityUrl in directories { + do { + let metadataUrl = entityUrl.appendingPathComponent(metadataFilename) + let data = try Data(contentsOf: metadataUrl) + let entity = try JSONDecoder().decode(T.self, from: data) + index[entity.id] = entity + } catch { + log.warning( + "failed to load entity, ignoring", + metadata: [ + "path": "\(entityUrl)" + ]) + } + } + + return index + } + + private func metadataUrl(_ id: String) -> URL { + entityUrl(id).appendingPathComponent(metadataFilename) + } +} diff --git a/Sources/ContainerPlugin/CommandLine+Executable.swift b/Sources/ContainerPlugin/CommandLine+Executable.swift new file mode 100644 index 00000000..a8be8cae --- /dev/null +++ b/Sources/ContainerPlugin/CommandLine+Executable.swift @@ -0,0 +1,26 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 CommandLine { + public static var executableDirectoryUrl: URL { + let executablePath = Self.arguments[0] + let executableUrl = URL(filePath: executablePath) + let executableDirectoryUrl = executableUrl.deletingLastPathComponent() + return executableDirectoryUrl.standardized + } +} diff --git a/Sources/ContainerPlugin/LaunchPlist.swift b/Sources/ContainerPlugin/LaunchPlist.swift new file mode 100644 index 00000000..01ab0b4c --- /dev/null +++ b/Sources/ContainerPlugin/LaunchPlist.swift @@ -0,0 +1,116 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 { + 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" + } + + 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 = 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/Plugin.swift b/Sources/ContainerPlugin/Plugin.swift new file mode 100644 index 00000000..98a11e88 --- /dev/null +++ b/Sources/ContainerPlugin/Plugin.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + + public init(binaryURL: URL, config: PluginConfig) { + self.binaryURL = binaryURL + self.config = config + } +} + +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 00000000..e7290e37 --- /dev/null +++ b/Sources/ContainerPlugin/PluginConfig.swift @@ -0,0 +1,117 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +/// 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.json` 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.json`. +/// An example of a valid plugin directory structure would be +/// $ tree foobar +/// foobar +/// ├── bin +/// │ └── foobar +/// └── 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. + /// This parameter is used only when `servicesConfig.loadAtBoot` is `true` + 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? +} + +extension PluginConfig { + public var isCLI: Bool { self.servicesConfig == nil } +} + +extension PluginConfig { + 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 + } + + let decoder: JSONDecoder = JSONDecoder() + self = try decoder.decode(PluginConfig.self, from: data) + } +} diff --git a/Sources/ContainerPlugin/PluginFactory.swift b/Sources/ContainerPlugin/PluginFactory.swift new file mode 100644 index 00000000..5194d81e --- /dev/null +++ b/Sources/ContainerPlugin/PluginFactory.swift @@ -0,0 +1,93 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +private let configFilename: String = "config.json" + +/// Describes the configuration and binary file locations for a plugin. +public protocol PluginFactory: Sendable { + /// Create a plugin conforming to the layout, if possible. + func create(installURL: URL) throws -> Plugin? +} + +/// Default layout which uses a Unix-like structure. +public struct DefaultPluginFactory: PluginFactory { + public init() {} + + public func create(installURL: URL) throws -> Plugin? { + let fm = FileManager.default + + let configURL = installURL.appending(path: configFilename) + guard fm.fileExists(atPath: configURL.path) 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 + } + + return Plugin(binaryURL: binaryURL, config: config) + } +} + +/// Layout which uses a macOS application bundle structure. +public struct AppBundlePluginFactory: PluginFactory { + private static let appSuffix = ".app" + + public init() {} + + public func create(installURL: URL) throws -> Plugin? { + let fm = FileManager.default + + let configURL = + installURL + .appending(path: "Contents") + .appending(path: "Resources") + .appending(path: configFilename) + guard fm.fileExists(atPath: configURL.path) 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 + } + + return Plugin(binaryURL: binaryURL, config: config) + } +} diff --git a/Sources/ContainerPlugin/PluginLoader.swift b/Sources/ContainerPlugin/PluginLoader.swift new file mode 100644 index 00000000..6a8b97b0 --- /dev/null +++ b/Sources/ContainerPlugin/PluginLoader.swift @@ -0,0 +1,213 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +public struct PluginLoader: Sendable { + // 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 defaultPluginResourcePath: URL + + private let pluginDirectories: [URL] + + private let pluginFactories: [PluginFactory] + + private let log: Logger? + + public typealias PluginQualifier = ((Plugin) -> Bool) + + public init(pluginDirectories: [URL], pluginFactories: [PluginFactory], defaultResourcePath: URL, log: Logger? = nil) { + self.pluginDirectories = pluginDirectories + self.pluginFactories = pluginFactories + self.log = log + self.defaultPluginResourcePath = defaultResourcePath + } + + static public func defaultPluginResourcePath(root: URL) -> URL { + root.appending(path: "plugin-state") + } + + static public func userPluginsDir(root: URL) -> URL { + root.appending(path: "user-plugins") + } +} + +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 footer = String(lines.removeLast()) + + let sectionHeader = "PLUGINS:" + lines.append(sectionHeader) + + for plugin in plugins { + let helpText = plugin.helpText(padding: 24) + lines.append(helpText) + } + lines.append("") + lines.append(footer) + + return lines.joined(separator: "\n") + } + + public func findPlugins() -> [Plugin] { + let fm = FileManager.default + + var pluginNames = Set() + var plugins: [Plugin] = [] + + for pluginDir in pluginDirectories { + if !fm.fileExists(atPath: pluginDir.path) { + continue + } + + guard + var dirs = try? fm.contentsOfDirectory( + at: pluginDir, + includingPropertiesForKeys: [.isDirectoryKey], + options: .skipsHiddenFiles + ) + else { + continue + } + dirs = dirs.filter { + $0.isDirectory + } + + for installURL in dirs { + do { + 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 + } + + guard !pluginNames.contains(plugin.name) else { + log?.warning( + "Not installing shadowed plugin", + metadata: [ + "path": "\(installURL.path)", + "name": "\(plugin.name)", + ]) + continue + } + + plugins.append(plugin) + pluginNames.insert(plugin.name) + } catch { + log?.warning( + "Not installing plugin with invalid configuration", + metadata: [ + "path": "\(installURL.path)", + "error": "\(error)", + ] + ) + } + } + } + + return plugins + } + + public func findPlugin(name: String, log: Logger? = nil) -> Plugin? { + do { + return + try pluginDirectories + .compactMap { installURL in + try pluginFactories.compactMap { try $0.create(installURL: installURL.appending(path: name)) }.first + } + .first + } catch { + log?.warning( + "Not installing plugin with invalid configuration", + metadata: [ + "name": "\(name)", + "error": "\(error)", + ] + ) + return nil + } + } +} + +extension PluginLoader { + public func registerWithLaunchd( + plugin: Plugin, + rootURL: URL? = nil, + args: [String]? = nil, + instanceId: String? = nil + ) 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 = rootURL ?? self.defaultPluginResourcePath.appending(path: plugin.name) + try FileManager.default.createDirectory(at: rootURL, withIntermediateDirectories: true) + let env = ProcessInfo.processInfo.environment.filter { key, _ in + key.hasPrefix("CONTAINER_") + } + let logUrl = rootURL.appendingPathComponent("service.log") + let plist = LaunchPlist( + label: id, + arguments: [plugin.binaryURL.path] + (args ?? serviceConfig.defaultArguments), + environment: env, + limitLoadToSessionType: [.Aqua, .Background, .System], + runAtLoad: serviceConfig.runAtLoad, + stdout: logUrl.path, + stderr: logUrl.path, + 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) + } +} diff --git a/Sources/ContainerPlugin/ServiceManager.swift b/Sources/ContainerPlugin/ServiceManager.swift new file mode 100644 index 00000000..d7a936f3 --- /dev/null +++ b/Sources/ContainerPlugin/ServiceManager.swift @@ -0,0 +1,131 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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]) + } + + /// 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 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 { + // TODO: review error handling + return [] + } + + guard let outputText = String(data: outputData, encoding: .utf8) else { + // TODO: review error handling + return [] + } + + // 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/ContainerXPC/XPCClient.swift b/Sources/ContainerXPC/XPCClient.swift new file mode 100644 index 00000000..16bea468 --- /dev/null +++ b/Sources/ContainerXPC/XPCClient.swift @@ -0,0 +1,113 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 struct XPCClient: Sendable { + // Access to `connection` is protected by a lock + 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) + } +} + +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) + } + + /// 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/XPCMessage.swift b/Sources/ContainerXPC/XPCMessage.swift new file mode 100644 index 00000000..5b81cabd --- /dev/null +++ b/Sources/ContainerXPC/XPCMessage.swift @@ -0,0 +1,267 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 boundries 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) { + let serializableError = ContainerXPCError(code: error.code.description, message: error.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 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 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 00000000..3af374a8 --- /dev/null +++ b/Sources/ContainerXPC/XPCServer.swift @@ -0,0 +1,199 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 +import Logging +import os +import Synchronization + +public struct XPCServer: Sendable { + public typealias RouteHandler = @Sendable (XPCMessage) async throws -> XPCMessage + + 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 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 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 \(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) + replySent.withLock { $0 = true } + } + if !added { + break + } + } + group.cancelAll() + } + } + + func handleMessage(connection: xpc_connection_t, object: xpc_object_t) async throws { + guard let route = object.route else { + log.error("empty route") + return + } + + if let handler = routes[route] { + let message = XPCMessage(object: object) + do { + let response = try await handler(message) + xpc_connection_send_message(connection, response.underlying) + } catch let error as ContainerizationError { + let reply = message.reply() + log.error("handler for \(route) threw error \(error)") + reply.set(error: error) + xpc_connection_send_message(connection, reply.underlying) + } catch { + let reply = message.reply() + log.error("handler for \(route) threw error \(error)") + let err = ContainerizationError(.unknown, message: String(describing: error)) + 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/DNSServer/DNSHandler.swift b/Sources/DNSServer/DNSHandler.swift new file mode 100644 index 00000000..7bb74479 --- /dev/null +++ b/Sources/DNSServer/DNSHandler.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 00000000..9c9f3941 --- /dev/null +++ b/Sources/DNSServer/DNSServer+Handle.swift @@ -0,0 +1,84 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 { + let chunkSize = 512 + var data = Data() + + self.log?.debug("reading data") + while packet.data.readableBytes > 0 { + if let chunk = packet.data.readBytes(length: min(chunkSize, packet.data.readableBytes)) { + data.append(contentsOf: chunk) + } + } + + self.log?.debug("deserializing message") + let query = try Message(deserialize: data) + self.log?.debug("processing query: \(query.questions)") + + // always send response + let responseData: Data + do { + 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: [] + ) + + // no responses + if response.answers.isEmpty { + response.returnCode = .nonExistentDomain + } + + self.log?.debug("serializing response") + responseData = try response.serialize() + } catch { + self.log?.error("error processing message from \(query): \(error)") + let response = Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions, + answers: [] + ) + responseData = try response.serialize() + } + + self.log?.debug("sending response for \(query.id)") + let rData = ByteBuffer(bytes: responseData) + try? await outbound.write(AddressedEnvelope(remoteAddress: packet.remoteAddress, data: rData)) + + self.log?.debug("processing done") + + } +} diff --git a/Sources/DNSServer/DNSServer.swift b/Sources/DNSServer/DNSServer.swift new file mode 100644 index 00000000..ff01381d --- /dev/null +++ b/Sources/DNSServer/DNSServer.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 00000000..d075a39c --- /dev/null +++ b/Sources/DNSServer/Handlers/CompositeResolver.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 00000000..1e362f41 --- /dev/null +++ b/Sources/DNSServer/Handlers/HostTableResolver.swift @@ -0,0 +1,83 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import DNS + +/// Handler that uses table lookup to resolve hostnames. +public struct HostTableResolver: DNSHandler { + public let hosts4: [String: IPv4] + private let ttl: UInt32 + + public init(hosts4: [String: IPv4], ttl: UInt32 = 300) { + self.hosts4 = hosts4 + self.ttl = ttl + } + + public func answer(query: Message) async throws -> Message? { + let question = query.questions[0] + let record: ResourceRecord? + switch question.type { + case ResourceRecordType.host: + record = answerHost(question: question) + case ResourceRecordType.nameServer, + ResourceRecordType.alias, + ResourceRecordType.startOfAuthority, + ResourceRecordType.pointer, + ResourceRecordType.mailExchange, + ResourceRecordType.text, + ResourceRecordType.host6, + ResourceRecordType.service, + ResourceRecordType.incrementalZoneTransfer, + ResourceRecordType.standardZoneTransfer, + ResourceRecordType.all: + return Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions, + answers: [] + ) + default: + return Message( + id: query.id, + type: .response, + returnCode: .formatError, + 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) -> ResourceRecord? { + guard let ip = hosts4[question.name] 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 00000000..193efca1 --- /dev/null +++ b/Sources/DNSServer/Handlers/NxDomainResolver.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import DNS + +/// 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: [] + ) + case ResourceRecordType.nameServer, + ResourceRecordType.alias, + ResourceRecordType.startOfAuthority, + ResourceRecordType.pointer, + ResourceRecordType.mailExchange, + ResourceRecordType.text, + ResourceRecordType.host6, + ResourceRecordType.service, + ResourceRecordType.incrementalZoneTransfer, + ResourceRecordType.standardZoneTransfer, + ResourceRecordType.all: + return Message( + id: query.id, + type: .response, + returnCode: .notImplemented, + questions: query.questions, + answers: [] + ) + default: + return Message( + id: query.id, + type: .response, + returnCode: .formatError, + questions: query.questions, + answers: [] + ) + } + } +} diff --git a/Sources/DNSServer/Handlers/StandardQueryValidator.swift b/Sources/DNSServer/Handlers/StandardQueryValidator.swift new file mode 100644 index 00000000..18db1ce0 --- /dev/null +++ b/Sources/DNSServer/Handlers/StandardQueryValidator.swift @@ -0,0 +1,64 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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/Types.swift b/Sources/DNSServer/Types.swift new file mode 100644 index 00000000..72dbc53c --- /dev/null +++ b/Sources/DNSServer/Types.swift @@ -0,0 +1,53 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import DNS +import Foundation + +public typealias Message = DNS.Message +public typealias ResourceRecord = DNS.ResourceRecord +public typealias HostRecord = DNS.HostRecord +public typealias IPv4 = DNS.IPv4 +public typealias IPv6 = DNS.IPv6 +public typealias ReturnCode = DNS.ReturnCode + +public enum DNSResolverError: Swift.Error, CustomStringConvertible { + case serverError(_ msg: String) + case invalidHandlerSpec(_ spec: String) + case unsupportedHandlerType(_ t: String) + case invalidIP(_ v: String) + case invalidHandlerOption(_ v: String) + case handlerConfigError(_ msg: String) + + public var description: String { + switch self { + case .serverError(let msg): + return "server error: \(msg)" + case .invalidHandlerSpec(let msg): + return "invalid handler spec: \(msg)" + case .unsupportedHandlerType(let t): + return "unsupported handler type specified: \(t)" + case .invalidIP(let ip): + return "invalid IP specified: \(ip)" + case .invalidHandlerOption(let v): + return "invalid handler option specified: \(v)" + case .handlerConfigError(let msg): + return "error configuring handler: \(msg)" + } + } +} diff --git a/Sources/Helpers/Images/ImagesHelper.swift b/Sources/Helpers/Images/ImagesHelper.swift new file mode 100644 index 00000000..620b803b --- /dev/null +++ b/Sources/Helpers/Images/ImagesHelper.swift @@ -0,0 +1,139 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 CVersion +import ContainerImagesService +import ContainerImagesServiceClient +import ContainerLog +import ContainerXPC +import Containerization +import Foundation +import Logging + +@main +struct ImagesHelper: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "container-core-images", + abstract: "XPC service for managing OCI images", + version: releaseVersion(), + subcommands: [ + Start.self + ] + ) +} + +extension ImagesHelper { + struct Start: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "start", + abstract: "Starts the image plugin" + ) + + @Flag(name: .long, help: "Enable debug logging") + var debug = false + + @Option(name: .long, help: "XPC service prefix") + var serviceIdentifier: String = "com.apple.container.core.container-core-images" + + @Option(name: .shortAndLong, help: "Daemon root directory") + var root = Self.appRoot.path + + static let appRoot: URL = { + FileManager.default.urls( + for: .applicationSupportDirectory, + in: .userDomainMask + ).first! + .appendingPathComponent("com.apple.container") + }() + + func run() async throws { + let commandName = ImagesHelper._commandName + let log = setupLogger() + log.info("starting \(commandName)") + defer { + log.info("stopping \(commandName)") + } + do { + log.info("configuring XPC server") + let root = URL(filePath: root) + var routes = [String: XPCServer.RouteHandler]() + try self.initializeContentService(root: root, log: log, routes: &routes) + try self.initializeImagesService(root: root, log: log, routes: &routes) + let xpc = XPCServer( + identifier: serviceIdentifier, + routes: routes, + log: log + ) + log.info("starting XPC server") + try await xpc.listen() + } catch { + log.error("\(commandName) failed", metadata: ["error": "\(error)"]) + ImagesHelper.exit(withError: error) + } + } + + private func initializeImagesService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws { + let contentStore = RemoteContentStoreClient() + let imageStore = try ImageStore(path: root, contentStore: contentStore) + let snapshotStore = try SnapshotStore(path: root) + let service = try ImagesService(contentStore: contentStore, imageStore: imageStore, snapshotStore: snapshotStore, log: log) + let harness = ImagesServiceHarness(service: service, log: log) + + routes[ImagesServiceXPCRoute.imagePull.rawValue] = harness.pull + routes[ImagesServiceXPCRoute.imageList.rawValue] = harness.list + routes[ImagesServiceXPCRoute.imageDelete.rawValue] = harness.delete + routes[ImagesServiceXPCRoute.imageTag.rawValue] = harness.tag + routes[ImagesServiceXPCRoute.imagePush.rawValue] = harness.push + routes[ImagesServiceXPCRoute.imageSave.rawValue] = harness.save + routes[ImagesServiceXPCRoute.imageLoad.rawValue] = harness.load + routes[ImagesServiceXPCRoute.imageUnpack.rawValue] = harness.unpack + routes[ImagesServiceXPCRoute.imagePrune.rawValue] = harness.prune + routes[ImagesServiceXPCRoute.snapshotDelete.rawValue] = harness.deleteSnapshot + routes[ImagesServiceXPCRoute.snapshotGet.rawValue] = harness.getSnapshot + } + + private func initializeContentService(root: URL, log: Logger, routes: inout [String: XPCServer.RouteHandler]) throws { + let service = try ContentStoreService(root: root, log: log) + let harness = ContentServiceHarness(service: service, log: log) + + routes[ImagesServiceXPCRoute.contentClean.rawValue] = harness.clean + routes[ImagesServiceXPCRoute.contentGet.rawValue] = harness.get + routes[ImagesServiceXPCRoute.contentDelete.rawValue] = harness.delete + routes[ImagesServiceXPCRoute.contentIngestStart.rawValue] = harness.newIngestSession + routes[ImagesServiceXPCRoute.contentIngestCancel.rawValue] = harness.cancelIngestSession + routes[ImagesServiceXPCRoute.contentIngestComplete.rawValue] = harness.completeIngestSession + } + + private func setupLogger() -> Logger { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "ImagesHelper" + ) + } + var log = Logger(label: "com.apple.container") + if debug { + log.logLevel = .debug + } + return log + } + } + + private static func releaseVersion() -> String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + } +} diff --git a/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift new file mode 100644 index 00000000..716f0416 --- /dev/null +++ b/Sources/Helpers/NetworkVmnet/NetworkVmnetHelper.swift @@ -0,0 +1,123 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 CVersion +import ContainerLog +import ContainerNetworkService +import ContainerXPC +import ContainerizationExtras +import Foundation +import Logging + +@main +struct NetworkVmnetHelper: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "container-network-vmnet", + abstract: "XPC service for managing a vmnet network", + version: releaseVersion(), + subcommands: [ + Start.self + ] + ) +} + +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: .shortAndLong, help: "CIDR address for the subnet") + var subnet: String? + + func run() async throws { + let commandName = NetworkVmnetHelper._commandName + let log = setupLogger() + log.info("starting \(commandName)") + defer { + log.info("stopping \(commandName)") + } + + do { + log.info("configuring XPC server") + let subnet = try self.subnet.map { try CIDRAddress($0) } + let configuration = NetworkConfiguration(id: id, mode: .nat, subnet: subnet?.description) + let network = try Self.createNetwork(configuration: configuration, log: log) + try await network.start() + let server = try await NetworkService(network: network, log: log) + let xpc = XPCServer( + identifier: serviceIdentifier, + routes: [ + NetworkRoutes.state.rawValue: server.state, + NetworkRoutes.allocate.rawValue: server.allocate, + NetworkRoutes.deallocate.rawValue: server.deallocate, + NetworkRoutes.lookup.rawValue: server.lookup, + NetworkRoutes.disableAllocator.rawValue: server.disableAllocator, + ], + log: log + ) + + log.info("starting XPC server") + try await xpc.listen() + } catch { + log.error("\(commandName) failed", metadata: ["error": "\(error)"]) + NetworkVmnetHelper.exit(withError: error) + } + } + + private func setupLogger() -> Logger { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "NetworkVmnetHelper" + ) + } + var log = Logger(label: "com.apple.container") + if debug { + log.logLevel = .debug + } + log[metadataKey: "id"] = "\(id)" + return log + } + + private static func createNetwork(configuration: NetworkConfiguration, log: Logger) throws -> Network { + guard #available(macOS 16, *) else { + return try AllocationOnlyVmnetNetwork(configuration: configuration, log: log) + } + + #if !CURRENT_SDK + return try ReservedVmnetNetwork(configuration: configuration, log: log) + #else + return try AllocationOnlyVmnetNetwork(configuration: configuration, log: log) + #endif + } + } + + private static func releaseVersion() -> String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + } +} diff --git a/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift b/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift new file mode 100644 index 00000000..1c9dabf6 --- /dev/null +++ b/Sources/Helpers/RuntimeLinux/IsolatedInterfaceStrategy.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +import ContainerSandboxService +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. +struct IsolatedInterfaceStrategy: InterfaceStrategy { + public func toInterface(attachment: Attachment, additionalData: XPCMessage?) -> Interface { + NATInterface(address: attachment.address, gateway: attachment.gateway) + } +} diff --git a/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift new file mode 100644 index 00000000..b3c7a811 --- /dev/null +++ b/Sources/Helpers/RuntimeLinux/NonisolatedInterfaceStrategy.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +import ContainerSandboxService +import ContainerXPC +import Containerization +import ContainerizationError +import Logging +import Virtualization +import vmnet + +#if !CURRENT_SDK +/// Interface strategy for containers running on macOS 16. +@available(macOS 16, *) +struct NonisolatedInterfaceStrategy: InterfaceStrategy { + private let log: Logger + + public init(log: Logger) { + self.log = log + } + + public func toInterface(attachment: Attachment, 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") + return NATNetworkInterface(address: attachment.address, gateway: attachment.gateway, reference: networkRef) + } +} +#endif diff --git a/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift new file mode 100644 index 00000000..eeefde6b --- /dev/null +++ b/Sources/Helpers/RuntimeLinux/RuntimeLinuxHelper.swift @@ -0,0 +1,130 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 CVersion +import ContainerClient +import ContainerLog +import ContainerNetworkService +import ContainerSandboxService +import ContainerXPC +import Containerization +import ContainerizationError +import Foundation +import Logging + +@main +struct RuntimeLinuxHelper: AsyncParsableCommand { + static let label = "com.apple.container.runtime.container-runtime-linux" + + static let configuration = CommandConfiguration( + commandName: "container-runtime-linux", + abstract: "XPC Service for managing a Linux sandbox", + version: releaseVersion() + ) + + @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 machServiceLabel: String { + "\(Self.label).\(uuid)" + } + + func run() async throws { + let commandName = Self._commandName + let log = setupLogger() + log.info("starting \(commandName)") + defer { + log.info("stopping \(commandName)") + } + + do { + try adjustLimits() + signal(SIGPIPE, SIG_IGN) + + log.info("configuring XPC server") + let interfaceStrategy: any InterfaceStrategy + #if !CURRENT_SDK + if #available(macOS 16, *) { + interfaceStrategy = NonisolatedInterfaceStrategy(log: log) + } else { + interfaceStrategy = IsolatedInterfaceStrategy() + } + #else + interfaceStrategy = IsolatedInterfaceStrategy() + #endif + let server = SandboxService(root: .init(fileURLWithPath: root), interfaceStrategy: interfaceStrategy, log: log) + let xpc = XPCServer( + identifier: machServiceLabel, + routes: [ + SandboxRoutes.bootstrap.rawValue: server.bootstrap, + SandboxRoutes.createProcess.rawValue: server.createProcess, + SandboxRoutes.state.rawValue: server.state, + SandboxRoutes.stop.rawValue: server.stop, + SandboxRoutes.kill.rawValue: server.kill, + SandboxRoutes.resize.rawValue: server.resize, + SandboxRoutes.wait.rawValue: server.wait, + SandboxRoutes.start.rawValue: server.startProcess, + SandboxRoutes.dial.rawValue: server.dial, + ], + log: log + ) + + log.info("starting XPC server") + try await xpc.listen() + } catch { + log.error("\(commandName) failed", metadata: ["error": "\(error)"]) + RuntimeLinuxHelper.exit(withError: error) + } + } + + private func setupLogger() -> Logger { + LoggingSystem.bootstrap { label in + OSLogHandler( + label: label, + category: "RuntimeLinuxHelper" + ) + } + var log = Logger(label: "com.apple.container") + if debug { + log.logLevel = .debug + } + log[metadataKey: "uuid"] = "\(uuid)" + return log + } + + 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)!) + } + } + + private static func releaseVersion() -> String { + (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String) ?? get_release_version().map { String(cString: $0) } ?? "0.0.0" + } +} diff --git a/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift new file mode 100644 index 00000000..59f9398a --- /dev/null +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCKeys.swift @@ -0,0 +1,86 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 + + /// ContentStore + case digest + case digests + case directory + case contentPath + case size + case ingestSessionId +} + +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 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 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 00000000..dc4ed95d --- /dev/null +++ b/Sources/Services/ContainerImagesService/Client/ImageServiceXPCRoutes.swift @@ -0,0 +1,50 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 imagePrune + + case contentGet + case contentDelete + case contentClean + 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 00000000..9fa30e10 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Client/RemoteContentStoreClient.swift @@ -0,0 +1,148 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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: .size) + 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: .size) + 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) + } +} + +#endif diff --git a/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift new file mode 100644 index 00000000..6afd3e61 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ContentServiceHarness.swift @@ -0,0 +1,114 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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: .size, 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: .size, 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 + } +} diff --git a/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift b/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift new file mode 100644 index 00000000..2e6f0afb --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ContentStoreService.swift @@ -0,0 +1,66 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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: \(#function) digest \(digest)") + return try await self.contentStore.get(digest: digest)?.path + } + + @discardableResult + public func delete(digests: [String]) async throws -> ([String], UInt64) { + self.log.debug("ContentStoreService: \(#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: \(#function) digests \(keeping)") + return try await self.contentStore.delete(keeping: keeping) + } + + public func newIngestSession() async throws -> (id: String, ingestDir: URL) { + self.log.debug("ContentStoreService: \(#function)") + return try await self.contentStore.newIngestSession() + } + + public func completeIngestSession(_ id: String) async throws -> [String] { + self.log.debug("ContentStoreService: \(#function) id \(id)") + return try await self.contentStore.completeIngestSession(id) + } + + public func cancelIngestSession(_ id: String) async throws { + self.log.debug("ContentStoreService: \(#function) id \(id)") + return try await self.contentStore.cancelIngestSession(id) + } +} diff --git a/Sources/Services/ContainerImagesService/Server/ImageService.swift b/Sources/Services/ContainerImagesService/Server/ImageService.swift new file mode 100644 index 00000000..1ba2dfe9 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ImageService.swift @@ -0,0 +1,205 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import ContainerImagesServiceClient +import Containerization +import ContainerizationArchive +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import Foundation +import Logging +import TerminalProgress + +public actor ImagesService { + public static let keychainID = "com.apple.container" + + 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.info("ImagesService: \(#function)") + return try await imageStore.list().map { $0.description.fromCZ } + } + + public func pull(reference: String, platform: Platform?, insecure: Bool, progressUpdate: ProgressUpdateHandler?) async throws -> ImageDescription { + self.log.info("ImagesService: \(#function) - ref: \(reference), platform: \(String(describing: platform)), insecure: \(insecure)") + 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)) + } + 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.info("ImagesService: \(#function) - ref: \(reference), platform: \(String(describing: platform)), insecure: \(insecure)") + 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.info("ImagesService: \(#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.info("ImagesService: \(#function) - ref: \(reference)") + try await self.imageStore.delete(reference: reference, performCleanup: garbageCollect) + } + + public func save(reference: String, out: URL, platform: Platform?) async throws { + self.log.info("ImagesService: \(#function) - reference: \(reference) , platform: \(String(describing: platform))") + let tempDir = FileManager.default.uniqueTemporaryDirectory() + defer { + try? FileManager.default.removeItem(at: tempDir) + } + try await self.imageStore.save(references: [reference], 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) async throws -> [ImageDescription] { + self.log.info("ImagesService: \(#function) from: \(tarFile.absolutePath())") + let reader = try ArchiveReader(file: tarFile) + let tempDir = FileManager.default.uniqueTemporaryDirectory() + defer { + try? FileManager.default.removeItem(at: tempDir) + } + try reader.extractContents(to: tempDir) + let loaded = try await self.imageStore.load(from: tempDir) + var images: [ImageDescription] = [] + for image in loaded { + images.append(image.description.fromCZ) + } + return images + } + + public func prune() async throws -> ([String], UInt64) { + let images = try await self._list() + let freedSnapshotBytes = try await self.snapshotStore.clean(keepingSnapshotsFor: images) + let (deleted, freedContentBytes) = try await self.imageStore.prune() + return (deleted, freedContentBytes + freedSnapshotBytes) + } +} + +// MARK: Image Snapshot Methods + +extension ImagesService { + public func unpack(description: ImageDescription, platform: Platform?, progressUpdate: ProgressUpdateHandler?) async throws { + self.log.info("ImagesService: \(#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.info("ImagesService: \(#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.info("ImagesService: \(#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(id: Self.keychainID) + authentication = try? keychain.lookup(domain: host) + do { + return try await body(authentication) + } catch { + guard authentication != nil else { + throw ContainerizationError(.internalError, message: "\(String(describing: error)). No credentials found for host \(host)") + } + throw error + } + } + + 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 00000000..5cac3af8 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/ImagesServiceHarness.swift @@ -0,0 +1,254 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import ContainerImagesServiceClient +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 progressUpdateService = ProgressUpdateService(message: message) + let imageDescription = try await service.pull(reference: ref, platform: platform, insecure: insecure, progressUpdate: progressUpdateService?.handler) + + 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: .imageDescription) + guard let data else { + throw ContainerizationError( + .invalidArgument, + message: "missing image description" + ) + } + let imageDescription = try JSONDecoder().decode(ImageDescription.self, from: data) + + 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(reference: imageDescription.reference, 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) + guard let input else { + throw ContainerizationError( + .invalidArgument, + message: "missing input file path" + ) + } + let images = try await service.load(from: URL(filePath: input)) + let data = try JSONEncoder().encode(images) + let reply = message.reply() + reply.set(key: .imageDescriptions, value: data) + return reply + } + + @Sendable + public func prune(_ message: XPCMessage) async throws -> XPCMessage { + let (deleted, size) = try await service.prune() + let reply = message.reply() + let data = try JSONEncoder().encode(deleted) + reply.set(key: .digests, value: data) + reply.set(key: .size, value: size) + 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 00000000..a5f7e927 --- /dev/null +++ b/Sources/Services/ContainerImagesService/Server/SnapshotStore.swift @@ -0,0 +1,217 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerClient +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import TerminalProgress + +public actor SnapshotStore { + private static let snapshotFileName = "snapshot" + private static let snapshotInfoFileName = "snapshot-info" + private static let ingestDirName = "ingest" + + let path: URL + let fm = FileManager.default + let ingestDir: URL + + public init(path: URL) throws { + let root = path.appendingPathComponent("snapshots") + self.path = root + self.ingestDir = self.path.appendingPathComponent(Self.ingestDirName) + 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)") + } + 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 mount = try await image.unpack(for: platform, at: tempSnapshotPath, progress: ContainerizationProgressAdapter.handler(from: taskUpdateProgress)) + 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 += (try? self.fm.directorySize(dir: unpackedPath)) ?? 0 + 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 + } +} + +extension FileManager { + fileprivate func directorySize(dir: URL) throws -> UInt64 { + var size = 0 + let resourceKeys: [URLResourceKey] = [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey] + let contents = try self.contentsOfDirectory( + at: dir, + includingPropertiesForKeys: resourceKeys) + + for p in contents { + let val = try p.resourceValues(forKeys: [.totalFileAllocatedSizeKey, .fileAllocatedSizeKey]) + size += val.totalFileAllocatedSize ?? val.fileAllocatedSize ?? 0 + } + return UInt64(size) + } +} + +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/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift new file mode 100644 index 00000000..0e74e354 --- /dev/null +++ b/Sources/Services/ContainerNetworkService/AllocationOnlyVmnetNetwork.swift @@ -0,0 +1,85 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerizationExtras +import Foundation +import Logging + +public actor AllocationOnlyVmnetNetwork: Network { + private let log: Logger + private var _state: NetworkState + + /// 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.subnet == nil else { + throw ContainerizationError(.unsupported, message: "subnet assignment is not yet implemented") + } + + self.log = log + self._state = .created(configuration) + } + + public var state: NetworkState { + self._state + } + + public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { + try handler(nil) + } + + public func start() async throws { + guard case .created(let configuration) = _state else { + throw ContainerizationError(.invalidState, message: "cannot start network \(_state.id) in \(_state.state) state") + } + var defaultSubnet = "192.168.64.1/24" + + log.info( + "starting allocation-only network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(NetworkMode.nat.rawValue)", + ] + ) + + if let suite = UserDefaults.init(suiteName: "com.apple.container.defaults") { + // TODO: Make the suiteName a constant defined in ClientDefaults and use that. + // This will need some re-working of dependencies between NetworkService and Client + defaultSubnet = suite.string(forKey: "network.subnet") ?? defaultSubnet + } + + let subnet = try CIDRAddress(defaultSubnet) + let gateway = IPv4Address(fromValue: subnet.lower.value + 1) + self._state = .running(configuration, NetworkStatus(address: subnet.description, gateway: gateway.description)) + log.info( + "started allocation-only network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(configuration.mode)", + "cidr": "\(defaultSubnet)", + ] + ) + } +} diff --git a/Sources/Services/ContainerNetworkService/Attachment.swift b/Sources/Services/ContainerNetworkService/Attachment.swift new file mode 100644 index 00000000..8ba675f2 --- /dev/null +++ b/Sources/Services/ContainerNetworkService/Attachment.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 snapshot of a network interface allocated to 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 subnet CIDR, where the address is the container interface IPv4 address. + public let address: String + /// The IPv4 gateway address. + public let gateway: String + + public init(network: String, hostname: String, address: String, gateway: String) { + self.network = network + self.hostname = hostname + self.address = address + self.gateway = gateway + } +} diff --git a/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift new file mode 100644 index 00000000..45e0afe1 --- /dev/null +++ b/Sources/Services/ContainerNetworkService/AttachmentAllocator.swift @@ -0,0 +1,58 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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.allocator( + lower: lower, + size: size + ) + } + + /// Allocate a network address for a host. + func allocate(hostname: String) async throws -> UInt32 { + guard hostnames[hostname] == nil else { + throw ContainerizationError(.exists, message: "Hostname \(hostname) already exists on the network") + } + let index = try allocator.allocate() + hostnames[hostname] = index + + return index + } + + /// Free an allocated network address by hostname. + func deallocate(hostname: String) async throws { + if let index = hostnames.removeValue(forKey: hostname) { + try allocator.release(index) + } + } + + /// If no addresses are allocated, prevent future allocations and return true. + func disableAllocator() async -> Bool { + allocator.disableAllocator() + } + + /// Retrieve the allocator index for a hostname. + func lookup(hostname: String) async throws -> UInt32? { + hostnames[hostname] + } +} diff --git a/Sources/Services/ContainerNetworkService/Network.swift b/Sources/Services/ContainerNetworkService/Network.swift new file mode 100644 index 00000000..c1a5feaf --- /dev/null +++ b/Sources/Services/ContainerNetworkService/Network.swift @@ -0,0 +1,29 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +/// Defines common characteristics and operations for a network. +public protocol Network: Sendable { + // Contains network attributes while the network is running + var state: NetworkState { 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/ContainerNetworkService/NetworkClient.swift b/Sources/Services/ContainerNetworkService/NetworkClient.swift new file mode 100644 index 00000000..80645e56 --- /dev/null +++ b/Sources/Services/ContainerNetworkService/NetworkClient.swift @@ -0,0 +1,135 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +/// A client for interacting with a single network. +public struct NetworkClient: Sendable { + // FIXME: need more flexibility than a hard-coded constant? + static let label = "com.apple.container.network.container-network-vmnet" + + private var machServiceLabel: String { + "\(Self.label).\(id)" + } + + let id: String + + /// Create a client for a network. + public init(id: String) { + self.id = id + } +} + +// Runtime Methods +extension NetworkClient { + public func state() async throws -> NetworkState { + let request = XPCMessage(route: NetworkRoutes.state.rawValue) + let client = createClient() + defer { client.close() } + + let response = try await client.send(request) + let state = try response.state() + return state + } + + public func allocate(hostname: String) async throws -> (attachment: Attachment, additionalData: XPCMessage?) { + let request = XPCMessage(route: NetworkRoutes.allocate.rawValue) + request.set(key: NetworkKeys.hostname.rawValue, value: hostname) + + let client = createClient() + defer { client.close() } + + let response = try await client.send(request) + let attachment = try response.attachment() + let additionalData = response.additionalData() + return (attachment, additionalData) + } + + public func deallocate(hostname: String) async throws { + let request = XPCMessage(route: NetworkRoutes.deallocate.rawValue) + request.set(key: NetworkKeys.hostname.rawValue, value: hostname) + + let client = createClient() + defer { client.close() } + try await client.send(request) + } + + 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() + defer { client.close() } + + let response = try await client.send(request) + return try response.dataNoCopy(key: NetworkKeys.attachment.rawValue).map { + try JSONDecoder().decode(Attachment.self, from: $0) + } + } + + public func disableAllocator() async throws -> Bool { + let request = XPCMessage(route: NetworkRoutes.disableAllocator.rawValue) + + let client = createClient() + defer { client.close() } + + let response = try await client.send(request) + return try response.allocatorDisabled() + } + + private func createClient() -> XPCClient { + XPCClient(service: machServiceLabel) + } +} + +extension XPCMessage { + func additionalData() -> XPCMessage? { + guard let additionalData = xpc_dictionary_get_dictionary(self.underlying, NetworkKeys.additionalData.rawValue) else { + return nil + } + return XPCMessage(object: additionalData) + } + + func allocatorDisabled() throws -> Bool { + self.bool(key: NetworkKeys.allocatorDisabled.rawValue) + } + + 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) + } + + 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 + } + + func state() throws -> NetworkState { + let data = self.dataNoCopy(key: NetworkKeys.state.rawValue) + guard let data else { + throw ContainerizationError(.invalidArgument, message: "No network snapshot data in message") + } + return try JSONDecoder().decode(NetworkState.self, from: data) + } +} diff --git a/Sources/Services/ContainerNetworkService/NetworkConfiguration.swift b/Sources/Services/ContainerNetworkService/NetworkConfiguration.swift new file mode 100644 index 00000000..0a3f060d --- /dev/null +++ b/Sources/Services/ContainerNetworkService/NetworkConfiguration.swift @@ -0,0 +1,38 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 parameters for network creation. +public struct NetworkConfiguration: Codable, Sendable, Identifiable { + /// A unique identifier for the network + public let id: String + + /// The network type + public let mode: NetworkMode + + /// The preferred CIDR address for the subnet, if specified + public let subnet: String? + + /// Creates a network configuration + public init( + id: String, + mode: NetworkMode, + subnet: String? = nil + ) { + self.id = id + self.mode = mode + self.subnet = subnet + } +} diff --git a/Sources/Services/ContainerNetworkService/NetworkKeys.swift b/Sources/Services/ContainerNetworkService/NetworkKeys.swift new file mode 100644 index 00000000..41e8f399 --- /dev/null +++ b/Sources/Services/ContainerNetworkService/NetworkKeys.swift @@ -0,0 +1,24 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 allocatorDisabled + case attachment + case hostname + case network + case state +} diff --git a/Sources/Services/ContainerNetworkService/NetworkMode.swift b/Sources/Services/ContainerNetworkService/NetworkMode.swift new file mode 100644 index 00000000..16a4e9ab --- /dev/null +++ b/Sources/Services/ContainerNetworkService/NetworkMode.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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" +} + +extension NetworkMode { + public init() { + self = .nat + } + + public init?(_ value: String) { + switch value.lowercased() { + case "nat": self = .nat + default: return nil + } + } +} diff --git a/Sources/Services/ContainerNetworkService/NetworkRoutes.swift b/Sources/Services/ContainerNetworkService/NetworkRoutes.swift new file mode 100644 index 00000000..f3fb95de --- /dev/null +++ b/Sources/Services/ContainerNetworkService/NetworkRoutes.swift @@ -0,0 +1,28 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 state of the network. + case state = "com.apple.container.network/state" + /// Allocates parameters for attaching a sandbox to the network. + case allocate = "com.apple.container.network/allocate" + /// Deallocates parameters for attaching a sandbox to the network. + case deallocate = "com.apple.container.network/deallocate" + /// Disables the allocator if no sandboxes are attached. + case disableAllocator = "com.apple.container.network/disableAllocator" + /// Retrieves the allocation for a hostname. + case lookup = "com.apple.container.network/lookup" +} diff --git a/Sources/Services/ContainerNetworkService/NetworkService.swift b/Sources/Services/ContainerNetworkService/NetworkService.swift new file mode 100644 index 00000000..859cc97e --- /dev/null +++ b/Sources/Services/ContainerNetworkService/NetworkService.swift @@ -0,0 +1,156 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerizationExtras +import Foundation +import Logging + +public actor NetworkService: Sendable { + private let network: any Network + private let log: Logger? + private var allocator: AttachmentAllocator + + /// Set up a network service for the specified network. + public init( + network: any Network, + log: Logger? = nil + ) async throws { + let state = await network.state + guard case .running(_, let status) = state else { + throw ContainerizationError(.invalidState, message: "invalid network state - network \(state.id) must be running") + } + + let subnet = try CIDRAddress(status.address) + + let size = Int(subnet.upper.value - subnet.lower.value - 3) + self.allocator = try AttachmentAllocator(lower: subnet.lower.value + 2, size: size) + self.network = network + self.log = log + } + + @Sendable + public func state(_ message: XPCMessage) async throws -> XPCMessage { + let reply = message.reply() + let state = await network.state + try reply.setState(state) + return reply + } + + @Sendable + public func allocate(_ message: XPCMessage) async throws -> XPCMessage { + let state = await network.state + guard case .running(_, let status) = state else { + throw ContainerizationError(.invalidState, message: "invalid network state - network \(state.id) must be running") + } + + let hostname = try message.hostname() + let index = try await allocator.allocate(hostname: hostname) + let subnet = try CIDRAddress(status.address) + let ip = IPv4Address(fromValue: index) + let attachment = Attachment( + network: state.id, + hostname: hostname, + address: try CIDRAddress(ip, prefixLength: subnet.prefixLength).description, + gateway: status.gateway + ) + log?.info( + "allocated attachment", + metadata: [ + "hostname": "\(hostname)", + "address": "\(attachment.address)", + "gateway": "\(attachment.gateway)", + ]) + let reply = message.reply() + try reply.setAttachment(attachment) + try network.withAdditionalData { + if let additionalData = $0 { + try reply.setAdditionalData(additionalData.underlying) + } + } + return reply + } + + @Sendable + public func deallocate(_ message: XPCMessage) async throws -> XPCMessage { + let hostname = try message.hostname() + try await allocator.deallocate(hostname: hostname) + log?.info("released attachments", metadata: ["hostname": "\(hostname)"]) + return message.reply() + } + + @Sendable + public func lookup(_ message: XPCMessage) async throws -> XPCMessage { + let state = await network.state + guard case .running(_, let status) = state else { + throw ContainerizationError(.invalidState, message: "invalid network state - network \(state.id) must be running") + } + + let hostname = try message.hostname() + let index = try await allocator.lookup(hostname: hostname) + let reply = message.reply() + guard let index else { + return reply + } + + let address = IPv4Address(fromValue: index) + let subnet = try CIDRAddress(status.address) + let attachment = Attachment( + network: state.id, + hostname: hostname, + address: try CIDRAddress(address, prefixLength: subnet.prefixLength).description, + gateway: status.gateway + ) + log?.debug( + "lookup attachment", + metadata: [ + "hostname": "\(hostname)", + "address": "\(address)", + ]) + try reply.setAttachment(attachment) + return reply + } + + @Sendable + public func disableAllocator(_ message: XPCMessage) async throws -> XPCMessage { + let success = await allocator.disableAllocator() + log?.info("attempted allocator disable", metadata: ["success": "\(success)"]) + let reply = message.reply() + reply.setAllocatorDisabled(success) + return reply + } +} + +extension XPCMessage { + fileprivate func setAdditionalData(_ additionalData: xpc_object_t) throws { + xpc_dictionary_set_value(self.underlying, NetworkKeys.additionalData.rawValue, additionalData) + } + + fileprivate func setAllocatorDisabled(_ allocatorDisabled: Bool) { + self.set(key: NetworkKeys.allocatorDisabled.rawValue, value: allocatorDisabled) + } + + fileprivate func setAttachment(_ attachment: Attachment) throws { + let data = try JSONEncoder().encode(attachment) + self.set(key: NetworkKeys.attachment.rawValue, value: data) + } + + fileprivate func setState(_ state: NetworkState) throws { + let data = try JSONEncoder().encode(state) + self.set(key: NetworkKeys.state.rawValue, value: data) + } +} diff --git a/Sources/Services/ContainerNetworkService/NetworkState.swift b/Sources/Services/ContainerNetworkService/NetworkState.swift new file mode 100644 index 00000000..21b9b15a --- /dev/null +++ b/Sources/Services/ContainerNetworkService/NetworkState.swift @@ -0,0 +1,56 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 NetworkStatus: Codable, Sendable { + /// The address allocated for the network if no subnet was specified at + /// creation time; otherwise, the subnet from the configuration. + public let address: String + /// The gateway IPv4 address. + public let gateway: String + + public init( + address: String, + gateway: String + ) { + self.address = address + self.gateway = gateway + } + +} + +/// The configuration and runtime attributes for a network. +public enum NetworkState: Codable, Sendable { + // The network has been configured. + case created(NetworkConfiguration) + // The network is running. + case running(NetworkConfiguration, NetworkStatus) + + public var state: String { + switch self { + case .created: "created" + case .running: "running" + } + } + + public var id: String { + switch self { + case .created(let configuration): configuration.id + case .running(let configuration, _): configuration.id + } + } +} diff --git a/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift b/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift new file mode 100644 index 00000000..2701cd40 --- /dev/null +++ b/Sources/Services/ContainerNetworkService/ReservedVmnetNetwork.swift @@ -0,0 +1,152 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 ContainerizationExtras +import Dispatch +import Foundation +import Logging +import SendableProperty +import SystemConfiguration +import XPC +import vmnet + +#if !CURRENT_SDK +/// Creates a vmnet network with macOS 16 reservation APIs. +@available(macOS 16, *) +public final class ReservedVmnetNetwork: Network { + @SendableProperty + private var _state: NetworkState + private let log: Logger + + @SendableProperty + private var network: vmnet_network_ref? + @SendableProperty + private var interface: interface_ref? + private let networkLock = NSLock() + + /// 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)") + } + + log.info("creating vmnet network") + self.log = log + _state = .created(configuration) + log.info("created vmnet network") + } + + public var state: NetworkState { + get async { _state } + } + + public nonisolated func withAdditionalData(_ handler: (XPCMessage?) throws -> Void) throws { + try networkLock.lock { + try handler(network.map { try Self.serialize_network_ref(ref: $0) }) + } + } + + public func start() async throws { + guard case .created(let configuration) = _state else { + throw ContainerizationError(.invalidArgument, message: "cannot start network that is in \(_state.state) state") + } + + try startNetwork(configuration: configuration, log: log) + } + + 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 { + log.info( + "starting nmos vmnet network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(configuration.mode)", + ] + ) + let suite = UserDefaults.init(suiteName: UserDefaults.appSuiteName) + let subnetText = configuration.subnet ?? suite?.string(forKey: "network.subnet") + + // with the reservation API, subnet priority is CLI argument, UserDefault, auto + let subnet = try subnetText.map { try CIDRAddress($0) } + + // set up the vmnet configuration + var status: vmnet_return_t = .VMNET_SUCCESS + guard let vmnetConfiguration = vmnet_network_configuration_create(vmnet.operating_modes_t.VMNET_SHARED_MODE, &status), status == .VMNET_SUCCESS else { + throw ContainerizationError(.unsupported, message: "failed to create vmnet config with status \(status)") + } + + vmnet_network_configuration_disable_dhcp(vmnetConfiguration) + + // set the subnet if the caller provided one + if let subnet { + let gateway = IPv4Address(fromValue: subnet.lower.value + 1) + var gatewayAddr = in_addr() + inet_pton(AF_INET, gateway.description, &gatewayAddr) + let mask = IPv4Address(fromValue: subnet.prefixLength.prefixMask32) + var maskAddr = in_addr() + inet_pton(AF_INET, mask.description, &maskAddr) + log.info( + "configuring vmnet subnet", + metadata: ["cidr": "\(subnet)"] + ) + let status = vmnet_network_configuration_set_ipv4_subnet(vmnetConfiguration, &gatewayAddr, &maskAddr) + guard status == .VMNET_SUCCESS else { + throw ContainerizationError(.internalError, message: "failed to set subnet \(subnet) for 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)") + } + self.network = network + + // 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(fromValue: subnetValue & maskValue) + let upper = IPv4Address(fromValue: lower.value + ~maskValue) + let runningSubnet = try CIDRAddress(lower: lower, upper: upper) + let runningGateway = IPv4Address(fromValue: runningSubnet.lower.value + 1) + self._state = .running(configuration, NetworkStatus(address: runningSubnet.description, gateway: runningGateway.description)) + log.info( + "started (reservation) nmos vmnet network", + metadata: [ + "id": "\(configuration.id)", + "mode": "\(configuration.mode)", + "cidr": "\(runningSubnet)", + ] + ) + } +} +#endif diff --git a/Sources/Services/ContainerNetworkService/UserDefaults+Backpack.swift b/Sources/Services/ContainerNetworkService/UserDefaults+Backpack.swift new file mode 100644 index 00000000..3bfdd3f8 --- /dev/null +++ b/Sources/Services/ContainerNetworkService/UserDefaults+Backpack.swift @@ -0,0 +1,21 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 UserDefaults { + public static let appSuiteName = "com.apple.container.defaults" +} diff --git a/Sources/Services/ContainerSandboxService/ExitMonitor.swift b/Sources/Services/ContainerSandboxService/ExitMonitor.swift new file mode 100644 index 00000000..0d60e282 --- /dev/null +++ b/Sources/Services/ContainerSandboxService/ExitMonitor.swift @@ -0,0 +1,69 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 Logging + +/// Track when a long running method exits, and notify the caller via a callback. +public actor ExitMonitor { + public typealias ExitCallback = @Sendable (String, Int32) async throws -> Void + public typealias WaitHandler = @Sendable () async throws -> Int32 + + public init(log: Logger? = nil) { + self.log = log + } + + private var exitCallbacks: [String: ExitCallback] = [:] + private var runningTasks: [String: Task] = [:] + private let log: Logger? + + public func stopTracking(id: String) async { + if let task = self.runningTasks[id] { + task.cancel() + } + exitCallbacks.removeValue(forKey: id) + runningTasks.removeValue(forKey: id) + } + + 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 + } + + 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, -1) + } + } + } +} diff --git a/Sources/Services/ContainerSandboxService/InterfaceStrategy.swift b/Sources/Services/ContainerSandboxService/InterfaceStrategy.swift new file mode 100644 index 00000000..d4b8fb09 --- /dev/null +++ b/Sources/Services/ContainerSandboxService/InterfaceStrategy.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +import ContainerNetworkService +import ContainerXPC +import Containerization + +/// Customized interface creation strategy. +public protocol InterfaceStrategy: Sendable { + /// Map a client network attachment request to a network interface specification. + func toInterface(attachment: Attachment, additionalData: XPCMessage?) throws -> Interface +} diff --git a/Sources/Services/ContainerSandboxService/SandboxService.swift b/Sources/Services/ContainerSandboxService/SandboxService.swift new file mode 100644 index 00000000..5f448d86 --- /dev/null +++ b/Sources/Services/ContainerSandboxService/SandboxService.swift @@ -0,0 +1,835 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import ContainerClient +import ContainerNetworkService +import ContainerXPC +import Containerization +import ContainerizationError +import ContainerizationExtras +import ContainerizationOCI +import ContainerizationOS +import Foundation +import Logging + +import struct ContainerizationOCI.Mount +import struct ContainerizationOCI.Process + +public actor SandboxService { + private let root: URL + private let interfaceStrategy: InterfaceStrategy + private var container: ContainerInfo? + private let monitor: ExitMonitor + private var waiters: [String: [CheckedContinuation]] = [:] + private let lock: AsyncLock = AsyncLock() + private let log: Logging.Logger + private var state: State = .created + private var processes: [String: ProcessInfo] = [:] + + public init(root: URL, interfaceStrategy: InterfaceStrategy, log: Logger) { + self.root = root + self.interfaceStrategy = interfaceStrategy + self.log = log + self.monitor = ExitMonitor(log: log) + } + + @Sendable + public func bootstrap(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`bootstrap` xpc handler") + 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 bundle = ContainerClient.Bundle(path: self.root) + try bundle.createLogFile() + + let vmm = VZVirtualMachineManager( + kernel: try bundle.kernel, + initialFilesystem: bundle.initialFilesystem.asMount, + bootlog: bundle.bootlog.path, + logger: self.log + ) + let config = try bundle.configuration + let container = LinuxContainer( + config.id, + rootfs: try bundle.containerRootfs.asMount, + vmm: vmm, + logger: self.log + ) + try await self.configureContainer(container: container, config: config) + + let fqdn: String + if let hostname = config.hostname { + if let suite = UserDefaults.init(suiteName: "com.apple.container.defaults"), + let dnsDomain = suite.string(forKey: "dns.domain"), + !hostname.contains(".") + { + // TODO: Make the suiteName a constant defined in ClientDefaults and use that. + // This will need some re-working of dependencies between SandboxService and Client + fqdn = "\(hostname).\(dnsDomain)." + } else { + fqdn = "\(hostname)." + } + } else { + fqdn = config.id + } + + var attachments: [Attachment] = [] + for index in 0.. XPCMessage { + self.log.info("`start` xpc handler") + return try await self.lock.withLock { _ in + let id = try message.id() + let stdio = message.stdio() + let containerInfo = try await self.getContainer() + let containerId = containerInfo.container.id + let container = containerInfo.container + let bundle = containerInfo.bundle + if id == containerId { + guard await self.state == .booted else { + throw ContainerizationError( + .invalidState, + message: "container expected to be in booted state, got: \(await self.state)" + ) + } + let containerLog = try FileHandle(forWritingTo: bundle.containerLog) + let config = containerInfo.config + 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 + }() + if let h = stdio[0] { + container.stdin = h + } + container.stdout = stdout + if let stderr { + container.stderr = stderr + } + await self.setState(.starting) + do { + try await container.start() + let waitFunc: ExitMonitor.WaitHandler = { + let code = try await container.wait() + return code + } + try await self.monitor.track(id: id, waitingOn: waitFunc) + } catch { + try? await self.cleanupContainer() + await self.setState(.created) + try await self.sendContainerEvent(.containerExit(id: id, exitCode: -1)) + throw error + } + await self.setState(.running) + try await self.sendContainerEvent(.containerStart(id: id)) + } else { + // we are starting a process other than the init process. Check if it exists + guard let processInfo = await self.processes[id] else { + throw ContainerizationError(.notFound, message: "Process with id \(id)") + } + let ociConfig = self.configureProcessConfig(config: processInfo.config) + let stdin: ReaderStream? = { + if let h = stdio[0] { + return h + } + return nil + }() + let process = try await container.exec( + id, + configuration: ociConfig, + stdin: stdin, + stdout: stdio[1], + stderr: stdio[2] + ) + try await self.setUnderlingProcess(id, process) + try await process.start() + let waitFunc: ExitMonitor.WaitHandler = { + try await process.wait() + } + try await self.monitor.track(id: id, waitingOn: waitFunc) + } + return message.reply() + } + } + + private func onContainerExit(id: String, code: Int32) async throws { + self.log.info("init process exited with: \(code)") + + try await self.lock.withLock { [self] _ in + let ctrInfo = try await getContainer() + let ctr = ctrInfo.container + // Did someone explicitly call stop and we're already + // cleaning up? + switch await self.state { + case .stopped(_): + return + default: + break + } + + do { + try await ctr.stop() + } catch { + log.notice("failed to stop sandbox gracefully: \(error)") + } + + do { + try await cleanupContainer() + } catch { + self.log.error("failed to cleanup container: \(error)") + } + await setState(.stopped(code)) + let waiters = await self.waiters[id] ?? [] + for cc in waiters { + cc.resume(returning: code) + } + await self.removeWaiters(for: id) + try await self.sendContainerEvent(.containerExit(id: id, exitCode: Int64(code))) + exit(code) + } + } + + private func configureContainer(container: LinuxContainer, config: ContainerConfiguration) throws { + container.cpus = config.resources.cpus + container.memoryInBytes = config.resources.memoryInBytes + container.rosetta = config.rosetta + container.sysctl = config.sysctls.reduce(into: [String: String]()) { + $0[$1.key] = $1.value + } + + for mount in config.mounts { + if try mount.isSocket() { + let socket = UnixSocketConfiguration( + host: URL(filePath: mount.source), + destination: URL(filePath: mount.destination) + ) + container.sockets.append(socket) + } else { + container.mounts.append(mount.asMount) + } + } + + container.hostname = config.hostname ?? config.id + + if let dns = config.dns { + container.dns = DNS( + nameservers: dns.nameservers, domain: dns.domain, + searchDomains: dns.searchDomains, options: dns.options) + } + + configureInitialProcess(container: container, process: config.initProcess) + } + + private func configureInitialProcess(container: LinuxContainer, process: ProcessConfiguration) { + container.arguments = [process.executable] + process.arguments + container.environment = modifyingEnvironment(process) + container.terminal = process.terminal + container.workingDirectory = process.workingDirectory + container.rlimits = process.rlimits.map { + .init(type: $0.limit, hard: $0.hard, soft: $0.soft) + } + switch process.user { + case .raw(let name): + container.user = .init( + uid: 0, + gid: 0, + umask: nil, + additionalGids: process.supplementalGroups, + username: name + ) + case .id(let uid, let gid): + container.user = .init( + uid: uid, + gid: gid, + umask: nil, + additionalGids: process.supplementalGroups, + username: "" + ) + } + } + + private nonisolated func configureProcessConfig(config: ProcessConfiguration) -> ContainerizationOCI.Process { + var proc = ContainerizationOCI.Process() + proc.args = [config.executable] + config.arguments + proc.env = modifyingEnvironment(config) + proc.terminal = config.terminal + proc.cwd = config.workingDirectory + proc.rlimits = config.rlimits.map { + .init(type: $0.limit, hard: $0.hard, soft: $0.soft) + } + 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 + } + + private nonisolated func modifyingEnvironment(_ config: ProcessConfiguration) -> [String] { + guard config.terminal else { + return config.environment + } + // Prepend the TERM env var. If the user has it specified our value will be overridden. + return ["TERM=xterm"] + config.environment + } + + @Sendable + public func createProcess(_ message: XPCMessage) async throws -> XPCMessage { + log.info("`createProcess` xpc handler") + return try await self.lock.withLock { [self] _ in + switch await self.state { + case .created, .stopped(_), .starting, .stopping: + throw ContainerizationError( + .invalidState, + message: "cannot exec: container is not running" + ) + case .running, .booted: + let id = try message.id() + let config = try message.processConfig() + await self.addNewProcess(id, config) + try await self.monitor.registerProcess( + id: id, + onExit: { id, code in + guard await self.processes[id] != nil else { + throw ContainerizationError(.invalidState, message: "ProcessInfo missing for process \(id)") + } + for cc in await self.waiters[id] ?? [] { + cc.resume(returning: code) + } + await self.removeWaiters(for: id) + try await self.setProcessState(id: id, state: .stopped(code)) + }) + return message.reply() + } + } + } + + /// Return the state for the sandbox and its containers. + @Sendable + public func state(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`state` xpc handler") + var status: RuntimeStatus = .unknown + var networks: [Attachment] = [] + var cs: ContainerSnapshot? + + switch state { + case .created, .stopped(_), .starting, .booted, .stopping: + status = .stopped + 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 all containers inside the sandbox, aborting any processes currently + /// executing inside the container, before stopping the underlying sandbox. + @Sendable + public func stop(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`stop` xpc handler") + let reply = try await self.lock.withLock { [self] _ in + switch await self.state { + case .stopped(_), .created, .stopping: + return message.reply() + case .starting: + throw ContainerizationError( + .invalidState, + message: "cannot stop: container is not running" + ) + case .running, .booted: + let ctr = try await getContainer() + let stopOptions = try message.stopOptions() + do { + try await gracefulStopContainer( + ctr.container, + stopOpts: stopOptions + ) + } catch { + log.notice("failed to stop sandbox gracefully: \(error)") + } + await setState(.stopping) + return message.reply() + } + } + do { + try await cleanupContainer() + } catch { + self.log.error("failed to cleanup container: \(error)") + } + return reply + } + + @Sendable + public func kill(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`kill` xpc handler") + return try await self.lock.withLock { [self] _ in + switch await self.state { + case .created, .stopped, .starting, .booted, .stopping: + throw ContainerizationError( + .invalidState, + message: "cannot kill: container is not running" + ) + case .running: + let ctr = try await getContainer() + let id = try message.id() + 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(Int32(try message.signal())) + return message.reply() + } + + // TODO: fix underying signal value to int64 + try await ctr.container.kill(Int32(try message.signal())) + return message.reply() + } + } + } + + @Sendable + public func resize(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`resize` xpc handler") + return try await self.lock.withLock { [self] _ in + switch await self.state { + case .created, .stopped, .starting, .booted, .stopping: + throw ContainerizationError( + .invalidState, + message: "cannot resize: container is not running" + ) + case .running: + let id = try message.id() + let ctr = try await getContainer() + let width = message.uint64(key: .width) + let height = message.uint64(key: .height) + 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.resize(to: .init(width: UInt16(width), height: UInt16(height))) + return message.reply() + } + + try await ctr.container.resize(to: .init(width: UInt16(width), height: UInt16(height))) + return message.reply() + } + } + } + + @Sendable + public func wait(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`wait` xpc handler") + guard let id = message.string(key: .id) else { + throw ContainerizationError(.invalidArgument, message: "Missing id in wait xpc message") + } + + let cachedCode: Int32? = try await self.lock.withLock { _ in + let ctrInfo = try await self.getContainer() + let ctr = ctrInfo.container + if id == ctr.id { + switch await self.state { + case .stopped(let code): + return code + default: + break + } + } else { + guard let processInfo = await self.processes[id] else { + throw ContainerizationError(.notFound, message: "Process with id \(id)") + } + switch processInfo.state { + case .stopped(let code): + return code + default: + break + } + } + return nil + } + if let cachedCode { + let reply = message.reply() + reply.set(key: .exitCode, value: Int64(cachedCode)) + return reply + } + + let exitCode = await withCheckedContinuation { cc in + // Is this safe since we are in an actor? :( + self.addWaiter(id: id, cont: cc) + } + let reply = message.reply() + reply.set(key: .exitCode, value: Int64(exitCode)) + return reply + } + + @Sendable + public func dial(_ message: XPCMessage) async throws -> XPCMessage { + self.log.info("`dial` xpc handler") + switch self.state { + case .starting, .created, .stopped, .stopping: + throw ContainerizationError( + .invalidState, + message: "cannot dial: container is not running" + ) + case .running, .booted: + let port = message.uint64(key: .port) + 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: .fd, value: fh) + return reply + } + } + + private func getContainer() throws -> ContainerInfo { + guard let container else { + throw ContainerizationError( + .invalidState, + message: "no container found" + ) + } + return container + } + + func gracefulStopContainer(_ lc: LinuxContainer, stopOpts: ContainerStopOptions) async throws { + // 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. + do { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + try await lc.wait() + } + group.addTask { + try await lc.kill(stopOpts.signal) + try await Task.sleep(for: .seconds(stopOpts.timeoutInSeconds)) + try await lc.kill(SIGKILL) + } + try await group.next() + group.cancelAll() + } + } catch {} + // Now actually bring down the vm. + try await lc.stop() + } + + func cleanupContainer() async throws { + // Give back our lovely IP(s) + let containerInfo = try self.getContainer() + for attachment in containerInfo.attachments { + let client = NetworkClient(id: attachment.network) + do { + try await client.deallocate(hostname: attachment.hostname) + } catch { + self.log.error("failed to deallocate hostname \(attachment.hostname) on network \(attachment.network): \(error)") + } + } + } + + private func sendContainerEvent(_ event: ContainerEvent) async throws { + let serviceIdentifier = "com.apple.container.apiserver" + let client = XPCClient(service: serviceIdentifier) + let message = XPCMessage(route: .containerEvent) + + let data = try JSONEncoder().encode(event) + message.set(key: .containerEvent, value: data) + try await client.send(message) + } + +} + +extension XPCMessage { + fileprivate func signal() throws -> Int64 { + self.int64(key: .signal) + } + + fileprivate 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) + } + + fileprivate func setState(_ state: SandboxSnapshot) throws { + let data = try JSONEncoder().encode(state) + self.set(key: .snapshot, value: data) + } + + fileprivate 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 + } + + fileprivate func setFileHandle(_ handle: FileHandle) { + self.set(key: .fd, value: handle) + } + + fileprivate 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) + } +} + +extension ContainerClient.Bundle { + public var containerLog: URL { + path.appendingPathComponent("stdio.log") + } + + func createLogFile() throws { + // Create the log file we'll write stdio to. + let fd = Darwin.open(self.containerLog.path, O_CREAT | O_RDONLY, 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, _, _): + return .block( + format: format, + source: self.source, + destination: self.destination, + options: self.options + ) + } + } + + func isSocket() throws -> Bool { + if !self.isVirtiofs { + return false + } + let info = try File.info(self.source) + return info.isSocket + } +} + +struct MultiWriter: Writer { + let handles: [FileHandle] + + func write(_ data: Data) throws { + for handle in self.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 helpers + +extension SandboxService { + private func addWaiter(id: String, cont: CheckedContinuation) { + var current = self.waiters[id] ?? [] + current.append(cont) + self.waiters[id] = current + } + + private func removeWaiters(for id: String) { + self.waiters[id] = [] + } + + private func setUnderlingProcess(_ 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 addNewProcess(_ id: String, _ config: ProcessConfiguration) { + self.processes[id] = ProcessInfo(config: config, process: nil, state: .created) + } + + private struct ProcessInfo { + let config: ProcessConfiguration + var process: LinuxProcess? + var state: State + } + + private struct ContainerInfo { + let container: LinuxContainer + let config: ContainerConfiguration + let attachments: [Attachment] + let bundle: ContainerClient.Bundle + } + + public enum State: Sendable, Equatable { + case created + case booted + case starting + case running + case stopping + case stopped(Int32) + } + + func setState(_ new: State) { + self.state = new + } +} diff --git a/Sources/TerminalProgress/Int+Formatted.swift b/Sources/TerminalProgress/Int+Formatted.swift new file mode 100644 index 00000000..b29937eb --- /dev/null +++ b/Sources/TerminalProgress/Int+Formatted.swift @@ -0,0 +1,52 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 00000000..db6241e2 --- /dev/null +++ b/Sources/TerminalProgress/Int64+Formatted.swift @@ -0,0 +1,36 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 00000000..953b308d --- /dev/null +++ b/Sources/TerminalProgress/ProgressBar+Add.swift @@ -0,0 +1,191 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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() { + if let totalTasks = state.totalTasks { + // For tasks, we're showing the current task rather then the number of completed tasks. + guard state.tasks > totalTasks else { + return + } + } + if let totalItems = state.totalItems { + guard state.items == totalItems else { + return + } + } + if let totalSize = state.totalSize { + guard state.size == totalSize else { + return + } + } + finish() + } + + /// Sets the current tasks. + /// - Parameter tasks: The current tasks to set. + public func set(tasks newTasks: Int, render: Bool = true) { + state.tasks = newTasks + if render { + self.render() + } + checkIfFinished() + } + + /// Performs an addition to the current tasks. + /// - Parameter tasks: The tasks to add to the current tasks. + public func add(tasks toAdd: Int, render: Bool = true) { + let newTasks = state.tasks + toAdd + set(tasks: newTasks, render: render) + } + + /// Sets the total tasks. + /// - Parameter totalTasks: The total tasks to set. + public func set(totalTasks newTotalTasks: Int, render: Bool = true) { + state.totalTasks = newTotalTasks + if render { + self.render() + } + } + + /// Performs an addition to the total tasks. + /// - Parameter totalTasks: The tasks to add to the total tasks. + public func add(totalTasks toAdd: Int, render: Bool = true) { + let totalTasks = state.totalTasks ?? 0 + let newTotalTasks = totalTasks + toAdd + set(totalTasks: newTotalTasks, render: render) + } + + /// Sets the items name. + /// - Parameter items: The current items to set. + public func set(itemsName newItemsName: String, render: Bool = true) { + state.itemsName = newItemsName + if render { + self.render() + } + } + + /// Sets the current items. + /// - Parameter items: The current items to set. + public func set(items newItems: Int, render: Bool = true) { + state.items = newItems + if render { + self.render() + } + } + + /// Performs an addition to the current items. + /// - Parameter items: The items to add to the current items. + public func add(items toAdd: Int, render: Bool = true) { + let newItems = state.items + toAdd + set(items: newItems, render: render) + } + + /// Sets the total items. + /// - Parameter totalItems: The total items to set. + public func set(totalItems newTotalItems: Int, render: Bool = true) { + state.totalItems = newTotalItems + if render { + self.render() + } + } + + /// Performs an addition to the total items. + /// - Parameter totalItems: The items to add to the total items. + public func add(totalItems toAdd: Int, render: Bool = true) { + let totalItems = state.totalItems ?? 0 + let newTotalItems = totalItems + toAdd + set(totalItems: newTotalItems, render: render) + } + + /// Sets the current size. + /// - Parameter size: The current size to set. + public func set(size newSize: Int64, render: Bool = true) { + state.size = newSize + if render { + self.render() + } + } + + /// Performs an addition to the current size. + /// - Parameter size: The size to add to the current size. + public func add(size toAdd: Int64, render: Bool = true) { + let newSize = state.size + toAdd + set(size: newSize, render: render) + } + + /// Sets the total size. + /// - Parameter totalSize: The total size to set. + public func set(totalSize newTotalSize: Int64, render: Bool = true) { + state.totalSize = newTotalSize + if render { + self.render() + } + } + + /// Performs an addition to the total size. + /// - Parameter totalSize: The size to add to the total size. + public func add(totalSize toAdd: Int64, render: Bool = true) { + let totalSize = state.totalSize ?? 0 + let newTotalSize = totalSize + toAdd + set(totalSize: newTotalSize, render: render) + } +} diff --git a/Sources/TerminalProgress/ProgressBar+State.swift b/Sources/TerminalProgress/ProgressBar+State.swift new file mode 100644 index 00000000..9cc2b42b --- /dev/null +++ b/Sources/TerminalProgress/ProgressBar+State.swift @@ -0,0 +1,98 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 configuration struct for the progress bar. + public struct State { + /// A flag indicating whether the progress bar is finished. + public 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 output = "" + + 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 00000000..75b038d7 --- /dev/null +++ b/Sources/TerminalProgress/ProgressBar+Terminal.swift @@ -0,0 +1,88 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 clearLine = "\u{001B}[2K" + static let moveUp = "\u{001B}[1A" +} + +extension ProgressBar { + /// Clears the progress bar and resets the cursor. + static public func clearAndResetCursor() { + ProgressBar.clear() + ProgressBar.resetCursor() + } + + /// Clears the progress bar. + static public func clear() { + ProgressBar.display(EscapeSequence.clearLine) + } + + /// Resets the cursor. + static public func resetCursor() { + ProgressBar.display(EscapeSequence.showCursor) + } + + static func getTerminal() -> FileHandle? { + let standardError = FileHandle.standardError + let fd = standardError.fileDescriptor + let isATTY = isatty(fd) + return isATTY == 1 ? standardError : nil + } + + static 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") { + guard + let termimalHandle = ProgressBar.term, + let terminal = try? Terminal(descriptor: termimalHandle.fileDescriptor) + else { + return + } + + var text = text + + // Clears previously printed characters if the new string is shorter. + text += String(repeating: " ", count: max(state.output.count - text.count, 0)) + state.output = text + + // Clears previously printed lines. + let terminalWidth = (try? Int(terminal.size.width)) ?? 0 + var lines = "" + if terminalWidth > 0 { + let lineCount = (text.count - 1) / terminalWidth + for _ in 0.. Bool { + state.finished + } + + private func printFullDescription() { + if state.subDescription != "" { + standardError.write("\(state.description) \(state.subDescription)") + } else { + standardError.write(state.description) + } + } + + /// 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.description = description + state.subDescription = "" + if config.disableProgressUpdates { + printFullDescription() + } + + state.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.subDescription = subDescription + if config.disableProgressUpdates { + printFullDescription() + } + } + + private func start(intervalSeconds: TimeInterval) async { + if config.disableProgressUpdates && !state.description.isEmpty { + printFullDescription() + } + + while !self.isFinished() { + let intervalNanoseconds = UInt64(intervalSeconds * 1_000_000_000) + render() + state.iteration += 1 + if (try? await Task.sleep(nanoseconds: intervalNanoseconds)) == nil { + return + } + } + } + + /// Starts an animation of the progress bar. + /// - Parameter intervalSeconds: The time interval between updates in seconds. + public func start(intervalSeconds: TimeInterval = 0.04) { + Task(priority: .utility) { + await start(intervalSeconds: intervalSeconds) + } + } + + /// Finishes the progress bar. + public func finish() { + guard !self.isFinished() else { + return + } + + state.finished = true + if !config.disableProgressUpdates && !config.clearOnFinish { + displayText(state.output, terminating: "\n") + } + + if config.clearOnFinish { + ProgressBar.clearAndResetCursor() + } else { + ProgressBar.resetCursor() + } + } +} + +extension ProgressBar { + private func secondsSinceStart() -> Int { + let timeDifferenceNanoseconds = DispatchTime.now().uptimeNanoseconds - state.startTime.uptimeNanoseconds + let timeDifferenceSeconds = Int(floor(Double(timeDifferenceNanoseconds) / 1_000_000_000)) + return timeDifferenceSeconds + } + + func render() { + guard ProgressBar.term != nil && !config.disableProgressUpdates else { + return + } + let output = draw() + displayText(output) + } + + func draw() -> String { + var components = [String]() + if config.showSpinner && !config.showProgressBar { + let spinnerIcon = config.theme.getSpinnerIcon(state.iteration) + components.append("\(spinnerIcon)") + } + + if config.showTasks, let totalTasks = state.totalTasks { + let tasks = min(state.tasks, totalTasks) + components.append("[\(tasks)/\(totalTasks)]") + } + + if config.showDescription && !state.description.isEmpty { + components.append("\(state.description)") + if !state.subDescription.isEmpty { + components.append("\(state.subDescription)") + } + } + + 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) + + if config.showPercent && total > 0 && allowProgress { + components.append("\(state.percent)") + } + + if config.showProgressBar, total > 0, allowProgress { + let usedWidth = components.joined(separator: " ").count + 45 /* the maximum number of characters we may need */ + let remainingWidth = max(config.width - usedWidth, 1 /* the minumum width of a progress bar */) + let barLength = Int(Int64(remainingWidth) * value / total) + let barPaddingLength = remainingWidth - barLength + let bar = "\(String(repeating: config.theme.bar, count: barLength))\(String(repeating: " ", count: barPaddingLength))" + components.append("|\(bar)|") + } + + var additionalComponents = [String]() + + if config.showItems, state.items > 0 { + var itemsName = "" + if !state.itemsName.isEmpty { + itemsName = " \(state.itemsName)" + } + if let totalItems = state.totalItems { + additionalComponents.append("\(state.items.formattedNumber()) of \(totalItems.formattedNumber())\(itemsName)") + } else { + additionalComponents.append("\(state.items.formattedNumber())\(itemsName)") + } + } + + if state.size > 0 && allowProgress { + var formattedCombinedSize = "" + if 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 + } + } + + var formattedSpeed = "" + if config.showSpeed { + formattedSpeed = "\(state.sizeSpeed ?? state.averageSizeSpeed)" + formattedSpeed = adjustFormattedSize(formattedSpeed) + } + + if config.showSize && config.showSpeed { + additionalComponents.append(formattedCombinedSize) + additionalComponents.append(formattedSpeed) + } else if config.showSize { + additionalComponents.append(formattedCombinedSize) + } else if config.showSpeed { + additionalComponents.append(formattedSpeed) + } + } + + if additionalComponents.count > 0 { + let joinedAdditionalComponents = additionalComponents.joined(separator: ", ") + components.append("(\(joinedAdditionalComponents))") + } + + if config.showTime { + let timeDifferenceSeconds = secondsSinceStart() + let formattedTime = timeDifferenceSeconds.formattedTime() + components.append("[\(formattedTime)]") + } + + 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() + guard !size.contains("."), 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: ".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)" + } +} diff --git a/Sources/TerminalProgress/ProgressConfig.swift b/Sources/TerminalProgress/ProgressConfig.swift new file mode 100644 index 00000000..86825b58 --- /dev/null +++ b/Sources/TerminalProgress/ProgressConfig.swift @@ -0,0 +1,165 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 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 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 reseting the cursor. + public let clearOnFinish: Bool + /// The flag indicating whether to update the progress bar. + public let disableProgressUpdates: Bool + /// Creates a new instance of `ProgressConfig`. + /// - Parameters: + /// - 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 reseting the cursor. The default is `true`. + /// - disableProgressUpdates: The flag indicating whether to update the progress bar. The default is `false`. + public init( + 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 + ) 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.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 + } +} + +extension ProgressConfig { + /// 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 00000000..9d370239 --- /dev/null +++ b/Sources/TerminalProgress/ProgressTaskCoordinator.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 00000000..fc537d40 --- /dev/null +++ b/Sources/TerminalProgress/ProgressTheme.swift @@ -0,0 +1,34 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 icons used to represent a progress bar. + var bar: String { get } +} + +public struct DefaultProgressTheme: ProgressTheme { + public let spinner = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"] + public let bar = "█" +} + +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 00000000..cb5ed920 --- /dev/null +++ b/Sources/TerminalProgress/ProgressUpdate.swift @@ -0,0 +1,41 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT 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 00000000..b7c7756a --- /dev/null +++ b/Sources/TerminalProgress/StandardError.swift @@ -0,0 +1,25 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/CLITests/Subcommands/Build/CLIBuildBase.swift b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift new file mode 100644 index 00000000..05ec3e03 --- /dev/null +++ b/Tests/CLITests/Subcommands/Build/CLIBuildBase.swift @@ -0,0 +1,257 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +/* CLIBuildBase is the base class used for creating builder tests. Subtests classes +// for these tests are nested in extensions of CLIBuildBase so that we can set +// the serialized parallelization attribute across all builder tests. +*/ +@Suite(.serialized) +class TestCLIBuildBase: CLITest { + override init() throws { + try super.init() + + try? builderDelete(force: true) + try builderStart() + try waitForBuilderRunning() + } + + deinit { + try? builderDelete(force: true) + } + + func waitForBuilderRunning() throws { + let buildkitName = "buildkit" + try waitForContainerRunning(buildkitName, 10) + + // exec into buildkit and check if builder-shim is running + var attempt = 3 + while attempt > 0 { + attempt -= 1 + let response = try doExec(name: buildkitName, cmd: ["pidof", "-s", "container-builder-shim"]) + if !response.isEmpty { + // found the init process running + return + } + sleep(1) + } + throw CLIError.executionFailed("failed to wait for container-builder-shim process on \(buildkitName)") + } + + func createTempDir() throws -> URL { + let tempDir = testDir.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return tempDir + } + + func createContext(tempDir: URL, dockerfile: String, context: [FileSystemEntry]? = nil) throws { + let dockerfileBytes = dockerfile.data(using: .utf8)! + try dockerfileBytes.write(to: tempDir.appendingPathComponent("Dockerfile"), options: .atomic) + + let contextDir: URL = tempDir.appendingPathComponent("context").absoluteURL + try FileManager.default.createDirectory(at: contextDir, withIntermediateDirectories: true, attributes: nil) + + if let context { + for entry in context { + try createEntry(entry, contextDir) + } + } + } + + @discardableResult + func build(tag: String, tempDir: URL, args: [String]? = nil) throws -> String { + try buildWithPaths(tag: tag, tempContext: tempDir, tempDockerfileContext: tempDir, args: args) + } + + // buildWithPaths is a helper function for calling build with different paths for the build context and + // the dockerfile path. If both paths are the same, use `build` func above. + @discardableResult + func buildWithPaths(tag: String, tempContext: URL, tempDockerfileContext: URL, args: [String]? = nil) throws -> String { + let contextDir: URL = tempContext.appendingPathComponent("context") + let contextDirPath = contextDir.absoluteURL.path + var buildArgs = [ + "build", + "-f", + tempDockerfileContext.appendingPathComponent("Dockerfile").path, + "-t", + tag, + ] + if let args = args { + for arg in args { + buildArgs.append("--build-arg") + buildArgs.append(arg) + } + } + buildArgs.append(contextDirPath) + + let response = try run(arguments: buildArgs) + if response.status != 0 { + throw CLIError.executionFailed("build failed: stdout=\(response.output) stderr=\(response.error)") + } + + return response.output + } + + 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 + ) + } + + func createEntry(_ entry: FileSystemEntry, _ contextDir: URL) throws { + switch entry { + // last 2 params are uid and gid + case .file(let path, let content, let permissions, _, _): + let fullPath = contextDir.appending(path: path) + // not using .absoluteURL deletes the last component from fullPath + let directory: URL = fullPath.absoluteURL.deletingLastPathComponent() + let contentPath = fullPath.path + + try FileManager.default.createDirectory( + atPath: directory.path, + withIntermediateDirectories: true, + attributes: nil + ) + + switch content { + case .data(let data): + try data.write(to: fullPath) + case .zeroFilled(let size): + let fd = open(contentPath, O_CREAT | O_WRONLY, permissions.rawValue) + if fd == -1 { throw NSError(domain: NSPOSIXErrorDomain, code: Int(errno)) } + defer { close(fd) } + ftruncate(fd, off_t(size)) + } + + // TODO: figure out why this block fails + // try FileManager.default.setAttributes( + // [ + // .posixPermissions: Int(permissions.rawValue), + // .ownerAccountID: uid, + // .groupOwnerAccountID: gid, + // ], + // ofItemAtPath: fullPath.absoluteURL.absoluteString + // ) + + case .directory(let path, let permissions, let uid, let gid): + let fullPath = contextDir.appendingPathComponent(path).absoluteURL + try FileManager.default.createDirectory( + atPath: fullPath.path, + withIntermediateDirectories: true, + attributes: [ + .posixPermissions: Int(permissions.rawValue), + .ownerAccountID: uid, + .groupOwnerAccountID: gid, + ] + ) + + case .symbolicLink(let path, let target, let uid, let gid): + let fullPath = contextDir.appendingPathComponent(path).absoluteURL + let directory: URL = fullPath.deletingLastPathComponent() + try FileManager.default.createDirectory( + atPath: directory.path, + withIntermediateDirectories: true, + attributes: nil + ) + let targetURL = contextDir.appendingPathComponent(target) + try FileManager.default.createSymbolicLink( + atPath: fullPath.path, + withDestinationPath: targetURL.relativePathFrom(from: fullPath) + ) + lchown(fullPath.path, uid, gid) + } + } + + 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) + } + + enum FileEntryContent { + case zeroFilled(size: Int64) + case data(Data) + } + + func builderStart(cpus: Int64 = 2, memoryInGBs: Int64 = 2) throws { + let (_, error, status) = try run(arguments: [ + "builder", + "start", + "-c", + "\(cpus)", + "-m", + "\(memoryInGBs)GB", + ]) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func builderStop() throws { + let (_, error, status) = try run(arguments: [ + "builder", + "stop", + ]) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func builderDelete(force: Bool = false) throws { + let (_, error, status) = try run( + arguments: [ + "builder", + "delete", + force ? "--force" : nil, + ].compactMap { $0 }) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + +} diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift new file mode 100644 index 00000000..b66a3a6d --- /dev/null +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderLifecycleTest.swift @@ -0,0 +1,39 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension TestCLIBuildBase { + class CLIBuilderLifecycleTest: TestCLIBuildBase { + override init() throws {} + @Test func testBuilderStartStopCommand() throws { + #expect(throws: Never.self) { + try builderStart() + try waitForBuilderRunning() + let status = try getContainerStatus("buildkit") + #expect(status == "running", "BuildKit container is not running") + } + #expect(throws: Never.self) { + try builderStop() + let status = try getContainerStatus("buildkit") + #expect(status == "stopped", "BuildKit container is not stopped") + } + } + } +} diff --git a/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift new file mode 100644 index 00000000..279b88d3 --- /dev/null +++ b/Tests/CLITests/Subcommands/Build/CLIBuilderTest.swift @@ -0,0 +1,348 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +extension TestCLIBuildBase { + class CLIBuilderTest: TestCLIBuildBase { + override init() throws { + try super.init() + } + + deinit { + try? builderDelete(force: true) + } + + @Test func testBuildDotFileSucceeds() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM scratch + + ADD emptyFile / + """ + let context: [FileSystemEntry] = [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + let imageName = "registry.local/dot-file:\(UUID().uuidString)" + try self.build(tag: imageName, tempDir: tempDir) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + + @Test func testBuildFromLocalImage() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM scratch + + ADD emptyFile / + """ + let context: [FileSystemEntry] = [ + .file("emptyFile", content: .zeroFilled(size: 0)), + .file(".dockerignore", content: .data(".dockerignore\n".data(using: .utf8)!)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + let imageName = "local-only:\(UUID().uuidString)" + try self.build(tag: imageName, tempDir: tempDir) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + + let newTempDir: URL = try createTempDir() + let newDockerfile: String = + """ + FROM local-only:\(imageName) + """ + let newContext: [FileSystemEntry] = [] + try createContext(tempDir: newTempDir, dockerfile: newDockerfile, context: newContext) + let newImageName = "from-local:\(UUID().uuidString)" + try self.build(tag: newImageName, tempDir: tempDir) + #expect(try self.inspectImage(newImageName) == newImageName, "expected to have successfully built \(newImageName)") + } + + @Test func testBuildScratchAdd() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM scratch + + ADD emptyFile / + """ + let context: [FileSystemEntry] = [.file("emptyFile", content: .zeroFilled(size: 1))] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + let imageName = "regitry.local/scratch-add:\(UUID().uuidString)" + try self.build(tag: imageName, tempDir: tempDir) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + + @Test func testBuildAddAll() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + + ADD . . + + RUN cat emptyFile + RUN cat Test/testempty + """ + let context: [FileSystemEntry] = [ + .directory("Test"), + .file("Test/testempty", content: .zeroFilled(size: 1)), + .file("emptyFile", content: .zeroFilled(size: 1)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + let imageName = "regitry.local/add-all:\(UUID().uuidString)" + try self.build(tag: imageName, tempDir: tempDir) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + + @Test func testBuildNetworkAccess() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + ARG ADDRESS + RUN nc -zv ${ADDRESS%:*} ${ADDRESS##*:} || exit 1 + """ + try createContext(tempDir: tempDir, dockerfile: dockerfile) + let imageName = "regitry.local/build-network-access:\(UUID().uuidString)" + + let proxyEnv = ProcessInfo.processInfo.environment["HTTP_PROXY"] + var address = "8.8.8.8:53" + if let proxyAddr = proxyEnv { + address = String(proxyAddr.trimmingPrefix("http://")) + } + try self.build(tag: imageName, tempDir: tempDir, args: ["ADDRESS=\(address)"]) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + + @Test func testBuildDockerfileKeywords() throws { + let tempDir: URL = try createTempDir() + let dockerfile = + """ + # stage 1 Meta ARG + ARG TAG=3.21 + FROM ghcr.io/apple-uat/test-images/alpine:${TAG} + + # stage 2 RUN + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + RUN echo "Hello, World!" > /hello.txt + + # stage 3 - RUN [] + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + RUN ["sh", "-c", "echo 'Exec form' > /exec.txt"] + + # stage 4 - CMD + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + CMD ["echo", "Exec default"] + + # stage 5 - CMD [] + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + CMD ["echo", "Exec'ing"] + + #stage 6 - LABEL + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + LABEL version="1.0" description="Test image" + + # stage 7 - EXPOSE + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + EXPOSE 8080 + + # stage 8 - ENV + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + ENV MY_ENV=hello + RUN echo $MY_ENV > /env.txt + + # stage 9 - ADD + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + ADD emptyFile / + + # stage 10 - COPY + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + COPY toCopy /toCopy + + # stage 11 - ENTRYPOINT + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + ENTRYPOINT ["echo", "entrypoint!"] + + # stage 12 - VOLUME + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + VOLUME /data + + # stage 13 - USER + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + RUN adduser -D myuser + USER myuser + CMD whoami + + # stage 14 - WORKDIR + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + WORKDIR /app + RUN pwd > /pwd.out + + # stage 15 - ARG + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + ARG MY_VAR=default + RUN echo $MY_VAR > /var.out + + # stage 16 - ONBUILD + # FROM ghcr.io/apple-uat/test-images/alpine:3.21 + # ONBUILD RUN echo "onbuild triggered" > /onbuild.out + + # stage 17 - STOPSIGNAL + # FROM ghcr.io/apple-uat/test-images/alpine:3.21 + # STOPSIGNAL SIGTERM + + # stage 18 - HEALTHCHECK + # FROM ghcr.io/apple-uat/test-images/alpine:3.21 + # HEALTHCHECK CMD echo "healthy" || exit 1 + + # stage 19 - SHELL + # FROM ghcr.io/apple-uat/test-images/alpine:3.21 + # SHELL ["/bin/sh", "-c"] + # RUN echo $0 > /shell.txt + """ + + let context: [FileSystemEntry] = [ + .file("emptyFile", content: .zeroFilled(size: 1)), + .file("toCopy", content: .zeroFilled(size: 1)), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + + let imageName = "regitry.local/dockerfile-keywords:\(UUID().uuidString)" + try self.build(tag: imageName, tempDir: tempDir) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + + @Test func testBuildSymlink() throws { + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + # Test 1: Test basic symlinking + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + + ADD Test1Source Test1Source + ADD Test1Source2 Test1Source2 + + RUN cat Test1Source2/test.yaml + + # Test2: Test symlinks in nested directories + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + + ADD Test2Source Test2Source + ADD Test2Source2 Test2Source2 + + RUN cat Test2Source2/Test/test.txt + + # Test 3: Test symlinks to directories work + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + + ADD Test3Source Test3Source + ADD Test3Source2 Test3Source2 + + RUN cat Test3Source2/Dest/test.txt + """ + let context: [FileSystemEntry] = [ + // test 1 + .directory("Test1Source"), + .directory("Test1Source2"), + .file("Test1Source/test.yaml", content: .zeroFilled(size: 1)), + .symbolicLink("Test1Source2/test.yaml", target: "Test1Source/test.yaml"), + + // test 2 + .directory("Test2Source"), + .directory("Test2Source2"), + .file("Test2Source/Test/Test/test.yaml", content: .zeroFilled(size: 1)), + .symbolicLink("Test2Source2/Test/test.yaml", target: "Test2Source/Test/Test/test.yaml"), + + // test 3 + .directory("Test3Source/Source"), + .directory("Test3Source2"), + .file("Test3Source/Source/test.txt", content: .zeroFilled(size: 1)), + .symbolicLink("Test3Source2/Dest", target: "Test3Source/Source"), + ] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + let imageName = "regitry.local/build-symlinks:\(UUID().uuidString)" + + #expect(throws: Never.self) { + try self.build(tag: imageName, tempDir: tempDir) + } + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + + @Test func testBuildAndRun() throws { + let name: String = "test-build-and-run" + + let tempDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + RUN echo "foobar" > /file + """ + let context: [FileSystemEntry] = [] + try createContext(tempDir: tempDir, dockerfile: dockerfile, context: context) + let imageName = "\(name):latest" + let containerName = "\(name)-container" + try self.build(tag: imageName, tempDir: tempDir) + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + // Check if the image we built is actually in the image store, and can be used. + try self.doLongRun(name: containerName, image: imageName) + defer { + try? self.doStop(name: containerName) + } + var output = try doExec(name: containerName, cmd: ["cat", "/file"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + let expected = "foobar" + try self.doStop(name: containerName) + #expect(output == expected, "expected file contents to be \(expected), instead got \(output)") + } + + @Test func testBuildDifferentPaths() throws { + let dockerfileCtxDir: URL = try createTempDir() + let dockerfile: String = + """ + FROM ghcr.io/apple-uat/test-images/alpine:3.21 + + RUN ls ./ + COPY . /root + + RUN cat /root/Test/test.txt + """ + let dockerfileCtx: [FileSystemEntry] = [ + .directory(".git"), + .file(".git/FETCH", content: .zeroFilled(size: 1)), + ] + try createContext(tempDir: dockerfileCtxDir, dockerfile: dockerfile, context: dockerfileCtx) + + let buildContextDir: URL = try createTempDir() + let buildContext: [FileSystemEntry] = [ + .directory("Test"), + .file("Test/test.txt", content: .zeroFilled(size: 1)), + ] + try createContext(tempDir: buildContextDir, dockerfile: "", context: buildContext) + + let imageName = "regitry.local/build-diff-context:\(UUID().uuidString)" + #expect(throws: Never.self) { + try self.buildWithPaths(tag: imageName, tempContext: buildContextDir, tempDockerfileContext: dockerfileCtxDir) + } + #expect(try self.inspectImage(imageName) == imageName, "expected to have successfully built \(imageName)") + } + } +} diff --git a/Tests/CLITests/Subcommands/Build/CLIRunBase.swift b/Tests/CLITests/Subcommands/Build/CLIRunBase.swift new file mode 100644 index 00000000..c8abb1fd --- /dev/null +++ b/Tests/CLITests/Subcommands/Build/CLIRunBase.swift @@ -0,0 +1,140 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 Testing + +// This test class is not thread safe +class TestCLIRunBase: CLITest, @unchecked Sendable { + var terminal: Terminal! + var containerName: String = UUID().uuidString + + var ContainerImage: String { + fatalError("Subclasses must override this property") + } + + var Interactive: Bool { + false + } + + var Tty: Bool { + false + } + + var Entrypoint: String? { + nil + } + + var Command: [String]? { + nil + } + + var DisableProgressUpdates: Bool { + false + } + + override init() throws { + try super.init() + do { + terminal = try containerStart(self.containerName) + try waitForContainerRunning(self.containerName) + } catch { + throw CLIError.containerRunFailed("failed to setup container \(error)") + } + } + + func containerRun(stdin: [String], findMessage: String) async throws -> Bool { + let stdout = FileHandle(fileDescriptor: terminal.handle.fileDescriptor, closeOnDealloc: false) + let stdoutListenTask = Task { + try await findStdoutOutput(stdout: stdout, findMessage: findMessage) + } + + let timeoutTask = Task { + try await Task.sleep(nanoseconds: 5 * 1_000_000_000) + stdoutListenTask.cancel() + } + + do { + try self.exec(commands: stdin) + let found = try await stdoutListenTask.value + timeoutTask.cancel() + return found + } catch is CancellationError { + throw CLIError.executionFailed("timeout hit") + } catch { + throw error + } + } + + func findStdoutOutput(stdout: FileHandle, findMessage: String) async throws -> Bool { + for try await line in stdout.bytes.lines { + if line.contains(findMessage) && !line.contains("echo") { + return true + } + } + return false + } + + func exec(commands: [String]) throws { + let stdin = FileHandle(fileDescriptor: terminal.handle.fileDescriptor, closeOnDealloc: false) + try commands.forEach { cmd in + let cmdLine = cmd.appending("\n") + guard let cmdNormalized = cmdLine.data(using: .ascii) else { + throw CLIError.invalidInput("shell command \(cmd) is invalid") + } + try stdin.write(contentsOf: cmdNormalized) + } + try stdin.synchronize() + } + + func containerStart(_ name: String) throws -> Terminal { + if name.count == 0 { + throw CLIError.invalidInput("container name cannot be empty") + } + + var arguments = [ + "run", + "--rm", + "--name", + name, + ] + + if Interactive && Tty { + arguments.append("-it") + } else { + if Interactive { arguments.append("-i") } + if Tty { arguments.append("-t") } + } + + if DisableProgressUpdates { + arguments.append("--disable-progress-updates") + } + + if let entrypoint = Entrypoint { + arguments += ["--entrypoint", entrypoint] + } + + arguments.append(ContainerImage) + + if let command = Command { + arguments += command + } + return try runInteractive(arguments: arguments) + } +} diff --git a/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift b/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift new file mode 100644 index 00000000..f48a5f2a --- /dev/null +++ b/Tests/CLITests/Subcommands/Build/TestCLITermIO.swift @@ -0,0 +1,71 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 Testing + +extension TestCLIRunBase { + // This test class is NOT thread safe + class TestCLITermIO: TestCLIRunBase, @unchecked Sendable { + override var ContainerImage: String { + "ghcr.io/apple-uat/test-images/alpine:3.21" + } + + override var Interactive: Bool { + true + } + + override var Tty: Bool { + true + } + + override var Command: [String]? { + ["/bin/sh"] + } + + override var DisableProgressUpdates: Bool { + true + } + + @Test func testTermIODoesNotPanic() async throws { + let uniqMessage = UUID().uuidString + let stdin: [String] = [ + "echo \(uniqMessage)", + "exit", + ] + do { + guard case let statusBefore = try getContainerStatus(containerName), statusBefore == "running" else { + Issue.record("test container is not running") + return + } + let found = try await containerRun(stdin: stdin, findMessage: uniqMessage) + if !found { + Issue.record("did not find stdout line") + return + } + } catch { + Issue.record( + "failed to start test container \(error)" + ) + return + } + } + } + +} diff --git a/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift new file mode 100644 index 00000000..a3b04a94 --- /dev/null +++ b/Tests/CLITests/Subcommands/Containers/TestCLIExec.swift @@ -0,0 +1,40 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +class TestCLIExecCommand: CLITest { + @Test func testCreateExecCommand() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + try doCreate(name: name) + defer { + try? doStop(name: name) + } + try doStart(name: name) + var unameActual = try doExec(name: name, cmd: ["uname"]) + unameActual = unameActual.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(unameActual == "Linux", "expected OS to be Linux, instead got \(unameActual)") + try doStop(name: name) + } catch { + Issue.record("failed to exec in container \(error)") + return + } + } +} diff --git a/Tests/CLITests/Subcommands/Images/TestCLIImages.swift b/Tests/CLITests/Subcommands/Images/TestCLIImages.swift new file mode 100644 index 00000000..0b4893f8 --- /dev/null +++ b/Tests/CLITests/Subcommands/Images/TestCLIImages.swift @@ -0,0 +1,299 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +class TestCLIImagesCommand: CLITest { + struct Image: Codable { + let reference: String + } + + struct InspectOutput: Codable { + let name: String + let variants: [variant] + struct variant: Codable { + let platform: imagePlatform + struct imagePlatform: Codable { + let os: String + let architecture: String + } + } + } + + func doRemoveImages(images: [String]? = nil) throws { + var args = [ + "images", + "rm", + ] + + if let images { + args.append(contentsOf: images) + } else { + args.append("--all") + } + + let (_, error, status) = try run(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func isImagePresent(targetImage: String) throws -> Bool { + let images = try doListImages() + return images.contains(where: { image in + if image.reference == targetImage { + return true + } + return false + }) + } + + func doListImages() throws -> [Image] { + let (output, error, status) = try run(arguments: [ + "images", + "list", + "--format", + "json", + ]) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + + guard let jsonData = output.data(using: .utf8) else { + throw CLIError.invalidOutput("image list output invalid \(output)") + } + + let decoder = JSONDecoder() + return try decoder.decode([Image].self, from: jsonData) + } + + func doInspectImages(image: String) throws -> [InspectOutput] { + let (output, error, status) = try run(arguments: [ + "images", + "inspect", + image, + ]) + + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + + guard let jsonData = output.data(using: .utf8) else { + throw CLIError.invalidOutput("image inspect output invalid \(output)") + } + + let decoder = JSONDecoder() + return try decoder.decode([InspectOutput].self, from: jsonData) + } + + func doImageTag(image: String, newName: String) throws { + let tagArgs = [ + "images", + "tag", + image, + newName, + ] + + let (_, error, status) = try run(arguments: tagArgs) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + +} + +extension TestCLIImagesCommand { + + @Test func testPull() throws { + do { + try doPull(imageName: alpine) + let imagePresent = try isImagePresent(targetImage: alpine) + #expect(imagePresent, "expected to see \(alpine) pulled") + } catch { + Issue.record("failed to pull alpine image \(error)") + return + } + } + + @Test func testPullMulti() throws { + do { + try doPull(imageName: alpine) + try doPull(imageName: busybox) + + let alpinePresent = try isImagePresent(targetImage: alpine) + #expect(alpinePresent, "expected to see \(alpine) pulled") + + let busyPresent = try isImagePresent(targetImage: busybox) + #expect(busyPresent, "expected to see \(busybox) pulled") + } catch { + Issue.record("failed to pull images \(error)") + return + } + } + + @Test func testPullPlatform() throws { + do { + let os = "linux" + let arch = "amd64" + let pullArgs = [ + "--platform", + "\(os)/\(arch)", + ] + + try doPull(imageName: alpine, args: pullArgs) + + let output = try doInspectImages(image: alpine) + #expect(output.count == 1, "expected a single image inspect output, got \(output)") + + var found = false + for v in output[0].variants { + if v.platform.os == os && v.platform.architecture == arch { + found = true + } + } + #expect(found, "expected to find image with os \(os) and architecture \(arch), instead got \(output[0])") + } catch { + Issue.record("failed to pull and inspect image \(error)") + return + } + } + + @Test func testPullRemoveSingle() throws { + do { + try doPull(imageName: alpine) + let imagePulled = try isImagePresent(targetImage: alpine) + #expect(imagePulled, "expected to see image \(alpine) pulled") + + // tag image so we can safely remove later + let alpineTagged = "\(alpine.dropLast("3.21".count))testPullRemoveSingle" + try doImageTag(image: alpine, newName: alpineTagged) + let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") + + try doRemoveImages(images: [alpineTagged]) + let imageRemoved = try !isImagePresent(targetImage: alpineTagged) + #expect(imageRemoved, "expected not to see image \(alpineTagged)") + } catch { + Issue.record("failed to pull and remove image \(error)") + return + } + } + + @Test func testImageTag() throws { + do { + try doPull(imageName: alpine) + let alpineTagged = "\(alpine.dropLast("3.21".count))testImageTag" + try doImageTag(image: alpine, newName: alpineTagged) + let imagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(imagePresent, "expected to see image \(alpineTagged) tagged") + } catch { + Issue.record("failed to pull and tag image \(error)") + return + } + } + + @Test func testImageDefaultRegistry() throws { + do { + let defaultDomain = "ghcr.io" + let imageName = "apple-uat/test-images/alpine:3.21" + defer { + try? doDefaultRegistrySet(domain: "docker.io") + } + try doDefaultRegistrySet(domain: defaultDomain) + try doPull(imageName: imageName, args: ["--platform", "linux/arm64"]) + guard let alpineImageDetails = try doInspectImages(image: imageName).first else { + Issue.record("alpine image not found") + return + } + #expect(alpineImageDetails.name == "\(defaultDomain)/\(imageName)") + + try doImageTag(image: imageName, newName: "username/image-name:mytag") + guard let taggedImage = try doInspectImages(image: "username/image-name:mytag").first else { + Issue.record("Tagged image not found") + return + } + #expect(taggedImage.name == "\(defaultDomain)/username/image-name:mytag") + + let listOutput = try doImageListQuite() + #expect(listOutput.contains("username/image-name:mytag")) + #expect(listOutput.contains(imageName)) + } catch { + Issue.record("failed default registry test") + return + } + } + + @Test func testImageSaveAndLoad() throws { + do { + // 1. pull image + try doPull(imageName: alpine) + + // 2. Tag image so we can safely remove later + let alpineTagged = "\(alpine.dropLast("3.21".count))testImageSaveAndLoad" + try doImageTag(image: alpine, newName: alpineTagged) + let taggedImagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(taggedImagePresent, "expected to see image \(alpineTagged) tagged") + + // 3. save the image as a tarball + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + defer { + try? FileManager.default.removeItem(at: tempDir) + } + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + let saveArgs = [ + "images", + "save", + alpineTagged, + "--output", + tempFile.path(), + ] + let (_, error, status) = try run(arguments: saveArgs) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + + // 4. remove the image through container + try doRemoveImages(images: [alpineTagged]) + + // 5. verify image is no longer present + let imageRemoved = try !isImagePresent(targetImage: alpineTagged) + #expect(imageRemoved, "expected image \(alpineTagged) to be removed") + + // 6. load the tarball + let loadArgs = [ + "images", + "load", + "-i", + tempFile.path(), + ] + let (_, loadErr, loadStatus) = try run(arguments: loadArgs) + if loadStatus != 0 { + throw CLIError.executionFailed("command failed: \(loadErr)") + } + + // 7. verify image is in the list again + let imagePresent = try isImagePresent(targetImage: alpineTagged) + #expect(imagePresent, "expected \(alpineTagged) to be present") + } catch { + Issue.record("failed to save and load image \(error)") + return + } + } +} diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift new file mode 100644 index 00000000..7880b261 --- /dev/null +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunLifecycle.swift @@ -0,0 +1,43 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 + +class TestCLIRunLifecycle: CLITest { + @Test func testRunFailureCleanup() throws { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + + // try to create a container we know will fail + let badArgs: [String] = [ + "--rm", + "--user", + name, + ] + #expect(throws: CLIError.self, "expect container to fail with invalid user") { + try self.doLongRun(name: name, args: badArgs) + } + + // try to create a container with the same name but no user that should succeed + #expect(throws: Never.self, "expected container run to succeed") { + try self.doLongRun(name: name, args: []) + defer { + try? self.doStop(name: name) + } + let _ = try self.doExec(name: name!, cmd: ["date"]) + try self.doStop(name: name) + } + } +} diff --git a/Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift b/Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift new file mode 100644 index 00000000..a8849c0c --- /dev/null +++ b/Tests/CLITests/Subcommands/Run/TestCLIRunOptions.swift @@ -0,0 +1,429 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import ContainerClient +import ContainerizationOS +import Foundation +import Testing + +class TestCLIRunCommand: CLITest { + @Test func testRunCommand() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + try doLongRun(name: name, args: []) + defer { + try? doStop(name: name) + } + let _ = try doExec(name: name, cmd: ["date"]) + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandCWD() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let expectedCWD = "/tmp" + try doLongRun(name: name, args: ["--cwd", expectedCWD]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["pwd"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == expectedCWD, "expected current working directory to be \(expectedCWD), instead got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandEnv() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let envData = "FOO=bar" + try doLongRun(name: name, args: ["--env", envData]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + #expect( + inspectResp.configuration.initProcess.environment.contains(envData), + "environment variable \(envData) not set in container configuration") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandEnvFile() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let envData = "FOO=bar" + let tempFile = FileManager.default.temporaryDirectory.appendingPathComponent("test.env") + guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(envData.utf8)) else { + Issue.record("failed to create temporary file \(tempFile.path())") + return + } + defer { + try? FileManager.default.removeItem(at: tempFile) + } + try doLongRun(name: name, args: ["--env-file", tempFile.path()]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + #expect( + inspectResp.configuration.initProcess.environment.contains(envData), + "environment variable \(envData) not set in container configuration") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUserIDGroupID() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let uid = "10" + let gid = "100" + try doLongRun(name: name, args: ["--uid", uid, "--gid", gid]) + defer { + try? doStop(name: name) + } + + var output = try doExec(name: name, cmd: ["id"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + try #expect(output.contains(Regex("uid=\(uid).*?gid=\(gid).*")), "invalid user/group id, got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUser() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let user = "nobody" + try doLongRun(name: name, args: ["--user", user]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["whoami"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == user, "expected user \(user), got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandCPUs() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let cpus = "2" + try doLongRun(name: name, args: ["--cpus", cpus]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["nproc"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == cpus, "expected \(cpus), instead got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandMemory() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let expectedMBs = 1024 + try doLongRun(name: name, args: ["--memory", "\(expectedMBs)M"]) + defer { + try? doStop(name: name) + } + let inspectResp = try inspectContainer(name) + let actualInBytes = inspectResp.configuration.resources.memoryInBytes + #expect(actualInBytes == expectedMBs.mib(), "expected \(expectedMBs.mib()) bytes, instead got \(actualInBytes) bytes") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandMount() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let targetContainerPath = "/tmp/testmount" + let testData = "hello world" + let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + let tempFile = tempDir.appendingPathComponent(UUID().uuidString) + guard FileManager.default.createFile(atPath: tempFile.path(), contents: Data(testData.utf8)) else { + Issue.record("failed to create temporary file \(tempFile.path())") + return + } + defer { + try? FileManager.default.removeItem(at: tempDir) + } + try doLongRun(name: name, args: ["--mount", "type=virtiofs,source=\(tempDir.path()),target=\(targetContainerPath),readonly"]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(tempFile.lastPathComponent)"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandUnixSocketMount() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let socketPath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + + let socketType = try UnixType(path: socketPath.path, unlinkExisting: true) + let socket = try Socket(type: socketType, closeOnDeinit: true) + try socket.listen() + defer { + try? socket.close() + try? FileManager.default.removeItem(at: socketPath) + } + + try doLongRun( + name: name, + args: ["-v", "\(socketPath.path):/woo"] + ) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["ls", "-alh", "woo"]) + let splitOutput = output.components(separatedBy: .whitespaces) + #expect(splitOutput.count > 0, "expected split output of 'ls -alh' to be at least 1, instead got \(splitOutput.count)") + + let perms = splitOutput[0] + let firstChar = perms[perms.startIndex] + #expect(firstChar == "s", "expected file in guest to be of type socket, instead got '\(firstChar)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandTmpfs() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let targetContainerPath = "/tmp/testtmpfs" + let expectedFilesystem = "tmpfs" + try doLongRun(name: name, args: ["--tmpfs", targetContainerPath]) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["df", targetContainerPath]) + let lines = output.split(separator: "\n") + #expect(lines.count == 2, "expected only two rows of output, instead got \(lines.count)") + let words = lines[1].split(separator: " ") + #expect(words.count > 1, "expected information to contain multiple words, got \(words.count)") + #expect(words[0].lowercased() == expectedFilesystem, "expected filesystem type to be \(expectedFilesystem), instead got \(output)") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandOSArch() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let os = "linux" + let arch = "amd64" + let expectedArch = "x86_64" + try doLongRun(name: name, args: ["--os", os, "--arch", arch]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["uname", "-sm"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + #expect(output == "\(os) \(expectedArch)", "expected container to use '\(os) \(expectedArch)', instead got '\(output)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandVolume() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let targetContainerPath = "/tmp/testvolume" + let testData = "one small step" + let volume = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + try FileManager.default.createDirectory(at: volume, withIntermediateDirectories: true) + let volumeFile = volume.appendingPathComponent(UUID().uuidString) + guard FileManager.default.createFile(atPath: volumeFile.path(), contents: Data(testData.utf8)) else { + Issue.record("failed to create file at \(volumeFile)") + return + } + defer { + try? FileManager.default.removeItem(at: volume) + } + try doLongRun(name: name, args: ["--volume", "\(volume.path):\(targetContainerPath)"]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["cat", "\(targetContainerPath)/\(volumeFile.lastPathComponent)"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + #expect(output == testData, "expected file with content '\(testData)', instead got '\(output)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandCidfile() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let filePath = FileManager.default.temporaryDirectory.appendingPathComponent(UUID().uuidString) + defer { + try? FileManager.default.removeItem(at: filePath) + } + try doLongRun(name: name, args: ["--cidfile", filePath.path()]) + defer { + try? doStop(name: name) + } + let actualID = try String(contentsOf: filePath, encoding: .utf8) + #expect(actualID == name, "expected container ID '\(name!)', instead got '\(actualID)'") + try doStop(name: name) + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandNoDNS() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + try doLongRun(name: name, args: ["--no-dns"]) + defer { + try? doStop(name: name) + } + #expect(throws: (any Error).self) { + try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + } + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandDNS() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let dns = "8.8.8.8" + try doLongRun(name: name, args: ["--dns", dns]) + defer { + try? doStop(name: name) + } + var output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + output = output.trimmingCharacters(in: .whitespacesAndNewlines) + let words = output.split(separator: " ") + #expect(words.count == 2, "expected 'nameserver \(dns)', instead got '\(output)'") + #expect(words[1].lowercased() == dns, "expected 'nameserver \(dns)', instead got '\(output)'") + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandDNSDomain() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let dnsDomain = "example.com" + try doLongRun(name: name, args: ["--dns-domain", dnsDomain]) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + let lines = output.split(separator: "\n") + #expect(lines.count == 2, "expected two lines of info in /etc/resolv.conf, got \(output)") + let words = lines[1].split(separator: " ") + #expect(words.count == 2, "expected 'domain \(dnsDomain)', instead got '\(lines[1])'") + #expect(words[0].lowercased() == "domain", "expected entry to list domain, instead got '\(words[0])'") + #expect(words[1].lowercased() == dnsDomain, "expected '\(dnsDomain)' search domain, instead got '\(words[1])'") + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandDNSSearch() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let dnsSearch = "test.com" + try doLongRun(name: name, args: ["--dns-search", dnsSearch]) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + let lines = output.split(separator: "\n") + #expect(lines.count == 2, "expected two lines of info in /etc/resolv.conf, got \(output)") + let words = lines[1].split(separator: " ") + #expect(words.count == 2, "expected 'search \(dnsSearch)', instead got '\(lines[1])'") + #expect(words[0].lowercased() == "search", "expected entry to list search domains, instead got '\(words[0])'") + #expect(words[1].lowercased() == dnsSearch, "expected '\(dnsSearch)' search domain, instead got '\(words[1])'") + } catch { + Issue.record("failed to run container \(error)") + return + } + } + + @Test func testRunCommandDNSOption() throws { + do { + let name: String! = Test.current?.name.trimmingCharacters(in: ["(", ")"]) + let dnsOption = "debug" + try doLongRun(name: name, args: ["--dns-option", dnsOption]) + defer { + try? doStop(name: name) + } + let output = try doExec(name: name, cmd: ["cat", "/etc/resolv.conf"]) + let lines = output.split(separator: "\n") + #expect(lines.count == 2, "expected two lines of info in /etc/resolv.conf, got \(output)") + let words = lines[1].split(separator: " ") + #expect(words.count == 2, "expected 'opts \(dnsOption)', instead got '\(lines[1])'") + #expect(words[0].lowercased() == "opts", "expected entry to list dns options, instead got '\(words[0])'") + #expect(words[1].lowercased() == dnsOption, "expected option '\(dnsOption)', instead got '\(words[1])'") + } catch { + Issue.record("failed to run container \(error)") + return + } + } +} diff --git a/Tests/CLITests/Utilities/CLITest.swift b/Tests/CLITests/Utilities/CLITest.swift new file mode 100644 index 00000000..e69d1840 --- /dev/null +++ b/Tests/CLITests/Utilities/CLITest.swift @@ -0,0 +1,370 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import ContainerClient +import Containerization +import ContainerizationOS +import Foundation +import Testing + +class CLITest { + init() throws {} + + let testUUID = UUID().uuidString + + var testDir: URL! { + let tempDir = URL(fileURLWithPath: FileManager.default.currentDirectoryPath) + .appendingPathComponent(".clitests") + .appendingPathComponent(testUUID) + try! FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true) + return tempDir + } + + let alpine = "ghcr.io/apple-uat/test-images/alpine:3.21" + let busybox = "ghcr.io/apple-uat/test-images/busybox:1.37" + + let defaultContainerArgs = ["sleep", "infinity"] + + var executablePath: URL { + get throws { + let containerPath = ProcessInfo.processInfo.environment["CONTAINER_CLI_PATH"] + if let containerPath { + return URL(filePath: containerPath) + } + let fileManager = FileManager.default + let currentDir = fileManager.currentDirectoryPath + + let releaseURL = URL(fileURLWithPath: currentDir) + .appendingPathComponent(".build") + .appendingPathComponent("release") + .appendingPathComponent("container") + + let debugURL = URL(fileURLWithPath: currentDir) + .appendingPathComponent(".build") + .appendingPathComponent("debug") + .appendingPathComponent("container") + + let releaseExists = fileManager.fileExists(atPath: releaseURL.path) + let debugExists = fileManager.fileExists(atPath: debugURL.path) + + if releaseExists && debugExists { // choose the latest build + do { + let releaseAttributes = try fileManager.attributesOfItem(atPath: releaseURL.path) + let debugAttributes = try fileManager.attributesOfItem(atPath: debugURL.path) + + if let releaseDate = releaseAttributes[.modificationDate] as? Date, + let debugDate = debugAttributes[.modificationDate] as? Date + { + return (releaseDate > debugDate) ? releaseURL : debugURL + } + } catch { + throw CLIError.binaryAttributesNotFound(error) + } + } else if releaseExists { + return releaseURL + } else if debugExists { + return debugURL + } + // both do not exist + throw CLIError.binaryNotFound + } + } + + func run(arguments: [String], currentDirectory: URL? = nil) throws -> (output: String, error: String, status: Int32) { + let process = Process() + process.executableURL = try executablePath + process.arguments = arguments + if let directory = currentDirectory { + process.currentDirectoryURL = directory + } + + let outputPipe = Pipe() + let errorPipe = Pipe() + process.standardOutput = outputPipe + process.standardError = errorPipe + + do { + try process.run() + process.waitUntilExit() + } catch { + throw CLIError.executionFailed("Failed to run CLI: \(error)") + } + + let outputData = outputPipe.fileHandleForReading.readDataToEndOfFile() + let errorData = errorPipe.fileHandleForReading.readDataToEndOfFile() + let output = String(data: outputData, encoding: .utf8) ?? "" + let error = String(data: errorData, encoding: .utf8) ?? "" + + return (output: output, error: error, status: process.terminationStatus) + } + + func runInteractive(arguments: [String], currentDirectory: URL? = nil) throws -> Terminal { + let process = Process() + process.executableURL = try executablePath + process.arguments = arguments + if let directory = currentDirectory { + process.currentDirectoryURL = directory + } + + do { + let (parent, child) = try Terminal.create() + process.standardInput = child.handle + process.standardOutput = child.handle + process.standardError = child.handle + + try process.run() + return parent + } catch { + fatalError(error.localizedDescription) + } + } + + func waitForContainerRunning(_ name: String, _ totalAttempts: Int64 = 100) throws { + var attempt = 0 + var found = false + while attempt < totalAttempts && !found { + attempt += 1 + let status = try? getContainerStatus(name) + if status == "running" { + found = true + continue + } + sleep(1) + } + if !found { + throw CLIError.containerNotFound(name) + } + } + + enum CLIError: Error { + case executionFailed(String) + case invalidInput(String) + case invalidOutput(String) + case containerNotFound(String) + case containerRunFailed(String) + case binaryNotFound + case binaryAttributesNotFound(Error) + } + + func doLongRun( + name: String, + image: String? = nil, + args: [String]? = nil, + containerArgs: [String]? = nil + ) throws { + var runArgs = [ + "run", + "--rm", + "--name", + name, + "-d", + ] + if let args { + runArgs.append(contentsOf: args) + } + + if let image { + runArgs.append(image) + } else { + runArgs.append(alpine) + } + + if let containerArgs { + runArgs.append(contentsOf: containerArgs) + } else { + runArgs.append(contentsOf: defaultContainerArgs) + } + + let (_, error, status) = try run(arguments: runArgs) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func doExec(name: String, cmd: [String]) throws -> String { + var execArgs = [ + "exec", + name, + ] + execArgs.append(contentsOf: cmd) + let (resp, error, status) = try run(arguments: execArgs) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + return resp + } + + func doStop(name: String, signal: String = "SIGKILL") throws { + let (_, error, status) = try run(arguments: [ + "stop", + "-s", + signal, + name, + ]) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func doCreate(name: String, image: String? = nil, args: [String]? = nil) throws { + let image = image ?? alpine + let args: [String] = args ?? ["sleep", "infinity"] + let (_, error, status) = try run( + arguments: [ + "create", + "--rm", + "--name", + name, + image, + ] + args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func doStart(name: String) throws { + let (_, error, status) = try run(arguments: [ + "start", + name, + ]) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + struct inspectOutput: Codable { + let status: String + let configuration: ContainerConfiguration + } + + func getContainerStatus(_ name: String) throws -> String { + try inspectContainer(name).status + } + + func inspectContainer(_ name: String) throws -> inspectOutput { + let response = try run(arguments: [ + "inspect", + name, + ]) + let cmdStatus = response.status + guard cmdStatus == 0 else { + throw CLIError.executionFailed("container inspect failed: exit \(cmdStatus)") + } + + let output = response.output + guard let jsonData = output.data(using: .utf8) else { + throw CLIError.invalidOutput("container inspect output invalid") + } + + let decoder = JSONDecoder() + + typealias inspectOutputs = [inspectOutput] + + let io = try decoder.decode(inspectOutputs.self, from: jsonData) + guard io.count > 0 else { + throw CLIError.containerNotFound(name) + } + return io[0] + } + + func inspectImage(_ name: String) throws -> String { + let response = try run(arguments: [ + "images", + "inspect", + name, + ]) + let cmdStatus = response.status + guard cmdStatus == 0 else { + throw CLIError.executionFailed("container inspect failed: exit \(cmdStatus)") + } + + let output = response.output + guard let jsonData = output.data(using: .utf8) else { + throw CLIError.invalidOutput("container inspect output invalid") + } + + let decoder = JSONDecoder() + + struct inspectOutput: Codable { + let name: String + } + + typealias inspectOutputs = [inspectOutput] + + let io = try decoder.decode(inspectOutputs.self, from: jsonData) + guard io.count > 0 else { + throw CLIError.containerNotFound(name) + } + return io[0].name + } + + func doPull(imageName: String, args: [String]? = nil) throws { + var pullArgs = [ + "images", + "pull", + ] + if let args { + pullArgs.append(contentsOf: args) + } + pullArgs.append(imageName) + + let (_, error, status) = try run(arguments: pullArgs) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func doImageListQuite() throws -> [String] { + let args = [ + "images", + "list", + "-q", + ] + + let (out, error, status) = try run(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + return out.trimmingCharacters(in: .whitespacesAndNewlines).components(separatedBy: .newlines) + } + + func doDefaultRegistrySet(domain: String) throws { + let args = [ + "registry", + "default", + "set", + domain, + ] + let (_, error, status) = try run(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } + + func doDefaultRegistryUnset() throws { + let args = [ + "registry", + "default", + "unset", + ] + let (_, error, status) = try run(arguments: args) + if status != 0 { + throw CLIError.executionFailed("command failed: \(error)") + } + } +} diff --git a/Tests/ContainerBuildTests/BuilderExtensionsTests.swift b/Tests/ContainerBuildTests/BuilderExtensionsTests.swift new file mode 100644 index 00000000..befd7405 --- /dev/null +++ b/Tests/ContainerBuildTests/BuilderExtensionsTests.swift @@ -0,0 +1,350 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 testParentOfRelativePaths() throws { + let absoluteChildDir = baseTempURL.appendingPathComponent("someDir") + try createDirectory(at: absoluteChildDir) + let relativeSelfURL = URL(fileURLWithPath: "a/relative/path") + #expect(relativeSelfURL.parentOf(absoluteChildDir)) + let potentiallyParentRelative = URL(fileURLWithPath: baseTempURL.lastPathComponent) + #expect(potentiallyParentRelative.parentOf(absoluteChildDir)) + } + + // MARK: - relativeChildPath Tests + + @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 00000000..24850382 --- /dev/null +++ b/Tests/ContainerBuildTests/GlobberTests.swift @@ -0,0 +1,207 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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), instaed 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/ContainerClientTests/HostDNSResolverTest.swift b/Tests/ContainerClientTests/HostDNSResolverTest.swift new file mode 100644 index 00000000..61abcf38 --- /dev/null +++ b/Tests/ContainerClientTests/HostDNSResolverTest.swift @@ -0,0 +1,132 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 Testing + +@testable import ContainerClient + +struct HostDNSResolverTest { + @Test + func testHostDNSCreate() 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 resolver = HostDNSResolver(configURL: tempURL) + try resolver.createDomain(name: "foo.bar") + let resolverConfigURL = tempURL.appending(path: "containerization.foo.bar") + let actualText = try String(contentsOf: resolverConfigURL, encoding: .utf8) + let expectedText = """ + domain foo.bar + search foo.bar + nameserver 127.0.0.1 + port 2053 + """ + + #expect(actualText == expectedText) + + try resolver.createDomain(name: "bar.foo") + let domains = resolver.listDomains() + #expect(domains == ["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 + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let resolver = HostDNSResolver(configURL: tempURL) + try resolver.createDomain(name: "foo.bar") + #expect { + try resolver.createDomain(name: "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 + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let resolver = HostDNSResolver(configURL: tempURL) + try resolver.createDomain(name: "foo.bar") + try resolver.deleteDomain(name: "foo.bar") + 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 + ) + defer { try? FileManager.default.removeItem(at: tempURL) } + + let resolver = HostDNSResolver(configURL: tempURL) + try resolver.createDomain(name: "foo.bar") + #expect { + try resolver.deleteDomain(name: "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/ContainerPluginTests/MockPluginFactory.swift b/Tests/ContainerPluginTests/MockPluginFactory.swift new file mode 100644 index 00000000..e738cc01 --- /dev/null +++ b/Tests/ContainerPluginTests/MockPluginFactory.swift @@ -0,0 +1,51 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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] + } +} diff --git a/Tests/ContainerPluginTests/PluginConfigTest.swift b/Tests/ContainerPluginTests/PluginConfigTest.swift new file mode 100644 index 00000000..b069151c --- /dev/null +++ b/Tests/ContainerPluginTests/PluginConfigTest.swift @@ -0,0 +1,91 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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.json") + let configJson = """ + { + "abstract" : "Default network management service", + "author": "Apple" + } + """ + try configJson.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.json") + let configJson = """ + { + "abstract" : "Default network management service", + "author": "Apple", + "servicesConfig" : { + "loadAtBoot" : true, + "runAtLoad" : true, + "defaultArguments" : ["start"], + "services" : [ + { + "type" : "network", + "description": "foo" + } + ] + } + } + """ + try configJson.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"]) + } +} diff --git a/Tests/ContainerPluginTests/PluginFactoryTest.swift b/Tests/ContainerPluginTests/PluginFactoryTest.swift new file mode 100644 index 00000000..1675f0eb --- /dev/null +++ b/Tests/ContainerPluginTests/PluginFactoryTest.swift @@ -0,0 +1,171 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 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.json + let configURL = tempURL.appending(path: "config.json") + let configJson = """ + { + "abstract" : "Default network management 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() + 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 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() + 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.json + let configURL = tempURL.appending(path: "config.json") + let configJson = """ + { + "abstract" : "Default network management service", + "author": "Apple" + } + """ + try configJson.write(to: configURL, atomically: true, encoding: .utf8) + + let factory = DefaultPluginFactory() + 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.json + let configURL = + installURL + .appending(path: "Contents") + .appending(path: "Resources") + .appending(path: "config.json") + let configJson = """ + { + "abstract" : "Default network management service", + "author": "Apple" + } + """ + try fm.createDirectory(at: configURL.deletingLastPathComponent(), withIntermediateDirectories: true) + try configJson.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() + 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")) + } +} diff --git a/Tests/ContainerPluginTests/PluginLoaderTest.swift b/Tests/ContainerPluginTests/PluginLoaderTest.swift new file mode 100644 index 00000000..8d30fcd5 --- /dev/null +++ b/Tests/ContainerPluginTests/PluginLoaderTest.swift @@ -0,0 +1,72 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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 = PluginLoader( + pluginDirectories: [tempURL], + pluginFactories: [factory], defaultResourcePath: tempURL) + 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 = PluginLoader( + pluginDirectories: [tempURL], + pluginFactories: [factory], defaultResourcePath: tempURL + ) + + #expect(loader.findPlugin(name: "cli")?.name == "cli") + #expect(loader.findPlugin(name: "service")?.name == "service") + #expect(loader.findPlugin(name: "throw") == nil) + } + + 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 00000000..17dc02b7 --- /dev/null +++ b/Tests/ContainerPluginTests/PluginTest.swift @@ -0,0 +1,134 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES 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/DNSServerTests/CompositeResolverTest.swift b/Tests/DNSServerTests/CompositeResolverTest.swift new file mode 100644 index 00000000..de7a50b4 --- /dev/null +++ b/Tests/DNSServerTests/CompositeResolverTest.swift @@ -0,0 +1,68 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import DNS +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(IPv4("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(IPv4("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 00000000..0b766360 --- /dev/null +++ b/Tests/DNSServerTests/HostTableResolverTest.swift @@ -0,0 +1,90 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import DNS +import Testing + +@testable import DNSServer + +struct HostTableResolverTest { + @Test func testUnsupportedQuestionType() async throws { + guard let ip = IPv4("1.2.3.4") else { + throw DNSResolverError.serverError("cannot create IP address in test") + } + let handler = 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) + + #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 { + guard let ip = IPv4("1.2.3.4") else { + throw DNSResolverError.serverError("cannot create IP address in test") + } + let handler = 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 { + guard let ip = IPv4("1.2.3.4") else { + throw DNSResolverError.serverError("cannot create IP address in test") + } + let handler = 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(IPv4("1.2.3.4") == answer?.ip) + } +} diff --git a/Tests/DNSServerTests/MockHandlers.swift b/Tests/DNSServerTests/MockHandlers.swift new file mode 100644 index 00000000..04aac09f --- /dev/null +++ b/Tests/DNSServerTests/MockHandlers.swift @@ -0,0 +1,59 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import DNS +import Testing + +@testable import DNSServer + +struct FooHandler: DNSHandler { + public func answer(query: Message) async throws -> Message? { + if query.questions[0].name == "foo" { + guard let ip = IPv4("1.2.3.4") else { + throw DNSResolverError.serverError("cannot create IP address in test") + } + 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" { + guard let ip = IPv4("5.6.7.8") else { + throw DNSResolverError.serverError("cannot create IP address in test") + } + 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 00000000..73479617 --- /dev/null +++ b/Tests/DNSServerTests/NxDomainResolverTest.swift @@ -0,0 +1,62 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import DNS +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/StandardQueryValidatorTest.swift b/Tests/DNSServerTests/StandardQueryValidatorTest.swift new file mode 100644 index 00000000..d89b6cf8 --- /dev/null +++ b/Tests/DNSServerTests/StandardQueryValidatorTest.swift @@ -0,0 +1,118 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import DNS +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 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(IPv4("1.2.3.4") == answer?.ip) + } +} diff --git a/Tests/TerminalProgressTests/ProgressBarTests.swift b/Tests/TerminalProgressTests/ProgressBarTests.swift new file mode 100644 index 00000000..a1eb9b3f --- /dev/null +++ b/Tests/TerminalProgressTests/ProgressBarTests.swift @@ -0,0 +1,552 @@ +//===----------------------------------------------------------------------===// +// Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// https://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +//===----------------------------------------------------------------------===// + +// + +import XCTest + +@testable import TerminalProgress + +final class ProgressBarTests: XCTestCase { + func testSpinner() async throws { + let config = try ProgressConfig( + description: "Task" + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testNoSpinner() async throws { + let config = try ProgressConfig( + description: "Task", + showSpinner: false + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "Task [0s]") + } + + func testNoTasks() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: false + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testTasks() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testTasksAdd() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true + ) + let progress = ProgressBar(config: config) + progress.add(tasks: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testTasksSet() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true + ) + let progress = ProgressBar(config: config) + progress.set(tasks: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testTotalTasks() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true, + totalTasks: 2 + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ [0/2] Task [0s]") + } + + func testTotalTasksAdd() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true, + totalTasks: 1 + ) + let progress = ProgressBar(config: config) + progress.add(totalTasks: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ [0/2] Task [0s]") + } + + func testTotalTasksSet() async throws { + let config = try ProgressConfig( + description: "Task", + showTasks: true, + totalTasks: 1 + ) + let progress = ProgressBar(config: config) + progress.set(totalTasks: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ [0/2] Task [0s]") + } + + func testTotalTasksInvalid() throws { + do { + let _ = try ProgressConfig(description: "test", totalTasks: 0) + } catch ProgressConfig.Error.invalid(_) { + return + } + XCTFail("expected ProgressConfig.Error.invalid") + } + + func testDescription() async throws { + let config = try ProgressConfig( + description: "Task" + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testNoDescription() async throws { + let config = try ProgressConfig() + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ [0s]") + } + + func testNoPercent() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: false, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testPercentHidden() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: true + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testPercentItems() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: true, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% [0s]") + } + + func testPercentSize() async throws { + let config = try ProgressConfig( + description: "Task", + showPercent: true, + showSize: false, + showSpeed: false, + totalSize: 2 + ) + let progress = ProgressBar(config: config) + progress.set(size: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% [0s]") + } + + func testNoProgressBar() async throws { + let config = try ProgressConfig( + description: "Task", + showProgressBar: false, + totalItems: 2, + width: 57 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% [0s]") + } + + func testProgressBar() async throws { + let config = try ProgressConfig( + description: "Task", + showProgressBar: true, + totalItems: 2, + width: 57 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "Task 50% |██ | [0s]") + } + + func testProgressBarMinWidth() async throws { + let config = try ProgressConfig( + description: "Task", + showProgressBar: true, + totalItems: 2, + width: 13 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "Task 50% | | [0s]") + } + + func testNoItems() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: false + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testItemsZero() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testItemsAdd() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true + ) + let progress = ProgressBar(config: config) + progress.add(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task (1 it) [0s]") + } + + func testItemsSet() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true + ) + let progress = ProgressBar(config: config) + progress.set(items: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task (2 it) [0s]") + } + + func testTotalItemsZeroItems() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + totalItems: 1 + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 0% [0s]") + } + + func testTotalItems() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it) [0s]") + } + + func testTotalItemsAdd() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + totalItems: 1 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.add(totalItems: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it) [0s]") + } + + func testTotalItemsSet() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + totalItems: 1 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.set(totalItems: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it) [0s]") + } + + func testTotalItemsInvalid() throws { + do { + let _ = try ProgressConfig(description: "test", totalItems: 0) + } catch ProgressConfig.Error.invalid(_) { + return + } + XCTFail("expected ProgressConfig.Error.invalid") + } + + func testNoSize() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: false + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testSizeZero() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testSizeAdd() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false + ) + let progress = ProgressBar(config: config) + progress.add(size: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task (1 byte) [0s]") + } + + func testSizeSet() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task (2 bytes) [0s]") + } + + func testTotalSizeZeroSize() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + totalSize: 1 + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 0% [0s]") + } + + func testTotalSizeDifferentUnits() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 2 + ) + let progress = ProgressBar(config: config) + progress.set(size: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (1 byte/2 bytes) [0s]") + } + + func testTotalSizeSameUnits() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (2/4 bytes) [0s]") + } + + func testTotalSizeAdd() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 3 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + progress.add(totalSize: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (2/4 bytes) [0s]") + } + + func testTotalSizeSet() async throws { + let config = try ProgressConfig( + description: "Task", + showSize: true, + showSpeed: false, + totalSize: 3 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + progress.set(totalSize: 4) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (2/4 bytes) [0s]") + } + + func testTotalSizeInvalid() throws { + do { + let _ = try ProgressConfig(description: "test", totalSize: 0) + } catch ProgressConfig.Error.invalid(_) { + return + } + XCTFail("expected ProgressConfig.Error.invalid") + } + + func testItemsAndSize() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + showSize: true, + showSpeed: false, + totalItems: 2, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.set(size: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (1 of 2 it, 2/4 bytes) [0s]") + } + + func testNoSpeed() async throws { + let config = try ProgressConfig( + description: "Task", + showSpeed: false, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (2/4 bytes) [0s]") + } + + func testSpeed() async throws { + let config = try ProgressConfig( + description: "Task", + showSpeed: true, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + let output = progress.draw() + XCTAssertTrue(output.contains("/s")) + } + + func testItemsSizeAndSpeed() async throws { + let config = try ProgressConfig( + description: "Task", + showItems: true, + showSize: true, + showSpeed: true, + totalItems: 2, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + progress.set(size: 2) + let output = progress.draw() + XCTAssertTrue(output.contains("1 of 2 it, 2/4 bytes")) + XCTAssertTrue(output.contains("/s")) + } + + func testNoTime() async throws { + let config = try ProgressConfig( + description: "Task", + showTime: false + ) + let progress = ProgressBar(config: config) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task") + } + + func testTime() async throws { + let config = try ProgressConfig( + description: "Task", + showTime: true + ) + let progress = ProgressBar(config: config) + sleep(1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [1s]") + } + + func testIgnoreSmallSize() async throws { + let config = try ProgressConfig( + description: "Task", + ignoreSmallSize: true, + totalSize: 4 + ) + let progress = ProgressBar(config: config) + progress.set(size: 2) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task [0s]") + } + + func testItemsName() async throws { + let config = try ProgressConfig( + description: "Task", + itemsName: "files", + showItems: true, + totalItems: 2 + ) + let progress = ProgressBar(config: config) + progress.set(items: 1) + let output = progress.draw() + XCTAssertEqual(output, "⠋ Task 50% (1 of 2 files) [0s]") + } +} diff --git a/config/container-core-images-config.json b/config/container-core-images-config.json new file mode 100644 index 00000000..755c2656 --- /dev/null +++ b/config/container-core-images-config.json @@ -0,0 +1,16 @@ +{ + "abstract" : "Core image management plugin", + "version": "0.1", + "author": "Apple", + "servicesConfig" : { + "loadAtBoot" : true, + "runAtLoad" : false, + "services" : [ + { + "type" : "core", + "description": "Provide an XPC interface to interact with an image store." + } + ], + "defaultArguments": ["start"] + } +} diff --git a/config/container-network-vmnet-config.json b/config/container-network-vmnet-config.json new file mode 100644 index 00000000..8f377d6b --- /dev/null +++ b/config/container-network-vmnet-config.json @@ -0,0 +1,15 @@ +{ + "abstract" : "vmnet network management plugin", + "version": "0.1", + "author": "Apple", + "servicesConfig" : { + "loadAtBoot" : false, + "runAtLoad" : true, + "services" : [ + { + "type" : "network" + } + ], + "defaultArguments": [] + } +} diff --git a/config/container-runtime-linux-config.json b/config/container-runtime-linux-config.json new file mode 100644 index 00000000..624c622b --- /dev/null +++ b/config/container-runtime-linux-config.json @@ -0,0 +1,15 @@ +{ + "abstract" : "Linux container runtime plugin", + "version": "0.1", + "author": "Apple", + "servicesConfig" : { + "loadAtBoot" : false, + "runAtLoad" : true, + "services" : [ + { + "type" : "runtime" + } + ], + "defaultArguments": [] + } +} diff --git a/docs/assets/landing-movie.gif b/docs/assets/landing-movie.gif new file mode 100644 index 00000000..ecd7ee35 Binary files /dev/null and b/docs/assets/landing-movie.gif differ diff --git a/docs/assets/logo.jpg b/docs/assets/logo.jpg new file mode 100644 index 00000000..f4924827 Binary files /dev/null and b/docs/assets/logo.jpg differ diff --git a/docs/localSwiftContainerization.md b/docs/localSwiftContainerization.md new file mode 100644 index 00000000..6b50413b --- /dev/null +++ b/docs/localSwiftContainerization.md @@ -0,0 +1,65 @@ +# Develop using a local copy of Containerization + +This page describes how to build and run container using a local copy of [`Containerization`](https://github.com/apple-uat/containerization). + +## Use the local copy of containerization + +1. Clone the [Containerization](https://github.com/apple-uat/containerization) repository such that it sits next to your clone of the `container` repository. + +2. In your development shell, go to the `container` project directory. + + ``` + cd container + ``` + +3. If the application services are already running, stop them. + + ``` + bin/container system stop + ``` + +4. Configure the environment variable `CONTAINERIZATION_PATH` to refer to your Containerization project, and update your `Package.resolved` file. + + ``` + export CONTAINERIZATION_PATH=../containerization + swift package update containerization + ``` + +5. Build the init filesystem for your local copy of containerization. + + ``` + (cd ../swiftcontainerization && make clean all) + ``` + +6. Build `container`. + + ``` + make clean all + ``` + +7. Start the application services. + + ``` + bin/container system start + ``` + +## Revert to the versioned Containerization package + +1. Unset your `CONTAINERIZATION_PATH` environment variable, and update `Package.resolved`. + + ``` + unset CONTAINERIZATION_PATH + swift package update containerization + ``` + +2. Rebuild `container`. + + ``` + make clean all + ``` + +3. Restart application services. + + ``` + bin/container system restart + ``` diff --git a/licenserc.toml b/licenserc.toml new file mode 100644 index 00000000..0fe46b77 --- /dev/null +++ b/licenserc.toml @@ -0,0 +1,25 @@ +additionalHeaders = ["scripts/container-header-style.toml"] + +headerPath = "scripts/license-header.txt" + +includes = [ + "Makefile", + "*.Makefile", + "*.swift", + "*.h", + "*.cpp", + "*.c", + "*.sh", +] + +excludes = [] + +[git] +attrs = 'enable' +ignore = 'enable' + +[properties] +copyrightOwner = "Apple Inc. and the container project authors" + +[mapping.SWIFT_STYLE] +extensions = ["swift"] diff --git a/scripts/container-header-style.toml b/scripts/container-header-style.toml new file mode 100644 index 00000000..ccbb6a79 --- /dev/null +++ b/scripts/container-header-style.toml @@ -0,0 +1,11 @@ +[SWIFT_STYLE] +firstLine = '//===----------------------------------------------------------------------===//' +endLine = "//===----------------------------------------------------------------------===//\n" +beforeEachLine = '// ' +afterEachLine = '' +allowBlankLines = false +multipleLines = true +padLines = false +firstLineDetectionPattern = '//\s?===' +lastLineDetectionPattern = '//\s?===' +skipLinePattern = '// swift-tools-version' diff --git a/scripts/ensure-container-stopped.sh b/scripts/ensure-container-stopped.sh new file mode 100755 index 00000000..cdf4222b --- /dev/null +++ b/scripts/ensure-container-stopped.sh @@ -0,0 +1,31 @@ +#! /bin/bash -f +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +domain_string="" + +launchd_domain=$(launchctl managername) + +if [[ "$launchd_domain" == "System" ]]; then + domain_string="system" +elif [[ "$launchd_domain" == "Aqua" ]]; then + domain_string="gui/$(id -u)" +elif [[ "$launchd_domain" == "Background" ]]; then + domain_string="user/$(id -u)" +else + echo "Unsupported launchd domain. Exiting" + exit 1 +fi + +launchctl list | grep -e 'com\.apple\.container\W' | awk '{print $3}' | xargs -I % launchctl bootout $domain_string/% diff --git a/scripts/ensure-hawkeye-exists.sh b/scripts/ensure-hawkeye-exists.sh new file mode 100755 index 00000000..02e3e73b --- /dev/null +++ b/scripts/ensure-hawkeye-exists.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +echo "Checking existence of hawkeye..." + +if command -v .local/bin/hawkeye >/dev/null 2>&1; then + echo "hawkeye found!" +else + echo "hawkeye not found in PATH" + echo "please install hawkeye. For convenience, you can run scripts/install-hawkeye.sh" + exit 1 +fi diff --git a/scripts/install-hawkeye.sh b/scripts/install-hawkeye.sh new file mode 100755 index 00000000..79aaef52 --- /dev/null +++ b/scripts/install-hawkeye.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT 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 command -v .local/bin/hawkeye >/dev/null 2>&1; then + echo "hawkeye already installed" +else + echo "Installing hawkeye" + export VERSION=v6.0.4 + curl --proto '=https' --tlsv1.2 -LsSf https://github.com/korandoru/hawkeye/releases/download/${VERSION}/hawkeye-installer.sh | CARGO_HOME=.local sh -s -- --no-modify-path +fi diff --git a/scripts/install-init.sh b/scripts/install-init.sh new file mode 100755 index 00000000..6cdd3f56 --- /dev/null +++ b/scripts/install-init.sh @@ -0,0 +1,31 @@ +#! /bin/bash -e +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +DESTDIR="${1:-$(git rev-parse --show-toplevel)/bin}" +mkdir -p "${DESTDIR}" + +IMAGE_NAME="vminit:latest" + +CONTAINERIZATION_VERSION="${CONTAINERIZATION_VERSION:-$(swift package show-dependencies --format json | jq -r '.dependencies[] | select(.identity == "containerization") | .version')}" +if [ ! -z "${CONTAINERIZATION_PATH}" -o "${CONTAINERIZATION_VERSION}" == "unspecified" ] ; then + CONTAINERIZATION_PATH="${CONTAINERIZATION_PATH:-$(swift package show-dependencies --format json | jq -r '.dependencies[] | select(.identity == "containerization") | .path')}" + echo "Creating InitImage" + make -C ${CONTAINERIZATION_PATH} init + ${CONTAINERIZATION_PATH}/bin/cctl images save -o /tmp/init.tar ${IMAGE_NAME} + # sleep because commands after stop and start are racy + bin/container system stop && sleep 3 && bin/container system start && sleep 3 + bin/container i load -i /tmp/init.tar + rm /tmp/init.tar +fi diff --git a/scripts/license-header.txt b/scripts/license-header.txt new file mode 100644 index 00000000..b26825ef --- /dev/null +++ b/scripts/license-header.txt @@ -0,0 +1,13 @@ +Copyright ©{{ " " }}{%- if attrs.git_file_modified_year != attrs.git_file_created_year -%}{{ attrs.git_file_created_year }}-{{ attrs.git_file_modified_year }}{%- else -%}{{ attrs.git_file_created_year }}{%- endif -%}{{ " " }}{{ props["copyrightOwner"] }}. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + https://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. \ No newline at end of file diff --git a/scripts/make-docs.sh b/scripts/make-docs.sh new file mode 100755 index 00000000..fca2eaf2 --- /dev/null +++ b/scripts/make-docs.sh @@ -0,0 +1,45 @@ +#! /bin/bash -e +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +opts=() +if [ ! -z "${CURRENT_SDK}" ] ; then + opts+=("-Xswiftc" "-DCURRENT_SDK") +fi +opts+=("--allow-writing-to-directory" "$1") +opts+=("generate-documentation") +opts+=("--target" "Build") +opts+=("--target" "Client") +opts+=("--target" "DNSServer") +opts+=("--target" "ImagesService") +opts+=("--target" "LogSupport") +opts+=("--target" "NetworkService") +opts+=("--target" "Persistence") +opts+=("--target" "Plugin") +opts+=("--target" "SandboxService") +opts+=("--target" "TerminalProgress") +opts+=("--target" "XPCSupport") +opts+=("--output-path" "$1") +opts+=("--disable-indexing") +opts+=("--transform-for-static-hosting") +opts+=("--enable-experimental-combined-documentation") +opts+=("--experimental-documentation-coverage") + +if [ ! -z "$2" ] ; then + opts+=("--hosting-base-path" "$2") +fi + +/usr/bin/swift package ${opts[@]} + +echo '{}' > "$1/theme-settings.json" diff --git a/scripts/uninstall-container.sh b/scripts/uninstall-container.sh new file mode 100755 index 00000000..f9c789bd --- /dev/null +++ b/scripts/uninstall-container.sh @@ -0,0 +1,83 @@ +#!/bin/bash +# Copyright © 2025 Apple Inc. and the container project authors. All rights reserved. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +set -uo pipefail + +INSTALL_DIR="/usr/local" +DELETE_DATA= +OPTS=0 + +usage() { + echo "Usage: $0 {-d | -k}" + echo "Uninstall container" + echo + echo "Options:" + echo "d Delete user data directory." + echo "k Don't delete user data directory." + echo + exit 1 +} + +while getopts ":dk" arg; do + case "$arg" in + d) + DELETE_DATA=true + ((OPTS+=1)) + ;; + k) + DELETE_DATA=false + ((OPTS+=1)) + ;; + *) + echo "Invalid option: -${OPTARG}" + usage + ;; + esac +done + +if [ $OPTS != 1 ]; then + echo "Invalid number of options. Must provide either -d OR -k" + exit 1 +fi + +# check if container is still running +CONTAINER_RUNNING=$(launchctl list | grep -e 'com\.apple\.container\W') +if [ -n "$CONTAINER_RUNNING" ]; then + echo '`container` is still running. Please ensure the service is stopped by running `container system stop`' + exit 1 +fi + +FILES=$(pkgutil --only-files --files com.apple.container-installer) +for i in ${FILES[@]}; do + # this command can fail for some of the reported files from pkgutil such as + # `/usr/local/bin/._uninstall-container.sh`` + sudo rm $INSTALL_DIR/$i &> /dev/null +done + + +DIRS=($(pkgutil --only-dirs --files com.apple.container-installer)) +for ((i=${#DIRS[@]}-1; i>=0; i--)); do + # this command will fail when trying to remove `bin` and `libexec` since those directories + # may not be empty + sudo rmdir $INSTALL_DIR/${DIRS[$i]} &> /dev/null +done + +sudo pkgutil --forget com.apple.container-installer > /dev/null +echo 'Removed `container` application' + +if [ "$DELETE_DATA" = true ]; then + echo 'Removing `container` user data' + sudo rm -rf ~/Library/Application\ Support/com.apple.container +fi diff --git a/signing/container-network-vmnet.entitlements b/signing/container-network-vmnet.entitlements new file mode 100644 index 00000000..d7d0d6e8 --- /dev/null +++ b/signing/container-network-vmnet.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.virtualization + + + diff --git a/signing/container-runtime-linux.entitlements b/signing/container-runtime-linux.entitlements new file mode 100644 index 00000000..d7d0d6e8 --- /dev/null +++ b/signing/container-runtime-linux.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.security.virtualization + + +