Initial commit

Co-authored-by: Aditya Ramani <a_ramani@apple.com>
Co-authored-by: Agam Dua <agam_dua@apple.com>
Co-authored-by: Danny Canter <danny_canter@apple.com>
Co-authored-by: Dmitry Kovba <dkovba@apple.com>
Co-authored-by: Eric Ernst <eric_ernst@apple.com>
Co-authored-by: Evan Hazlett <ehazlett@apple.com>
Co-authored-by: Gilbert Song <gilbertsong@apple.com>
Co-authored-by: Hugh Bussell <hbussell@apple.com>
Co-authored-by: John Logan <john_logan@apple.com>
Co-authored-by: Kathryn Baldauf <k_baldauf@apple.com>
Co-authored-by: Madhu Venugopal <mvenugopal@apple.com>
Co-authored-by: Michael Crosby <michael_crosby@apple.com>
Co-authored-by: Sidhartha Mani <sidhartha_mani@apple.com>
Co-authored-by: Tanweer Noor <tnoor@apple.com>
Co-authored-by: Ximena Perez Diaz <xperez528@gmail.com>
Co-authored-by: Yibo Zhuang <yzhuang@apple.com>
This commit is contained in:
Kathryn Baldauf
2025-06-05 15:51:55 -07:00
co-authored by Aditya Ramani Agam Dua Danny Canter Dmitry Kovba Eric Ernst Evan Hazlett Gilbert Song Hugh Bussell John Logan Madhu Venugopal Michael Crosby Sidhartha Mani Tanweer Noor Ximena Perez Diaz Yibo Zhuang
commit 8e9670c8f8
225 changed files with 27705 additions and 0 deletions
+20
View File
@@ -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
+77
View File
@@ -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
+42
View File
@@ -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
+26
View File
@@ -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
+68
View File
@@ -0,0 +1,68 @@
{
"fileScopedDeclarationPrivacy" : {
"accessLevel" : "private"
},
"indentation" : {
"spaces" : 4
},
"indentConditionalCompilationBlocks" : false,
"indentSwitchCaseLabels" : false,
"lineBreakAroundMultilineExpressionChainComponents" : false,
"lineBreakBeforeControlFlowKeywords" : false,
"lineBreakBeforeEachArgument" : false,
"lineBreakBeforeEachGenericRequirement" : false,
"lineLength" : 180,
"maximumBlankLines" : 1,
"multiElementCollectionTrailingCommas" : true,
"noAssignmentInExpressions" : {
"allowedFunctions" : [
"XCTAssertNoThrow"
]
},
"prioritizeKeepingFunctionOutputTogether" : false,
"respectsExistingLineBreaks" : true,
"rules" : {
"AllPublicDeclarationsHaveDocumentation" : false,
"AlwaysUseLowerCamelCase" : true,
"AmbiguousTrailingClosureOverload" : false,
"BeginDocumentationCommentWithOneLineSummary" : false,
"DoNotUseSemicolons" : true,
"DontRepeatTypeInStaticProperties" : true,
"FileScopedDeclarationPrivacy" : true,
"FullyIndirectEnum" : true,
"GroupNumericLiterals" : true,
"IdentifiersMustBeASCII" : true,
"NeverForceUnwrap" : true,
"NeverUseForceTry" : true,
"NeverUseImplicitlyUnwrappedOptionals" : true,
"NoAccessLevelOnExtensionDeclaration" : true,
"NoAssignmentInExpressions" : true,
"NoBlockComments" : false,
"NoCasesWithOnlyFallthrough" : true,
"NoEmptyTrailingClosureParentheses" : true,
"NoLabelsInCasePatterns" : true,
"NoLeadingUnderscores" : false,
"NoParensAroundConditions" : true,
"NoPlaygroundLiterals" : true,
"NoVoidReturnOnFunctionSignature" : true,
"OmitExplicitReturns" : true,
"OneCasePerLine" : true,
"OneVariableDeclarationPerLine" : true,
"OnlyOneTrailingClosureArgument" : true,
"OrderedImports" : true,
"ReplaceForEachWithForLoop" : true,
"ReturnVoidInsteadOfEmptyTuple" : true,
"TypeNamesShouldBeCapitalized" : true,
"UseEarlyExits" : true,
"UseLetInEveryBoundCaseVariable" : true,
"UseShorthandTypeNames" : true,
"UseSingleLinePropertyGetter" : true,
"UseSynthesizedInitializer" : true,
"UseTripleSlashForDocumentationComments" : true,
"UseWhereClausesInForLoops" : false,
"ValidateDocumentationComments" : true
},
"spacesAroundRangeFormationOperators" : false,
"tabWidth" : 2,
"version" : 1
}
+77
View File
@@ -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 projects 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
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+191
View File
@@ -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
+258
View File
@@ -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
}
+319
View File
@@ -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)\""),
]
),
]
)
+59
View File
@@ -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."
+696
View File
@@ -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] <subcommand>
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 '<!DOCTYPE html><html><head><title>Hello</title></head><body><p><img src="logo.jpg" style="width: 2rem; height: 2rem;">Hello, world!</p></body></html>' > 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
<!DOCTYPE html><html><head><title>Hello</title></head><body><p><img src="logo.jpg" style="width: 2rem; height: 2rem;">Hello, world!</p></body></html>
%
```
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
<!DOCTYPE html><html><head><title>Hello</title></head><body><p><img src="logo.jpg" style="width: 2rem; height: 2rem;">Hello, world!</p></body></html>
% 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.
+11
View File
@@ -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.
+237
View File
@@ -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"
}
}
@@ -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<IPv4>(name: question.name, ttl: ttl, ip: ip)
}
}
@@ -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
}
}
@@ -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
)
}
}
}
@@ -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()
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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)
}
}
@@ -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()
}
}
@@ -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<NetworkConfiguration>
private let pluginLoader: PluginLoader
private let log: Logger
private let networkPlugin: Plugin
private var networkStates = [String: NetworkState]()
private var busyNetworks = Set<String>()
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<NetworkConfiguration>(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
)
}
}
@@ -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
}
}
@@ -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)"
}
}
}
}
+296
View File
@@ -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)"
}
}
+313
View File
@@ -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)")
}
}
}
}
+31
View File
@@ -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,
])
}
}
+57
View File
@@ -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
}
}
}
}
+267
View File
@@ -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)")
}
}
}
+71
View File
@@ -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
}
}
}
}
+49
View File
@@ -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
}
}
}
}
+25
View File
@@ -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: ","))]"
}
}
+104
View File
@@ -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)
}
}
}
+127
View File
@@ -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<String>(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)")
}
}
}
}
+100
View File
@@ -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)
}
}
}
@@ -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())
}
}
}
+79
View File
@@ -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<String>(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")
}
}
}
}
+110
View File
@@ -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
}
}
+139
View File
@@ -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<String> { 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)
}
}
}
}
@@ -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)
}
}
}
+102
View File
@@ -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<String>(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
}
}
}
@@ -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"]
)
}
}
+31
View File
@@ -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")
}
}
}
+54
View File
@@ -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)
}
}
+53
View File
@@ -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"))")
}
}
}
}
+175
View File
@@ -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 ?? "<none>",
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 ?? "<none>",
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)
}
}
}
+76
View File
@@ -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)
}
}
}
}
+38
View File
@@ -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")
}
}
}
+84
View File
@@ -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()
}
}
}
+63
View File
@@ -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()
}
}
}
+99
View File
@@ -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)
}
}
}
+67
View File
@@ -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")
}
}
}
+42
View File
@@ -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)")
}
}
}
+38
View File
@@ -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"]
)
}
}
+85
View File
@@ -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")
}
}
}
+39
View File
@@ -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)
}
}
}
@@ -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"]
)
}
}
@@ -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))
}
}
}
+275
View File
@@ -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
)
}
}
+51
View File
@@ -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")
}
}
}
}
+72
View File
@@ -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) ?? "")
}
}
}
}
+49
View File
@@ -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")
}
}
}
}
+36
View File
@@ -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"))
}
}
}
+113
View File
@@ -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()
}
}
}
+35
View File
@@ -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"]
)
}
}
+34
View File
@@ -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,
]
)
}
}
+29
View File
@@ -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
]
)
}
}
+82
View File
@@ -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)
}
}
}
+51
View File
@@ -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)"
)
}
}
}
}
+168
View File
@@ -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"
}
}
+91
View File
@@ -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..<Self.shutdownTimeoutSeconds {
let anyRunning = try await ClientContainer.list()
.contains { $0.status == .running }
guard anyRunning else {
break
}
try await Task.sleep(for: .seconds(1))
}
} catch {
log.warning("failed to wait for all containers", metadata: ["error": "\(error)"])
}
log.info("stopping service", metadata: ["label": "\(fullLabel)"])
try ServiceManager.deregister(fullServiceLabel: fullLabel)
// Note: The assumption here is that we would have registered the launchd services
// in the same domain as `launchdDomainString`. This is a fairly sane assumption since
// if somehow the launchd domain changed, XPC interactions would not be possible.
try ServiceManager.enumerate()
.filter { $0.hasPrefix(prefix) }
.filter { $0 != fullLabel }
.map { "\(launchdDomainString)/\($0)" }
.forEach {
log.info("stopping service", metadata: ["label": "\($0)"])
try? ServiceManager.deregister(fullServiceLabel: $0)
}
}
}
}
+44
View File
@@ -0,0 +1,44 @@
/*
* 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.
*/
//===----------------------------------------------------------------------===//
//
// This source file is part of the container open source project
//
// Copyright (c) 2025 Apple Inc. and the container project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.md for the list of container project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
//
#include "Version.h"
const char* get_git_commit() {
return GIT_COMMIT;
}
const char* get_release_version() {
return RELEASE_VERSION;
}
const char* get_swift_containerization_version() {
return CZ_VERSION;
}
+47
View File
@@ -0,0 +1,47 @@
/*
* 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.
*/
//===----------------------------------------------------------------------===//
//
// This source file is part of the container open source project
//
// Copyright (c) 2025 Apple Inc. and the container project authors
// Licensed under Apache License v2.0
//
// See LICENSE.txt for license information
// See CONTRIBUTORS.md for the list of container project authors
//
// SPDX-License-Identifier: Apache-2.0
//
//===----------------------------------------------------------------------===//
#ifndef CZ_VERSION
#define CZ_VERSION "latest"
#endif
#ifndef GIT_COMMIT
#define GIT_COMMIT "unspecified"
#endif
#ifndef RELEASE_VERSION
#define RELEASE_VERSION "0.0.0"
#endif
const char* get_git_commit();
const char* get_release_version();
const char* get_swift_containerization_version();
@@ -0,0 +1,154 @@
//===----------------------------------------------------------------------===//
// 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 typealias IO = Com_Apple_Container_Build_V1_IO
public typealias InfoRequest = Com_Apple_Container_Build_V1_InfoRequest
public typealias InfoResponse = Com_Apple_Container_Build_V1_InfoResponse
public typealias ClientStream = Com_Apple_Container_Build_V1_ClientStream
public typealias ServerStream = Com_Apple_Container_Build_V1_ServerStream
public typealias ImageTransfer = Com_Apple_Container_Build_V1_ImageTransfer
public typealias BuildTransfer = Com_Apple_Container_Build_V1_BuildTransfer
public typealias BuilderClient = Com_Apple_Container_Build_V1_BuilderNIOClient
public typealias BuilderClientAsync = Com_Apple_Container_Build_V1_BuilderAsyncClient
public typealias BuilderClientProtocol = Com_Apple_Container_Build_V1_BuilderClientProtocol
public typealias BuilderClientAsyncProtocol = Com_Apple_Container_Build_V1_BuilderAsyncClient
extension BuildTransfer {
func stage() -> String? {
let stage = self.metadata["stage"]
return stage == "" ? nil : stage
}
func method() -> String? {
let method = self.metadata["method"]
return method == "" ? nil : method
}
func includePatterns() -> [String]? {
guard let includePatternsString = self.metadata["include-patterns"] else {
return nil
}
return includePatternsString == "" ? nil : includePatternsString.components(separatedBy: ",")
}
func followPaths() -> [String]? {
guard let followPathString = self.metadata["followpaths"] else {
return nil
}
return followPathString == "" ? nil : followPathString.components(separatedBy: ",")
}
func mode() -> String? {
self.metadata["mode"]
}
func size() -> Int? {
guard let sizeStr = self.metadata["size"] else {
return nil
}
return sizeStr == "" ? nil : Int(sizeStr)
}
func offset() -> UInt64? {
guard let offsetStr = self.metadata["offset"] else {
return nil
}
return offsetStr == "" ? nil : UInt64(offsetStr)
}
func len() -> Int? {
guard let lenStr = self.metadata["length"] else {
return nil
}
return lenStr == "" ? nil : Int(lenStr)
}
}
extension ImageTransfer {
func stage() -> String? {
self.metadata["stage"]
}
func method() -> String? {
self.metadata["method"]
}
func ref() -> String? {
self.metadata["ref"]
}
func platform() throws -> Platform? {
let metadata = self.metadata
guard let platform = metadata["platform"] else {
return nil
}
return try Platform(from: platform)
}
func mode() -> String? {
self.metadata["mode"]
}
func size() -> Int? {
let metadata = self.metadata
guard let sizeStr = metadata["size"] else {
return nil
}
return Int(sizeStr)
}
func len() -> Int? {
let metadata = self.metadata
guard let lenStr = metadata["length"] else {
return nil
}
return Int(lenStr)
}
func offset() -> UInt64? {
let metadata = self.metadata
guard let offsetStr = metadata["offset"] else {
return nil
}
return UInt64(offsetStr)
}
}
extension ServerStream {
func getImageTransfer() -> ImageTransfer? {
if case .imageTransfer(let v) = self.packetType {
return v
}
return nil
}
func getBuildTransfer() -> BuildTransfer? {
if case .buildTransfer(let v) = self.packetType {
return v
}
return nil
}
func getIO() -> IO? {
if case .io(let v) = self.packetType {
return v
}
return nil
}
}
+129
View File
@@ -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<ClientStream>.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<ClientStream>.Continuation, ServerStream), Swift.Error>.Continuation?
let writeStream: AsyncThrowingStream<(AsyncStream<ClientStream>.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<ClientStream>.Continuation, _ packet: ServerStream) async throws {
self.channel.yield((sender, packet)) // guarantees ordering while being non-blocking
}
func write(_ sender: AsyncStream<ClientStream>.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"
}
}
}
}
+498
View File
@@ -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<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let buildTransfer = packet.getBuildTransfer() else {
throw Error.buildTransferMissing
}
guard let method = buildTransfer.method() else {
throw Error.methodMissing
}
switch try FSSyncMethod(method) {
case .read:
try await self.read(sender, buildTransfer, packet.buildID)
case .info:
try await self.info(sender, buildTransfer, packet.buildID)
case .walk:
try await self.walk(sender, buildTransfer, packet.buildID)
}
}
func read(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: BuildTransfer, _ buildID: String) async throws {
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
var path: URL = 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<ClientStream>.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<ClientStream>.Continuation,
_ packet: BuildTransfer,
_ buildID: String
) async throws {
let wantsTar = packet.mode() == "tar"
var entries: [String: Set<DirEntry>] = [:]
let followPaths: [String] = packet.followPaths() ?? []
let followPathsWalked = try walk(root: self.contextDir, includePatterns: followPaths)
for url in followPathsWalked {
guard self.contextDir.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<DirEntry>],
processedPaths: inout [String]
) throws {
guard let entries = inputEntries[currentDir] else {
return
}
// Sort purely by lexicographical order of relativePath
let sortedEntries = entries.sorted { $0.relativePath < $1.relativePath }
for entry in sortedEntries {
processedPaths.append(entry.relativePath)
if entry.isDirectory {
try processDirectory(
entry.relativePath,
inputEntries: inputEntries,
processedPaths: &processedPaths
)
}
}
}
struct FileInfo: Codable {
let name: String
let modTime: String
let mode: UInt32
let size: UInt64
let isDir: Bool
let uid: UInt32
let gid: UInt32
let target: String
init(path: URL, contextDir: URL) throws {
if path.isSymlink {
let target: URL = path.resolvingSymlinksInPath()
if contextDir.parentOf(target) {
self.target = target.relativePathFrom(from: path)
} else {
self.target = target.cleanPath
}
} else {
self.target = ""
}
self.name = try path.relativeChildPath(to: contextDir)
self.modTime = try path.modTime()
self.mode = try path.mode()
self.size = try path.size()
self.isDir = path.hasDirectoryPath
self.uid = 0
self.gid = 0
}
}
enum FSSyncMethod: String {
case read = "Read"
case info = "Info"
case walk = "Walk"
init(_ method: String) throws {
switch method {
case "Read":
self = .read
case "Info":
self = .info
case "Walk":
self = .walk
default:
throw Error.unknownMethod(method)
}
}
}
}
extension BuildFSSync {
enum Error: Swift.Error, CustomStringConvertible, Equatable {
case buildTransferMissing
case methodMissing
case unknownMethod(String)
case contextNotFound(String)
case contextIsNotDirectory(String)
case couldNotDetermineFileSize(String)
case couldNotDetermineModTime(String)
case couldNotDetermineFileMode(String)
case invalidOffsetSizeForFile(String, UInt64, Int)
case couldNotDetermineUID(String)
case couldNotDetermineGID(String)
case pathIsNotChild(String, String)
var description: String {
switch self {
case .buildTransferMissing:
return "buildTransfer field missing in packet"
case .methodMissing:
return "method is missing in request"
case .unknownMethod(let m):
return "unknown content-store method \(m)"
case .contextNotFound(let path):
return "context dir \(path) not found"
case .contextIsNotDirectory(let path):
return "context \(path) not a directory"
case .couldNotDetermineFileSize(let path):
return "could not determine size of file \(path)"
case .couldNotDetermineModTime(let path):
return "could not determine last modified time of \(path)"
case .couldNotDetermineFileMode(let path):
return "could not determine posix permissions (FileMode) of \(path)"
case .invalidOffsetSizeForFile(let digest, let offset, let size):
return "invalid request for file: \(digest) with offset: \(offset) size: \(size)"
case .couldNotDetermineUID(let path):
return "could not determine UID of file at path: \(path)"
case .couldNotDetermineGID(let path):
return "could not determine GID of file at path: \(path)"
case .pathIsNotChild(let path, let parent):
return "\(path) is not a child of \(parent)"
}
}
}
}
extension BuildTransfer {
fileprivate init(id: String, source: String, complete: Bool, isDir: Bool, metadata: [String: String], data: Data? = nil) {
self.init()
self.id = id
self.source = source
self.direction = .outof
self.complete = complete
self.metadata = metadata
self.isDirectory = isDir
if let data {
self.data = data
}
}
}
extension URL {
fileprivate func size() throws -> UInt64 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let size = attrs[FileAttributeKey.size] as? UInt64 {
return size
}
throw BuildFSSync.Error.couldNotDetermineFileSize(self.cleanPath)
}
fileprivate func modTime() throws -> String {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let date = attrs[FileAttributeKey.modificationDate] as? Date {
return date.rfc3339()
}
throw BuildFSSync.Error.couldNotDetermineModTime(self.cleanPath)
}
fileprivate func isDir() throws -> Bool {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
guard let t = attrs[.type] as? FileAttributeType, t == .typeDirectory else {
return false
}
return true
}
fileprivate func mode() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let mode = attrs[FileAttributeKey.posixPermissions] as? NSNumber {
return mode.uint32Value
}
throw BuildFSSync.Error.couldNotDetermineFileMode(self.cleanPath)
}
fileprivate func uid() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let uid = attrs[.ownerAccountID] as? UInt32 {
return uid
}
throw BuildFSSync.Error.couldNotDetermineUID(self.cleanPath)
}
fileprivate func gid() throws -> UInt32 {
let attrs = try FileManager.default.attributesOfItem(atPath: self.cleanPath)
if let gid = attrs[.groupOwnerAccountID] as? UInt32 {
return gid
}
throw BuildFSSync.Error.couldNotDetermineGID(self.cleanPath)
}
fileprivate func buildTransfer(
id: String,
contextDir: URL? = nil,
complete: Bool = false,
data: Data = Data()
) throws -> BuildTransfer {
let p = try {
if let contextDir { return try self.relativeChildPath(to: contextDir) }
return self.cleanPath
}()
return BuildTransfer(
id: id,
source: String(p),
complete: complete,
isDir: try self.isDir(),
metadata: [
"os": "linux",
"stage": "fssync",
"mode": String(try self.mode()),
"size": String(try self.size()),
"modified_at": try self.modTime(),
"uid": String(try self.uid()),
"gid": String(try self.gid()),
],
data: data
)
}
}
extension Date {
fileprivate func rfc3339() -> String {
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZZZZZ"
dateFormatter.locale = Locale(identifier: "en_US_POSIX")
dateFormatter.timeZone = TimeZone(secondsFromGMT: 0) // Adjust if necessary
return dateFormatter.string(from: self)
}
}
extension String {
var cleanPathComponent: String {
let trimmed = self.trimmingCharacters(in: CharacterSet(charactersIn: "/"))
if let clean = trimmed.removingPercentEncoding {
return clean
}
return trimmed
}
}
@@ -0,0 +1,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<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let imageTransfer = packet.getImageTransfer() else {
throw Error.imageTransferMissing
}
guard let ref = imageTransfer.ref() else {
throw Error.tagMissing
}
guard let platform = try imageTransfer.platform() else {
throw Error.platformMissing
}
let img = try await {
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"
}
}
}
}
@@ -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<ClientStream>.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<ClientStream>.Continuation,
receiver: GRPCAsyncResponseStream<ServerStream>
) 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<Task<(), Error>>.Continuation?
let tasks = AsyncStream<Task<(), Error>> { continuation in
taskContinuation = continuation
}
guard let taskContinuation else {
throw NSError(
domain: "untilFirstError",
code: 1,
userInfo: [NSLocalizedDescriptionKey: "Failed to initialize task continuation"])
}
defer { taskContinuation.finish() }
let stream = AsyncStream<Error> { continuation in
let processTasks = Task {
let taskStream = await group.tasks()
defer {
continuation.finish()
}
for await item in taskStream {
try Task.checkCancellation()
let addedTask = Task {
try Task.checkCancellation()
do {
try await item()
} catch {
continuation.yield(error)
await group.continuation?.finish()
throw error
}
}
taskContinuation.yield(addedTask)
}
}
taskContinuation.yield(processTasks)
let mainTask = Task { @Sendable in
defer {
continuation.finish()
processTasks.cancel()
taskContinuation.finish()
}
do {
try Task.checkCancellation()
try await body(group)
} catch {
continuation.yield(error)
await group.continuation?.finish()
throw error
}
}
taskContinuation.yield(mainTask)
}
// when the first handler fails, cancel all tasks and throw error
for await item in stream {
try Task.checkCancellation()
Task {
for await task in tasks {
task.cancel()
}
}
throw item
}
// if none of the handlers fail, wait for all subtasks to complete
for await task in tasks {
try Task.checkCancellation()
try await task.value
}
}
private actor UntilFirstError {
var stream: AsyncStream<@Sendable () async throws -> Void>?
var continuation: AsyncStream<@Sendable () async throws -> Void>.Continuation?
init() async throws {
self.stream = AsyncStream { cont in
self.continuation = cont
}
guard let _ = continuation else {
throw NSError()
}
}
func addTask(body: @Sendable @escaping () async throws -> Void) {
if !Task.isCancelled {
self.continuation?.yield(body)
}
}
func tasks() -> AsyncStream<@Sendable () async throws -> Void> {
self.stream!
}
}
}
@@ -0,0 +1,188 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard let imageTransfer = packet.getImageTransfer() else {
throw Error.imageTransferMissing
}
guard let method = imageTransfer.method() else {
throw Error.methodMissing
}
switch try ContentStoreMethod(method) {
case .info:
try await self.info(sender, imageTransfer, packet.buildID)
case .readerAt:
try await self.readerAt(sender, imageTransfer, packet.buildID)
default:
throw Error.unknownMethod(method)
}
}
func info(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
let descriptor = try await local.get(digest: packet.tag)
let size = try descriptor?.size()
let transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.info.rawValue,
size: size
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
}
func readerAt(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer, _ buildID: String) async throws {
let digest = packet.descriptor.digest
let offset: UInt64 = packet.offset() ?? 0
let size: Int = packet.len() ?? 0
guard let descriptor = try await local.get(digest: digest) else {
throw Error.contentMissing
}
if offset == 0 && size == 0 { // Metadata request
var transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.readerAt.rawValue,
size: descriptor.size(),
data: Data()
)
transfer.complete = true
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
return
}
guard let data = try descriptor.data(offset: offset, length: size) else {
throw Error.invalidOffsetSizeForContent(packet.descriptor.digest, offset, size)
}
let transfer = try ImageTransfer(
id: packet.id,
digest: packet.tag,
method: ContentStoreMethod.readerAt.rawValue,
size: UInt64(data.count),
data: data
)
var response = ClientStream()
response.buildID = buildID
response.imageTransfer = transfer
response.packetType = .imageTransfer(transfer)
sender.yield(response)
}
func delete(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.delete)"])
}
func update(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.update)"])
}
func walk(_ sender: AsyncStream<ClientStream>.Continuation, _ packet: ImageTransfer) async throws {
throw NSError(domain: "RemoteContentProxy", code: 1, userInfo: [NSLocalizedDescriptionKey: "unimplemented method \(ContentStoreMethod.walk)"])
}
enum ContentStoreMethod: String {
case info = "/containerd.services.content.v1.Content/Info"
case readerAt = "/containerd.services.content.v1.Content/ReaderAt"
case delete = "/containerd.services.content.v1.Content/Delete"
case update = "/containerd.services.content.v1.Content/Update"
case walk = "/containerd.services.content.v1.Content/Walk"
init(_ method: String) throws {
guard let value = ContentStoreMethod(rawValue: method) else {
throw Error.unknownMethod(method)
}
self = value
}
}
}
extension ImageTransfer {
fileprivate init(id: String, digest: String, method: String, size: UInt64? = nil, data: Data = Data()) throws {
self.init()
self.id = id
self.tag = digest
self.metadata = [
"os": "linux",
"stage": "content-store",
"method": method,
]
if let size {
self.metadata["size"] = String(size)
}
self.complete = true
self.direction = .into
self.data = data
}
}
extension BuildRemoteContentProxy {
enum Error: Swift.Error, CustomStringConvertible {
case imageTransferMissing
case methodMissing
case contentMissing
case unknownMethod(String)
case invalidOffsetSizeForContent(String, UInt64, Int)
var description: String {
switch self {
case .imageTransferMissing:
return "imageTransfer is missing"
case .methodMissing:
return "method is missing in request"
case .contentMissing:
return "content cannot be found"
case .unknownMethod(let m):
return "unknown content-store method \(m)"
case .invalidOffsetSizeForContent(let digest, let offset, let size):
return "invalid request for content: \(digest) with offset: \(offset) size: \(size)"
}
}
}
}
+70
View File
@@ -0,0 +1,70 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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<ClientStream>.Continuation, _ packet: ServerStream) async throws {
guard !quiet else {
return
}
guard let io = packet.getIO() else {
throw Error.ioMissing
}
if let cmdString = try TerminalCommand().json() {
var response = ClientStream()
response.buildID = packet.buildID
response.command = .init()
response.command.id = packet.buildID
response.command.command = cmdString
sender.yield(response)
}
handle.write(io.data)
}
}
extension BuildStdio {
enum Error: Swift.Error, CustomStringConvertible {
case ioMissing
case invalidContinuation
var description: String {
switch self {
case .ioMissing:
return "io field missing in packet"
case .invalidContinuation:
return "continuation could not created"
}
}
}
}
+881
View File
@@ -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<Com_Apple_Container_Build_V1_CreateBuildRequest, Com_Apple_Container_Build_V1_CreateBuildResponse>
func performBuild(
callOptions: CallOptions?,
handler: @escaping (Com_Apple_Container_Build_V1_ServerStream) -> Void
) -> BidirectionalStreamingCall<Com_Apple_Container_Build_V1_ClientStream, Com_Apple_Container_Build_V1_ServerStream>
func info(
_ request: Com_Apple_Container_Build_V1_InfoRequest,
callOptions: CallOptions?
) -> UnaryCall<Com_Apple_Container_Build_V1_InfoRequest, Com_Apple_Container_Build_V1_InfoResponse>
}
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<Com_Apple_Container_Build_V1_CreateBuildRequest, Com_Apple_Container_Build_V1_CreateBuildResponse> {
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<Com_Apple_Container_Build_V1_ClientStream, Com_Apple_Container_Build_V1_ServerStream> {
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<Com_Apple_Container_Build_V1_InfoRequest, Com_Apple_Container_Build_V1_InfoResponse> {
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<Com_Apple_Container_Build_V1_CreateBuildRequest, Com_Apple_Container_Build_V1_CreateBuildResponse>
func makePerformBuildCall(
callOptions: CallOptions?
) -> GRPCAsyncBidirectionalStreamingCall<Com_Apple_Container_Build_V1_ClientStream, Com_Apple_Container_Build_V1_ServerStream>
func makeInfoCall(
_ request: Com_Apple_Container_Build_V1_InfoRequest,
callOptions: CallOptions?
) -> GRPCAsyncUnaryCall<Com_Apple_Container_Build_V1_InfoRequest, Com_Apple_Container_Build_V1_InfoResponse>
}
@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<Com_Apple_Container_Build_V1_CreateBuildRequest, Com_Apple_Container_Build_V1_CreateBuildResponse> {
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<Com_Apple_Container_Build_V1_ClientStream, Com_Apple_Container_Build_V1_ServerStream> {
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<Com_Apple_Container_Build_V1_InfoRequest, Com_Apple_Container_Build_V1_InfoResponse> {
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<RequestStream>(
_ requests: RequestStream,
callOptions: CallOptions? = nil
) -> GRPCAsyncResponseStream<Com_Apple_Container_Build_V1_ServerStream> 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<RequestStream>(
_ requests: RequestStream,
callOptions: CallOptions? = nil
) -> GRPCAsyncResponseStream<Com_Apple_Container_Build_V1_ServerStream> 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<Com_Apple_Container_Build_V1_CreateBuildRequest, Com_Apple_Container_Build_V1_CreateBuildResponse>]
/// - Returns: Interceptors to use when invoking 'performBuild'.
func makePerformBuildInterceptors() -> [ClientInterceptor<Com_Apple_Container_Build_V1_ClientStream, Com_Apple_Container_Build_V1_ServerStream>]
/// - Returns: Interceptors to use when invoking 'info'.
func makeInfoInterceptors() -> [ClientInterceptor<Com_Apple_Container_Build_V1_InfoRequest, Com_Apple_Container_Build_V1_InfoResponse>]
}
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<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(context: StreamingResponseCallContext<Com_Apple_Container_Build_V1_ServerStream>) -> EventLoopFuture<(StreamEvent<Com_Apple_Container_Build_V1_ClientStream>) -> Void>
func info(request: Com_Apple_Container_Build_V1_InfoRequest, context: StatusOnlyCallContext) -> EventLoopFuture<Com_Apple_Container_Build_V1_InfoResponse>
}
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<Com_Apple_Container_Build_V1_CreateBuildRequest>(),
responseSerializer: ProtobufSerializer<Com_Apple_Container_Build_V1_CreateBuildResponse>(),
interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [],
userFunction: self.createBuild(request:context:)
)
case "PerformBuild":
return BidirectionalStreamingServerHandler(
context: context,
requestDeserializer: ProtobufDeserializer<Com_Apple_Container_Build_V1_ClientStream>(),
responseSerializer: ProtobufSerializer<Com_Apple_Container_Build_V1_ServerStream>(),
interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [],
observerFactory: self.performBuild(context:)
)
case "Info":
return UnaryServerHandler(
context: context,
requestDeserializer: ProtobufDeserializer<Com_Apple_Container_Build_V1_InfoRequest>(),
responseSerializer: ProtobufSerializer<Com_Apple_Container_Build_V1_InfoResponse>(),
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<Com_Apple_Container_Build_V1_ClientStream>,
responseStream: GRPCAsyncResponseStreamWriter<Com_Apple_Container_Build_V1_ServerStream>,
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<Com_Apple_Container_Build_V1_CreateBuildRequest>(),
responseSerializer: ProtobufSerializer<Com_Apple_Container_Build_V1_CreateBuildResponse>(),
interceptors: self.interceptors?.makeCreateBuildInterceptors() ?? [],
wrapping: { try await self.createBuild(request: $0, context: $1) }
)
case "PerformBuild":
return GRPCAsyncServerHandler(
context: context,
requestDeserializer: ProtobufDeserializer<Com_Apple_Container_Build_V1_ClientStream>(),
responseSerializer: ProtobufSerializer<Com_Apple_Container_Build_V1_ServerStream>(),
interceptors: self.interceptors?.makePerformBuildInterceptors() ?? [],
wrapping: { try await self.performBuild(requestStream: $0, responseStream: $1, context: $2) }
)
case "Info":
return GRPCAsyncServerHandler(
context: context,
requestDeserializer: ProtobufDeserializer<Com_Apple_Container_Build_V1_InfoRequest>(),
responseSerializer: ProtobufSerializer<Com_Apple_Container_Build_V1_InfoResponse>(),
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<Com_Apple_Container_Build_V1_CreateBuildRequest, Com_Apple_Container_Build_V1_CreateBuildResponse>]
/// - Returns: Interceptors to use when handling 'performBuild'.
/// Defaults to calling `self.makeInterceptors()`.
func makePerformBuildInterceptors() -> [ServerInterceptor<Com_Apple_Container_Build_V1_ClientStream, Com_Apple_Container_Build_V1_ServerStream>]
/// - Returns: Interceptors to use when handling 'info'.
/// Defaults to calling `self.makeInterceptors()`.
func makeInfoInterceptors() -> [ServerInterceptor<Com_Apple_Container_Build_V1_InfoRequest, Com_Apple_Container_Build_V1_InfoResponse>]
}
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
)
}
}
File diff suppressed because it is too large Load Diff
+383
View File
@@ -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<ClientStream>.Continuation?
let reqStream = AsyncStream<ClientStream> { (cont: AsyncStream<ClientStream>.Continuation) in
continuation = cont
}
guard let continuation else {
throw Error.invalidContinuation
}
defer {
continuation.finish()
}
if let terminal = config.terminal {
Task {
let winchHandler = AsyncSignalHandler.create(notify: [SIGWINCH])
let setWinch = { (rows: UInt16, cols: UInt16) in
var winch = ClientStream()
winch.command = .init()
if let cmdString = try TerminalCommand(rows: rows, cols: cols).json() {
winch.command.command = cmdString
continuation.yield(winch)
}
}
let size = try terminal.size
var width = size.width
var height = size.height
try setWinch(height, width)
for await _ in winchHandler.signals {
let size = try terminal.size
let cols = size.width
let rows = size.height
if cols != width || rows != height {
width = cols
height = rows
try setWinch(height, width)
}
}
}
}
let 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<Int32>.size
) { raw in
#if canImport(Darwin)
return setsockopt(
self.fileDescriptor,
level, name,
raw,
socklen_t(MemoryLayout<Int32>.size))
#else
fatalError("unsupported platform")
#endif
}
}
if res == -1 {
throw POSIXError(POSIXErrorCode(rawValue: errno) ?? .EPERM)
}
}
}
+121
View File
@@ -0,0 +1,121 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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<URL> = .init()
public init(_ input: URL) {
self.input = input
}
public func match(_ pattern: String) throws {
let adjustedPattern =
pattern
.replacingOccurrences(of: #"^\./(?=.)"#, with: "", options: .regularExpression)
.replacingOccurrences(of: "^\\.[/]?$", with: "*", options: .regularExpression)
.replacingOccurrences(of: "\\*{2,}[/]", with: "*/**/", options: .regularExpression)
.replacingOccurrences(of: "[/]\\*{2,}([^/])", with: "/**/*$1", options: .regularExpression)
.replacingOccurrences(of: "^\\*{2,}([^/])", with: "**/*$1", options: .regularExpression)
for child in input.children {
try self.match(input: child, components: adjustedPattern.split(separator: "/").map(String.init))
}
}
private func match(input: URL, components: [String]) throws {
if components.isEmpty {
var dir = input.standardizedFileURL
while dir != self.input.standardizedFileURL {
results.insert(dir)
guard dir.pathComponents.count > 1 else { break }
dir.deleteLastPathComponent()
}
return input.childrenRecursive.forEach { results.insert($0) }
}
let head = components.first ?? ""
let tail = components.tail
if head == "**" {
var tail: [String] = tail
while tail.first == "**" {
tail = tail.tail
}
try self.match(input: input, components: tail)
for child in input.children {
try self.match(input: child, components: components)
}
return
}
if try glob(input.lastPathComponent, head) {
try self.match(input: input, components: tail)
for child in input.children where try glob(child.lastPathComponent, tail.first ?? "") {
try self.match(input: child, components: tail)
}
return
}
}
func glob(_ input: String, _ pattern: String) throws -> Bool {
let regexPattern =
"^"
+ NSRegularExpression.escapedPattern(for: pattern)
.replacingOccurrences(of: "\\*", with: "[^/]*")
.replacingOccurrences(of: "\\?", with: "[^/]")
.replacingOccurrences(of: "[\\^", with: "[^")
.replacingOccurrences(of: "\\[", with: "[")
.replacingOccurrences(of: "\\]", with: "]") + "$"
// validate the regex pattern created
let _ = try Regex(regexPattern)
return input.range(of: regexPattern, options: .regularExpression) != nil
}
}
extension URL {
var children: [URL] {
(try? FileManager.default.contentsOfDirectory(at: self, includingPropertiesForKeys: nil))
?? []
}
var childrenRecursive: [URL] {
var results: [URL] = []
if let enumerator = FileManager.default.enumerator(
at: self, includingPropertiesForKeys: [.isDirectoryKey, .isSymbolicLinkKey])
{
while let child = enumerator.nextObject() as? URL {
results.append(child)
}
}
return [self] + results
}
}
extension [String] {
var tail: [String] {
if self.count <= 1 {
return []
}
return Array(self.dropFirst())
}
}
@@ -0,0 +1,51 @@
//===----------------------------------------------------------------------===//
// Copyright © 2025 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: "="))
}
}
+136
View File
@@ -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..<baseComponents.count {
let sub = baseComponents[0...i]
let currentPath = URL(filePath: sub.joined(separator: "/"))
let resourceValues: URLResourceValues? = try? currentPath.resourceValues(forKeys: [.isDirectoryKey])
if case let isDirectory = resourceValues?.isDirectory, isDirectory == true {
relPath.append("..")
}
}
relPath.append(contentsOf: destComponents[lastCommon...])
return relPath.joined(separator: "/")
}
func zeroCopyReader(
chunk: Int = 1024 * 1024,
buffer: AsyncStream<Data>.Continuation.BufferingPolicy = .unbounded
) throws -> AsyncStream<Data> {
let path = self.cleanPath
let fd = open(path, O_RDONLY | O_NONBLOCK)
guard fd >= 0 else { throw POSIXError.fromErrno() }
let channel = DispatchIO(
type: .stream,
fileDescriptor: fd,
queue: .global(qos: .userInitiated)
) { errno in
close(fd)
}
channel.setLimit(highWater: chunk)
return AsyncStream(bufferingPolicy: buffer) { continuation in
channel.read(
offset: 0, length: Int.max,
queue: .global(qos: .userInitiated)
) { done, ddata, err in
if err != 0 {
continuation.finish()
return
}
if let ddata, ddata.count > -1 {
let data = Data(ddata)
switch continuation.yield(data) {
case .terminated:
channel.close(flags: .stop)
default: break
}
}
if done {
channel.close(flags: .stop)
continuation.finish()
}
}
}
}
}
+27
View File
@@ -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
}
}
+293
View File
@@ -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<UInt8>.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"
}
}
}
}
@@ -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<Element>()
return filter { elems.insert($0).inserted }
}
}
@@ -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)
}
@@ -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)
}
}
}
+153
View File
@@ -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<T>(filename: String) throws -> T where T: Decodable {
try load(path: self.path.appendingPathComponent(filename))
}
private func load<T>(path: URL) throws -> T where T: Decodable {
let data = try Data(contentsOf: path)
return try JSONDecoder().decode(T.self, from: data)
}
}
@@ -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
)
}
}
}
@@ -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)"
}
}
}
@@ -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)
}
}
@@ -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
}
}
@@ -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")
}
}
}
@@ -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)
}
}
@@ -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
)
}
}
}

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