mirror of
https://github.com/apple/container.git
synced 2026-08-24 10:05:43 -05:00
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:
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
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"fileScopedDeclarationPrivacy" : {
|
||||
"accessLevel" : "private"
|
||||
},
|
||||
"indentation" : {
|
||||
"spaces" : 4
|
||||
},
|
||||
"indentConditionalCompilationBlocks" : false,
|
||||
"indentSwitchCaseLabels" : false,
|
||||
"lineBreakAroundMultilineExpressionChainComponents" : false,
|
||||
"lineBreakBeforeControlFlowKeywords" : false,
|
||||
"lineBreakBeforeEachArgument" : false,
|
||||
"lineBreakBeforeEachGenericRequirement" : false,
|
||||
"lineLength" : 180,
|
||||
"maximumBlankLines" : 1,
|
||||
"multiElementCollectionTrailingCommas" : true,
|
||||
"noAssignmentInExpressions" : {
|
||||
"allowedFunctions" : [
|
||||
"XCTAssertNoThrow"
|
||||
]
|
||||
},
|
||||
"prioritizeKeepingFunctionOutputTogether" : false,
|
||||
"respectsExistingLineBreaks" : true,
|
||||
"rules" : {
|
||||
"AllPublicDeclarationsHaveDocumentation" : false,
|
||||
"AlwaysUseLowerCamelCase" : true,
|
||||
"AmbiguousTrailingClosureOverload" : false,
|
||||
"BeginDocumentationCommentWithOneLineSummary" : false,
|
||||
"DoNotUseSemicolons" : true,
|
||||
"DontRepeatTypeInStaticProperties" : true,
|
||||
"FileScopedDeclarationPrivacy" : true,
|
||||
"FullyIndirectEnum" : true,
|
||||
"GroupNumericLiterals" : true,
|
||||
"IdentifiersMustBeASCII" : true,
|
||||
"NeverForceUnwrap" : true,
|
||||
"NeverUseForceTry" : true,
|
||||
"NeverUseImplicitlyUnwrappedOptionals" : true,
|
||||
"NoAccessLevelOnExtensionDeclaration" : true,
|
||||
"NoAssignmentInExpressions" : true,
|
||||
"NoBlockComments" : false,
|
||||
"NoCasesWithOnlyFallthrough" : true,
|
||||
"NoEmptyTrailingClosureParentheses" : true,
|
||||
"NoLabelsInCasePatterns" : true,
|
||||
"NoLeadingUnderscores" : false,
|
||||
"NoParensAroundConditions" : true,
|
||||
"NoPlaygroundLiterals" : true,
|
||||
"NoVoidReturnOnFunctionSignature" : true,
|
||||
"OmitExplicitReturns" : true,
|
||||
"OneCasePerLine" : true,
|
||||
"OneVariableDeclarationPerLine" : true,
|
||||
"OnlyOneTrailingClosureArgument" : true,
|
||||
"OrderedImports" : true,
|
||||
"ReplaceForEachWithForLoop" : true,
|
||||
"ReturnVoidInsteadOfEmptyTuple" : true,
|
||||
"TypeNamesShouldBeCapitalized" : true,
|
||||
"UseEarlyExits" : true,
|
||||
"UseLetInEveryBoundCaseVariable" : true,
|
||||
"UseShorthandTypeNames" : true,
|
||||
"UseSingleLinePropertyGetter" : true,
|
||||
"UseSynthesizedInitializer" : true,
|
||||
"UseTripleSlashForDocumentationComments" : true,
|
||||
"UseWhereClausesInForLoops" : false,
|
||||
"ValidateDocumentationComments" : true
|
||||
},
|
||||
"spacesAroundRangeFormationOperators" : false,
|
||||
"tabWidth" : 2,
|
||||
"version" : 1
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
## Code of Conduct
|
||||
|
||||
### Our Pledge
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our
|
||||
project and our community a harassment-free experience for everyone,
|
||||
regardless of age, body size, disability, ethnicity, sex
|
||||
characteristics, gender identity and expression, level of experience,
|
||||
education, socio-economic status, nationality, personal appearance,
|
||||
race, religion, or sexual identity and orientation.
|
||||
|
||||
### Our Standards
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
|
||||
* Using welcoming and inclusive language
|
||||
* Being respectful of differing viewpoints and experiences
|
||||
* Gracefully accepting constructive criticism
|
||||
* Focusing on what is best for the community
|
||||
* Showing empathy towards other community members
|
||||
|
||||
Examples of unacceptable behavior by participants include:
|
||||
|
||||
* The use of sexualized language or imagery and unwelcome sexual
|
||||
attention or advances
|
||||
* Trolling, insulting/derogatory comments, and personal or political
|
||||
attacks
|
||||
* Public or private harassment
|
||||
* Publishing others’ private information, such as a physical or
|
||||
electronic address, without explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
### Our Responsibilities
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of
|
||||
acceptable behavior and are expected to take appropriate and fair
|
||||
corrective action in response to any instances of unacceptable behavior.
|
||||
|
||||
Project maintainers have the right and responsibility to remove, edit,
|
||||
or reject comments, commits, code, wiki edits, issues, and other
|
||||
contributions that are not aligned to this Code of Conduct, or to ban
|
||||
temporarily or permanently any contributor for other behaviors that they
|
||||
deem inappropriate, threatening, offensive, or harmful.
|
||||
|
||||
### Scope
|
||||
|
||||
This Code of Conduct applies within all project spaces, and it also
|
||||
applies when an individual is representing the project or its community
|
||||
in public spaces. Examples of representing a project or community
|
||||
include using an official project e-mail address, posting via an
|
||||
official social media account, or acting as an appointed representative
|
||||
at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
### Enforcement
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may
|
||||
be reported by contacting the open source team at
|
||||
opensource-conduct@group.apple.com. All complaints will be reviewed and
|
||||
investigated and will result in a response that is deemed necessary and
|
||||
appropriate to the circumstances. The project team is obligated to
|
||||
maintain confidentiality with regard to the reporter of an incident.
|
||||
Further details of specific enforcement policies may be posted
|
||||
separately.
|
||||
|
||||
Project maintainers who do not follow or enforce the Code of Conduct in
|
||||
good faith may face temporary or permanent repercussions as determined
|
||||
by other members of the project’s leadership.
|
||||
|
||||
### Attribution
|
||||
|
||||
This Code of Conduct is adapted from the
|
||||
[Contributor Covenant](https://www.contributor-covenant.org), version 1.4,
|
||||
available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,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
|
||||
@@ -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
@@ -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)\""),
|
||||
]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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."
|
||||
@@ -0,0 +1,696 @@
|
||||
# `container`
|
||||
|
||||

|
||||
|
||||
`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
@@ -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.
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: ","))]"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"))")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) ?? "")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"))
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -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"]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
]
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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: "="))
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user