├── .github ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md ├── release.yml └── workflows │ ├── endtoend_tests.yml │ ├── integration_tests.yml │ ├── interop_tests.yml │ ├── main.yml │ └── pull_request.yml ├── .gitignore ├── .licenseignore ├── .mailmap ├── .spi.yml ├── .swift-format ├── .swiftformatignore ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── CONTRIBUTORS.txt ├── Examples ├── HelloWorldHummingbird │ ├── .gitignore │ ├── Package.swift │ └── Sources │ │ ├── App.swift │ │ └── Application+build.swift ├── HelloWorldVapor │ ├── .gitignore │ ├── Package.swift │ └── Sources │ │ ├── configure.swift │ │ ├── entrypoint.swift │ │ └── routes.swift └── HelloWorldWithResources │ ├── .gitignore │ ├── Package.swift │ └── Sources │ ├── App.swift │ ├── Application+build.swift │ └── resources │ ├── happy-cat-face.jpg │ ├── slightly-smiling-face.jpg │ └── smiling-face-with-sunglasses.jpg ├── LICENSE.txt ├── NOTICE.txt ├── Package.swift ├── Plugins └── ContainerImageBuilder │ ├── Pipe+lines.swift │ ├── main.swift │ └── runner.swift ├── README.md ├── SECURITY.md ├── Sources ├── ContainerRegistry │ ├── AuthHandler.swift │ ├── HTTPClient.swift │ ├── ImageDestination.swift │ ├── ImageReference+Digest.swift │ ├── ImageReference.swift │ ├── ImageSource.swift │ ├── RegistryClient+CheckAPI.swift │ ├── RegistryClient+ImageDestination.swift │ ├── RegistryClient+ImageSource.swift │ ├── RegistryClient+Tags.swift │ ├── RegistryClient.swift │ ├── Schema.swift │ └── ScratchImage.swift ├── Tar │ └── tar.swift ├── containertool │ ├── ELFDetect.swift │ ├── Extensions │ │ ├── Archive+appending.swift │ │ ├── ELF+containerArchitecture.swift │ │ ├── Errors+CustomStringConvertible.swift │ │ ├── NetrcError+CustomStringConvertible.swift │ │ ├── RegistryClient+CopyBlobs.swift │ │ ├── RegistryClient+Layers.swift │ │ └── RegistryClient+publish.swift │ ├── Logging.swift │ ├── containertool.swift │ └── gzip.swift └── swift-container-plugin │ ├── Documentation.docc │ ├── Adding-the-plugin-to-your-project.md │ ├── Info.plist │ ├── Swift-Container-Plugin.md │ ├── _Resources │ │ └── swift-container-plugin-flow-diagram.png │ ├── authentication.md │ ├── build-container-image.md │ ├── build.md │ ├── requirements.md │ └── run.md │ ├── Empty.swift │ └── README.md ├── Tests ├── ContainerRegistryTests │ ├── AuthTests.swift │ ├── ImageReferenceTests.swift │ ├── Resources │ │ ├── netrc.basic │ │ ├── netrc.default │ │ ├── netrc.empty │ │ └── netrc.invaliddefault │ └── SmokeTests.swift ├── TarTests │ ├── TarInteropTests.swift │ └── TarUnitTests.swift └── containertoolTests │ ├── ELFDetectTests.swift │ └── ZlibTests.swift ├── Vendor └── github.com │ └── apple │ ├── swift-nio-extras │ ├── LICENSE.txt │ └── Sources │ │ └── CNIOExtrasZlib │ │ ├── empty.c │ │ └── include │ │ └── CNIOExtrasZlib.h │ └── swift-package-manager │ ├── LICENSE.txt │ └── Sources │ └── Basics │ ├── AuthorizationProvider.swift │ └── Netrc.swift └── scripts ├── generate-contributors-list.sh ├── run-integration-tests.sh ├── test-containertool-elf-detection.sh ├── test-containertool-resources.sh └── test-plugin-output-streaming.sh /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | ### Description 2 | 3 | _[Explain what causes the problem you're reporting.]_ 4 | 5 | ### Reproduction 6 | 7 | _[Please provide inputs to help us reproduce the problem. Try to reduce the test case to the smallest amount of code possible which still reproduces the problem.]_ 8 | 9 | ### Package versions 10 | 11 | _[Provide the version of the Swift Container Plugin you are using when encountering the problem.]_ 12 | 13 | ### Expected behavior 14 | 15 | _[Describe what you expect to happen.]_ 16 | 17 | ### Environment 18 | 19 | _[Provide the Swift version, tag, or revision you are using.]_ 20 | 21 | ### Additional information 22 | 23 | _[Provide any extra information which could help others understand or work around the problem.]_ 24 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | Motivation 2 | ---------- 3 | 4 | _[Explain here the context, and why you're making that change. What is the problem you're trying to solve.]_ 5 | 6 | Modifications 7 | ------------- 8 | 9 | _[Describe the modifications you've made.]_ 10 | 11 | Result 12 | ------ 13 | 14 | _[After your change, what will change.]_ 15 | 16 | Test Plan 17 | --------- 18 | 19 | _[Describe the steps you took, or will take, to qualify the change - such as adjusting tests and manual testing.]_ 20 | -------------------------------------------------------------------------------- /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | categories: 3 | - title: SemVer Major 4 | labels: 5 | - semver/major 6 | - title: SemVer Minor 7 | labels: 8 | - semver/minor 9 | - title: SemVer Patch 10 | labels: 11 | - semver/patch 12 | - title: Other Changes 13 | labels: 14 | - semver/none 15 | - "*" 16 | -------------------------------------------------------------------------------- /.github/workflows/endtoend_tests.yml: -------------------------------------------------------------------------------- 1 | name: End to end tests 2 | 3 | on: 4 | workflow_call: 5 | # inputs: 6 | # example: 7 | # required: true 8 | # type: string 9 | 10 | jobs: 11 | endtoend-tests: 12 | name: End to end tests 13 | runs-on: ubuntu-latest 14 | services: 15 | registry: 16 | image: registry:2 17 | ports: 18 | - 5000:5000 19 | strategy: 20 | matrix: 21 | example: 22 | - Examples/HelloWorldVapor 23 | - Examples/HelloWorldHummingbird 24 | - Examples/HelloWorldWithResources 25 | steps: 26 | - name: Checkout repository 27 | uses: actions/checkout@v4 28 | with: 29 | persist-credentials: false 30 | 31 | - name: Mark the workspace as safe 32 | # https://github.com/actions/checkout/issues/766 33 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 34 | 35 | - name: Install the static SDK 36 | run: | 37 | swift sdk install \ 38 | https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz \ 39 | --checksum 111c6f7d280a651208b8c74c0521dd99365d785c1976a6e23162f55f65379ac6 40 | 41 | - name: Build the example 42 | run: | 43 | sed -i'.bak' -e "/swift-container-plugin/ s@(url:.*),@(path: \"$PWD\"),@" ${{ matrix.example }}/Package.swift # Use plugin from this checkout 44 | cat ${{ matrix.example }}/Package.swift 45 | swift package \ 46 | --package-path ${{ matrix.example }} \ 47 | -Xswiftc -warnings-as-errors \ 48 | --swift-sdk x86_64-swift-linux-musl \ 49 | --allow-network-connections all \ 50 | build-container-image \ 51 | --repository localhost:5000/example \ 52 | --from scratch 53 | 54 | - name: Run the example 55 | run: | 56 | docker run -d --platform linux/amd64 -p 8080:8080 localhost:5000/example 57 | 58 | - name: Check that the service is running 59 | run: | 60 | # The curious combination of --verbose and --silent causes 61 | # curl to print the request and response (--verbose) but not 62 | # the transmission progress messages (--silent). 63 | # 64 | # --fail-with-body causes curl to exit with a nonzero exit code 65 | # if the HTTP response code is >= 400. Without this flag, curl 66 | # only returns a nonzero exit code if something went wrong while 67 | # connecting to the server - a successful HTTP transaction which 68 | # indicates a server error is still considered to be a successful 69 | # transaction from the client's point of view. 70 | curl --verbose --silent --output /dev/null --fail-with-body localhost:8080 71 | -------------------------------------------------------------------------------- /.github/workflows/integration_tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration tests 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | integration-tests: 8 | name: Integration tests 9 | runs-on: ubuntu-latest 10 | services: 11 | registry: 12 | image: registry:2 13 | ports: 14 | - 5000:5000 15 | container: 16 | image: swift:6.0-noble 17 | steps: 18 | - name: Checkout repository 19 | uses: actions/checkout@v4 20 | with: 21 | persist-credentials: false 22 | 23 | - name: Mark the workspace as safe 24 | # https://github.com/actions/checkout/issues/766 25 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 26 | 27 | - name: Install bsdtar 28 | run: | 29 | which bsdtar || (apt-get -q update && apt-get -yq install libarchive-tools) 30 | 31 | - name: Run test job 32 | env: 33 | REGISTRY_HOST: registry 34 | REGISTRY_PORT: 5000 35 | run: | 36 | swift test 37 | 38 | containertool-resources-test: 39 | name: Containertool resources test 40 | runs-on: ubuntu-latest 41 | services: 42 | registry: 43 | image: registry:2 44 | ports: 45 | - 5000:5000 46 | steps: 47 | - name: Checkout repository 48 | uses: actions/checkout@v4 49 | with: 50 | persist-credentials: false 51 | 52 | - name: Mark the workspace as safe 53 | # https://github.com/actions/checkout/issues/766 54 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 55 | 56 | - name: Check plugin streaming output is reassembled and printed properly 57 | run: | 58 | scripts/test-containertool-resources.sh 59 | 60 | plugin-streaming-output-test: 61 | name: Plugin streaming output test 62 | runs-on: ubuntu-latest 63 | services: 64 | registry: 65 | image: registry:2 66 | ports: 67 | - 5000:5000 68 | steps: 69 | - name: Checkout repository 70 | uses: actions/checkout@v4 71 | with: 72 | persist-credentials: false 73 | 74 | - name: Mark the workspace as safe 75 | # https://github.com/actions/checkout/issues/766 76 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 77 | 78 | - name: Check plugin streaming output is reassembled and printed properly 79 | run: | 80 | scripts/test-plugin-output-streaming.sh 81 | -------------------------------------------------------------------------------- /.github/workflows/interop_tests.yml: -------------------------------------------------------------------------------- 1 | name: Interop tests 2 | 3 | on: 4 | workflow_call: 5 | 6 | jobs: 7 | layering-test: 8 | name: Containertool layering test 9 | runs-on: ubuntu-latest 10 | services: 11 | registry: 12 | image: registry:2 13 | ports: 14 | - 5000:5000 15 | steps: 16 | - name: Checkout repository 17 | uses: actions/checkout@v4 18 | with: 19 | persist-credentials: false 20 | 21 | - name: Mark the workspace as safe 22 | # https://github.com/actions/checkout/issues/766 23 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 24 | 25 | # First layer: payload does not have to be an executable, it just has to have known contents 26 | - name: Build first layer 27 | run: | 28 | echo first layer > payload 29 | swift run containertool --repository localhost:5000/layering_test payload --from scratch 30 | docker create --name first --pull always localhost:5000/layering_test 31 | docker cp first:/payload first.payload 32 | grep first first.payload 33 | 34 | # Second layer: payload does not have to be an executable, it just has to have known contents. It should replace the first layer. 35 | - name: Build another layer, which should override 'payload' from the first layer 36 | run: | 37 | echo second layer > payload 38 | swift run containertool --repository localhost:5000/layering_test payload --from localhost:5000/layering_test:latest 39 | docker create --name second --pull always localhost:5000/layering_test 40 | docker cp second:/payload second.payload 41 | grep second second.payload 42 | 43 | elf-detection-test: 44 | name: Containertool ELF detection test 45 | runs-on: ubuntu-latest 46 | services: 47 | registry: 48 | image: registry:2 49 | ports: 50 | - 5000:5000 51 | steps: 52 | - name: Checkout repository 53 | uses: actions/checkout@v4 54 | with: 55 | persist-credentials: false 56 | 57 | - name: Mark the workspace as safe 58 | # https://github.com/actions/checkout/issues/766 59 | run: git config --global --add safe.directory ${GITHUB_WORKSPACE} 60 | 61 | - name: Install the static SDK 62 | run: | 63 | swift sdk install \ 64 | https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz \ 65 | --checksum 111c6f7d280a651208b8c74c0521dd99365d785c1976a6e23162f55f65379ac6 66 | 67 | # Run the test script 68 | - name: Test ELF detection 69 | run: | 70 | scripts/test-containertool-elf-detection.sh 71 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: main 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | schedule: 8 | - cron: '12 3 * * *' 9 | 10 | jobs: 11 | unit-tests: 12 | name: Unit tests 13 | uses: apple/swift-nio/.github/workflows/unit_tests.yml@main 14 | with: 15 | linux_5_9_enabled: false 16 | linux_5_10_enabled: false 17 | linux_6_0_arguments_override: "--skip SmokeTests --skip TarInteropTests" 18 | linux_6_1_arguments_override: "--skip SmokeTests --skip TarInteropTests" 19 | linux_nightly_6_1_arguments_override: "--skip SmokeTests --skip TarInteropTests" 20 | linux_nightly_main_arguments_override: "--skip SmokeTests --skip TarInteropTests" 21 | 22 | integration-tests: 23 | name: Integration tests 24 | uses: ./.github/workflows/integration_tests.yml 25 | 26 | endtoend-tests: 27 | name: End to end tests 28 | uses: ./.github/workflows/endtoend_tests.yml 29 | -------------------------------------------------------------------------------- /.github/workflows/pull_request.yml: -------------------------------------------------------------------------------- 1 | name: PR 2 | 3 | on: 4 | pull_request: 5 | types: [opened, reopened, synchronize] 6 | 7 | jobs: 8 | soundness: 9 | name: Soundness 10 | uses: swiftlang/github-workflows/.github/workflows/soundness.yml@main 11 | with: 12 | api_breakage_check_container_image: "swift:6.0-noble" 13 | docs_check_container_image: "swift:6.0-noble" 14 | license_header_check_project_name: "SwiftContainerPlugin" 15 | shell_check_container_image: "swift:6.0-noble" 16 | 17 | # Unit tests for functions and modules 18 | unit-tests: 19 | name: Unit tests 20 | uses: apple/swift-nio/.github/workflows/unit_tests.yml@main 21 | with: 22 | linux_5_9_enabled: false 23 | linux_5_10_enabled: false 24 | linux_6_0_arguments_override: "--skip SmokeTests --skip TarInteropTests" 25 | linux_6_1_arguments_override: "--skip SmokeTests --skip TarInteropTests" 26 | linux_nightly_6_1_arguments_override: "--skip SmokeTests --skip TarInteropTests" 27 | linux_nightly_main_arguments_override: "--skip SmokeTests --skip TarInteropTests" 28 | 29 | # Test functions and modules against a separate registry 30 | integration-tests: 31 | name: Integration tests 32 | uses: ./.github/workflows/integration_tests.yml 33 | 34 | # Test that outputs can be handled properly by other systems 35 | interop-tests: 36 | name: Interop tests 37 | uses: ./.github/workflows/interop_tests.yml 38 | 39 | # Full build-package-deploy-run cycles 40 | endtoend-tests: 41 | name: End to end tests 42 | uses: ./.github/workflows/endtoend_tests.yml 43 | 44 | swift-6-language-mode: 45 | name: Swift 6 Language Mode 46 | uses: apple/swift-nio/.github/workflows/swift_6_language_mode.yml@main 47 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | /Package.resolved 10 | -------------------------------------------------------------------------------- /.licenseignore: -------------------------------------------------------------------------------- 1 | **/*.docc/* 2 | **/*.jpg 3 | **/*.md 4 | **/.gitignore 5 | **/Package.resolved 6 | **/Package.swift 7 | **/README.md 8 | .dockerignore 9 | .github/* 10 | .gitignore 11 | .licenseignore 12 | .mailmap 13 | .spi.yml 14 | .swift-format 15 | .swiftformatignore 16 | CODE_OF_CONDUCT.md 17 | CONTRIBUTING.md 18 | CONTRIBUTORS.txt 19 | LICENSE.txt 20 | NOTICE.txt 21 | Package.resolved 22 | Package.swift 23 | README.md 24 | SECURITY.md 25 | Tests/ContainerRegistryTests/Resources/* 26 | Vendor/* 27 | docker-compose.yaml 28 | docker/* 29 | scripts/* 30 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Euan Harris 2 | -------------------------------------------------------------------------------- /.spi.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | builder: 3 | configs: 4 | - documentation_targets: 5 | - swift-container-plugin 6 | -------------------------------------------------------------------------------- /.swift-format: -------------------------------------------------------------------------------- 1 | { 2 | "fileScopedDeclarationPrivacy" : { 3 | "accessLevel" : "private" 4 | }, 5 | "indentation" : { 6 | "spaces" : 4 7 | }, 8 | "indentConditionalCompilationBlocks" : false, 9 | "indentSwitchCaseLabels" : false, 10 | "lineBreakAroundMultilineExpressionChainComponents" : true, 11 | "lineBreakBeforeControlFlowKeywords" : false, 12 | "lineBreakBeforeEachArgument" : true, 13 | "lineBreakBeforeEachGenericRequirement" : true, 14 | "lineLength" : 120, 15 | "maximumBlankLines" : 1, 16 | "prioritizeKeepingFunctionOutputTogether" : false, 17 | "respectsExistingLineBreaks" : true, 18 | "rules" : { 19 | "AllPublicDeclarationsHaveDocumentation" : true, 20 | "AlwaysUseLowerCamelCase" : false, 21 | "AlwaysUseLiteralForEmptyCollectionInit" : true, 22 | "AmbiguousTrailingClosureOverload" : true, 23 | "BeginDocumentationCommentWithOneLineSummary" : false, 24 | "DoNotUseSemicolons" : true, 25 | "DontRepeatTypeInStaticProperties" : false, 26 | "FileScopedDeclarationPrivacy" : true, 27 | "FullyIndirectEnum" : true, 28 | "GroupNumericLiterals" : true, 29 | "IdentifiersMustBeASCII" : true, 30 | "NeverForceUnwrap" : false, 31 | "NeverUseForceTry" : false, 32 | "NeverUseImplicitlyUnwrappedOptionals" : false, 33 | "NoAccessLevelOnExtensionDeclaration" : false, 34 | "NoAssignmentInExpressions" : true, 35 | "NoBlockComments" : true, 36 | "NoCasesWithOnlyFallthrough" : true, 37 | "NoEmptyTrailingClosureParentheses" : true, 38 | "NoLabelsInCasePatterns" : false, 39 | "NoLeadingUnderscores" : false, 40 | "NoParensAroundConditions" : true, 41 | "NoVoidReturnOnFunctionSignature" : true, 42 | "OmitExplicitReturns" : true, 43 | "OneCasePerLine" : true, 44 | "OneVariableDeclarationPerLine" : true, 45 | "OnlyOneTrailingClosureArgument" : true, 46 | "OrderedImports" : false, 47 | "ReplaceForEachWithForLoop" : true, 48 | "ReturnVoidInsteadOfEmptyTuple" : true, 49 | "UseEarlyExits" : false, 50 | "UseLetInEveryBoundCaseVariable" : false, 51 | "UseShorthandTypeNames" : true, 52 | "UseSingleLinePropertyGetter" : false, 53 | "UseSynthesizedInitializer" : true, 54 | "UseTripleSlashForDocumentationComments" : true, 55 | "UseWhereClausesInForLoops" : false, 56 | "ValidateDocumentationComments" : true 57 | }, 58 | "spacesAroundRangeFormationOperators" : false, 59 | "tabWidth" : 8, 60 | "version" : 1 61 | } 62 | -------------------------------------------------------------------------------- /.swiftformatignore: -------------------------------------------------------------------------------- 1 | Vendor/* 2 | -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Code of Conduct 2 | 3 | The code of conduct for this project can be found at https://swift.org/code-of-conduct. 4 | 5 | 6 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Legal 2 | 3 | By submitting a pull request, you represent that you have the right to license 4 | your contribution to Apple and the community, and agree by submitting the patch 5 | that your contributions are licensed under the Apache 2.0 license (see 6 | `LICENSE.txt`). 7 | 8 | ## How to submit a bug report 9 | 10 | Please ensure to specify the following: 11 | 12 | * Commit hash 13 | * Contextual information (e.g. what you were trying to achieve with swift-container-plugin) 14 | * Simplest possible steps to reproduce 15 | * More complex the steps are, lower the priority will be. 16 | * A pull request with failing test case is preferred, but it's just fine to paste the test case into the issue description. 17 | * Anything that might be relevant in your opinion, such as: 18 | * Swift version or the output of `swift --version` 19 | * OS version and the output of `uname -a` 20 | * Network configuration 21 | 22 | ### Example 23 | 24 | ``` 25 | Commit hash: b17a8a9f0f814c01a56977680cb68d8a779c951f 26 | 27 | Context: 28 | While testing my application that uses with swift-container-plugin, I noticed that ... 29 | 30 | Steps to reproduce: 31 | 1. ... 32 | 2. ... 33 | 3. ... 34 | 4. ... 35 | 36 | $ swift --version 37 | Swift version 4.0.2 (swift-4.0.2-RELEASE) 38 | Target: x86_64-unknown-linux-gnu 39 | 40 | Operating system: Ubuntu Linux 16.04 64-bit 41 | 42 | $ uname -a 43 | Linux beefy.machine 4.4.0-101-generic #124-Ubuntu SMP Fri Nov 10 18:29:59 UTC 2017 x86_64 x86_64 x86_64 GNU/Linux 44 | 45 | My system has IPv6 disabled. 46 | ``` 47 | 48 | ## Writing a Patch 49 | 50 | A good patch is: 51 | 52 | 1. Concise, and contains as few changes as needed to achieve the end result. 53 | 2. Tested, ensuring that any tests provided failed before the patch and pass after it. 54 | 3. Documented, adding API documentation as needed to cover new functions and properties. 55 | 4. Accompanied by a great commit message, using our commit message template. 56 | 57 | ## How to contribute your work 58 | 59 | Please open a pull request at https://github.com/apple/swift-container-plugin. 60 | Continuous integration (CI) checks for required content (headers), runs a variety of tests, and verifies the documentation builds. All pull requests should pass CI checks before a code review. 61 | -------------------------------------------------------------------------------- /CONTRIBUTORS.txt: -------------------------------------------------------------------------------- 1 | For the purpose of tracking copyright, this is the list of individuals and 2 | organizations who have contributed source code to SwiftContainerPlugin. 3 | 4 | For employees of an organization/company where the copyright of work done 5 | by employees of that company is held by the company itself, only the company 6 | needs to be listed here. 7 | 8 | ## COPYRIGHT HOLDERS 9 | 10 | - Apple Inc. (all contributors with '@apple.com') 11 | 12 | ### Contributors 13 | 14 | - Euan Harris 15 | 16 | **Updating this list** 17 | 18 | Please do not edit this file manually. It is generated using `bash ./scripts/generate-contributors-list.sh`. If a name is misspelled or appearing multiple times: add an entry in `./.mailmap` 19 | -------------------------------------------------------------------------------- /Examples/HelloWorldHummingbird/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | /Package.resolved 10 | -------------------------------------------------------------------------------- /Examples/HelloWorldHummingbird/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | //===----------------------------------------------------------------------===// 4 | // 5 | // This source file is part of the SwiftContainerPlugin open source project 6 | // 7 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 8 | // Licensed under Apache License v2.0 9 | // 10 | // See LICENSE.txt for license information 11 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 12 | // 13 | // SPDX-License-Identifier: Apache-2.0 14 | // 15 | //===----------------------------------------------------------------------===// 16 | 17 | import PackageDescription 18 | 19 | let package = Package( 20 | name: "hello-world", 21 | platforms: [.macOS(.v14)], 22 | dependencies: [ 23 | .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.1.0"), 24 | .package(path: "../.."), 25 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), 26 | ], 27 | targets: [ 28 | .executableTarget( 29 | name: "hello-world", 30 | dependencies: [ 31 | .product(name: "Hummingbird", package: "hummingbird"), 32 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 33 | ] 34 | ) 35 | ] 36 | ) 37 | -------------------------------------------------------------------------------- /Examples/HelloWorldHummingbird/Sources/App.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import ArgumentParser 16 | 17 | @main 18 | struct Hello: AsyncParsableCommand { 19 | @Option(name: .shortAndLong) 20 | var hostname: String = "0.0.0.0" 21 | 22 | @Option(name: .shortAndLong) 23 | var port: Int = 8080 24 | 25 | func run() async throws { 26 | let app = buildApplication( 27 | configuration: .init( 28 | address: .hostname(hostname, port: port), 29 | serverName: "Hummingbird" 30 | ) 31 | ) 32 | try await app.runService() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Examples/HelloWorldHummingbird/Sources/Application+build.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | import Hummingbird 17 | import Logging 18 | 19 | let myos = ProcessInfo.processInfo.operatingSystemVersionString 20 | 21 | func buildApplication(configuration: ApplicationConfiguration) -> some ApplicationProtocol { 22 | let router = Router() 23 | router.addMiddleware { LogRequestsMiddleware(.info) } 24 | router.get("/") { _, _ in 25 | "Hello World, from Hummingbird on \(myos)\n" 26 | } 27 | 28 | let app = Application( 29 | router: router, 30 | configuration: configuration, 31 | logger: Logger(label: "HelloWorldHummingbird") 32 | ) 33 | 34 | return app 35 | } 36 | -------------------------------------------------------------------------------- /Examples/HelloWorldVapor/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | /Package.resolved 10 | -------------------------------------------------------------------------------- /Examples/HelloWorldVapor/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | //===----------------------------------------------------------------------===// 4 | // 5 | // This source file is part of the SwiftContainerPlugin open source project 6 | // 7 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 8 | // Licensed under Apache License v2.0 9 | // 10 | // See LICENSE.txt for license information 11 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 12 | // 13 | // SPDX-License-Identifier: Apache-2.0 14 | // 15 | //===----------------------------------------------------------------------===// 16 | 17 | import PackageDescription 18 | 19 | let package = Package( 20 | name: "hello-world", 21 | platforms: [.macOS(.v13)], 22 | dependencies: [ 23 | .package(url: "https://github.com/vapor/vapor", from: "4.102.0"), 24 | .package(url: "https://github.com/apple/swift-container-plugin", from: "1.0.0"), 25 | ], 26 | targets: [.executableTarget(name: "hello-world", dependencies: [.product(name: "Vapor", package: "vapor")])] 27 | ) 28 | -------------------------------------------------------------------------------- /Examples/HelloWorldVapor/Sources/configure.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Vapor 16 | 17 | func configure(_ app: Application) async throws { 18 | try routes(app) 19 | } 20 | -------------------------------------------------------------------------------- /Examples/HelloWorldVapor/Sources/entrypoint.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Vapor 16 | 17 | @main 18 | enum Entrypoint { 19 | static func main() async throws { 20 | let env = try Environment.detect() 21 | let app = try await Application.make(env) 22 | app.http.server.configuration.hostname = "0.0.0.0" 23 | 24 | do { 25 | try await configure(app) 26 | try await app.execute() 27 | } catch { 28 | app.logger.report(error: error) 29 | try? await app.asyncShutdown() 30 | throw error 31 | } 32 | try await app.asyncShutdown() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Examples/HelloWorldVapor/Sources/routes.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | import Vapor 17 | 18 | let myos = ProcessInfo.processInfo.operatingSystemVersionString 19 | 20 | func routes(_ app: Application) throws { 21 | app.get { req async in 22 | "Hello World, from Vapor on \(myos)\n" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /Examples/HelloWorldWithResources/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | /Package.resolved 10 | -------------------------------------------------------------------------------- /Examples/HelloWorldWithResources/Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | //===----------------------------------------------------------------------===// 4 | // 5 | // This source file is part of the SwiftContainerPlugin open source project 6 | // 7 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 8 | // Licensed under Apache License v2.0 9 | // 10 | // See LICENSE.txt for license information 11 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 12 | // 13 | // SPDX-License-Identifier: Apache-2.0 14 | // 15 | //===----------------------------------------------------------------------===// 16 | 17 | import PackageDescription 18 | 19 | let package = Package( 20 | name: "hello-world", 21 | platforms: [.macOS(.v14)], 22 | dependencies: [ 23 | .package(url: "https://github.com/hummingbird-project/hummingbird.git", from: "2.1.0"), 24 | .package(url: "https://github.com/apple/swift-container-plugin", from: "1.0.0"), 25 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), 26 | ], 27 | targets: [ 28 | .executableTarget( 29 | name: "hello-world", 30 | dependencies: [ 31 | .product(name: "Hummingbird", package: "hummingbird"), 32 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 33 | ], 34 | resources: [.process("resources")] 35 | ) 36 | ] 37 | ) 38 | -------------------------------------------------------------------------------- /Examples/HelloWorldWithResources/Sources/App.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import ArgumentParser 16 | 17 | @main 18 | struct Hello: AsyncParsableCommand { 19 | @Option(name: .shortAndLong) 20 | var hostname: String = "0.0.0.0" 21 | 22 | @Option(name: .shortAndLong) 23 | var port: Int = 8080 24 | 25 | func run() async throws { 26 | let app = buildApplication( 27 | configuration: .init( 28 | address: .hostname(hostname, port: port), 29 | serverName: "Hummingbird" 30 | ) 31 | ) 32 | try await app.runService() 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Examples/HelloWorldWithResources/Sources/Application+build.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | import Hummingbird 17 | import Logging 18 | 19 | let myos = ProcessInfo.processInfo.operatingSystemVersionString 20 | 21 | func buildApplication(configuration: ApplicationConfiguration) -> some ApplicationProtocol { 22 | let router = Router() 23 | router.addMiddleware { LogRequestsMiddleware(.info) } 24 | router.get("/") { _, _ in 25 | let faces = [ 26 | "happy-cat-face", 27 | "slightly-smiling-face", 28 | "smiling-face-with-sunglasses", 29 | ] 30 | 31 | guard let resourceURL = Bundle.module.url(forResource: faces.randomElement(), withExtension: "jpg") else { 32 | throw HTTPError(.internalServerError) 33 | } 34 | 35 | let image = try Data(contentsOf: resourceURL) 36 | 37 | return Response( 38 | status: .ok, 39 | headers: [.contentType: "image/jpg"], 40 | body: .init(byteBuffer: ByteBuffer(bytes: image)) 41 | ) 42 | } 43 | 44 | let app = Application( 45 | router: router, 46 | configuration: configuration, 47 | logger: Logger(label: "hello-with-resources") 48 | ) 49 | 50 | return app 51 | } 52 | -------------------------------------------------------------------------------- /Examples/HelloWorldWithResources/Sources/resources/happy-cat-face.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apple/swift-container-plugin/9417b634d9d7afc3816505c05226b96691d1708c/Examples/HelloWorldWithResources/Sources/resources/happy-cat-face.jpg -------------------------------------------------------------------------------- /Examples/HelloWorldWithResources/Sources/resources/slightly-smiling-face.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apple/swift-container-plugin/9417b634d9d7afc3816505c05226b96691d1708c/Examples/HelloWorldWithResources/Sources/resources/slightly-smiling-face.jpg -------------------------------------------------------------------------------- /Examples/HelloWorldWithResources/Sources/resources/smiling-face-with-sunglasses.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apple/swift-container-plugin/9417b634d9d7afc3816505c05226b96691d1708c/Examples/HelloWorldWithResources/Sources/resources/smiling-face-with-sunglasses.jpg -------------------------------------------------------------------------------- /NOTICE.txt: -------------------------------------------------------------------------------- 1 | 2 | The SwiftContainerPlugin Project 3 | ================================== 4 | 5 | Please visit the SwiftContainerPlugin web site for more information: 6 | 7 | * https://github.com/apple/swift-container-plugin 8 | 9 | Copyright 2024 The SwiftContainerPlugin Project 10 | 11 | The SwiftContainerPlugin Project licenses this file to you under the Apache 12 | License, version 2.0 (the "License"); you may not use this file except in 13 | compliance with the License. You may obtain a copy of the License at: 14 | 15 | https://www.apache.org/licenses/LICENSE-2.0 16 | 17 | Unless required by applicable law or agreed to in writing, software 18 | distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 19 | WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 20 | License for the specific language governing permissions and limitations 21 | under the License. 22 | 23 | Also, please refer to each LICENSE.txt file, which is located in 24 | the 'license' directory of the distribution file, for the license terms of the 25 | components that this product depends on. 26 | 27 | ------------------------------------------------------------------------------- 28 | 29 | This product contains derivations of various scripts and templates from SwiftNIO. 30 | 31 | * LICENSE (Apache License 2.0): 32 | * https://www.apache.org/licenses/LICENSE-2.0 33 | * HOMEPAGE: 34 | * https://github.com/apple/swift-nio 35 | 36 | ------------------------------------------------------------------------------- 37 | 38 | This product contains derivations of templates and proposal workflows from Swift. 39 | 40 | * LICENSE (Apache License 2.0): 41 | * https://swift.org/LICENSE.txt 42 | * HOMEPAGE: 43 | * https://github.com/apple/swift 44 | 45 | ------------------------------------------------------------------------------- 46 | 47 | This product contains derivations of 'AuthorizationProvider' from Swift Package 48 | Manager. 49 | 50 | * LICENSE (Apache License 2.0): 51 | * https://www.apache.org/licenses/LICENSE-2.0 52 | * HOMEPAGE: 53 | * https://github.com/apple/swift-package-manager 54 | 55 | ------------------------------------------------------------------------------- 56 | 57 | This product contains derivations of 'CNIOExtrasZlib' and the 'gzip' 58 | function from SwiftNIO Extras. 59 | 60 | * LICENSE (Apache License 2.0): 61 | * https://www.apache.org/licenses/LICENSE-2.0 62 | * HOMEPAGE: 63 | * https://github.com/swift-server/swift-nio-extras 64 | 65 | ------------------------------------------------------------------------------- 66 | 67 | This product contains samples of how to use Swift Container Plugin with the 68 | following other projects: 69 | 70 | * Distribution Registry 71 | * LICENSE (Apache License 2.0): 72 | * https://www.apache.org/licenses/LICENSE-2.0 73 | * HOMEPAGE: 74 | * https://github.com/distribution/distribution 75 | 76 | * Vapor 77 | * LICENSE (MIT License): 78 | * https://mit-license.org 79 | * HOMEPAGE: 80 | * https://github.com/vapor/vapor 81 | 82 | * Hummingbird 83 | * LICENSE (Apache License 2.0): 84 | * https://www.apache.org/licenses/LICENSE-2.0 85 | * HOMEPAGE: 86 | * https://github.com/hummingbird-project/hummingbird 87 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 6.0 2 | 3 | //===----------------------------------------------------------------------===// 4 | // 5 | // This source file is part of the SwiftContainerPlugin open source project 6 | // 7 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 8 | // Licensed under Apache License v2.0 9 | // 10 | // See LICENSE.txt for license information 11 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 12 | // 13 | // SPDX-License-Identifier: Apache-2.0 14 | // 15 | //===----------------------------------------------------------------------===// 16 | 17 | import PackageDescription 18 | 19 | let package = Package( 20 | name: "swift-container-plugin", 21 | platforms: [.macOS(.v13)], 22 | products: [ 23 | .executable(name: "containertool", targets: ["containertool"]), 24 | .plugin(name: "ContainerImageBuilder", targets: ["ContainerImageBuilder"]), 25 | ], 26 | dependencies: [ 27 | .package(url: "https://github.com/apple/swift-crypto.git", "1.0.0"..<"4.0.0"), 28 | .package(url: "https://github.com/apple/swift-argument-parser", from: "1.3.0"), 29 | .package(url: "https://github.com/apple/swift-http-types.git", from: "1.2.0"), 30 | .package(url: "https://github.com/swiftlang/swift-docc-plugin", from: "1.0.0"), 31 | ], 32 | targets: [ 33 | .target( 34 | name: "ContainerRegistry", 35 | dependencies: [ 36 | .target(name: "Basics"), .product(name: "Crypto", package: "swift-crypto"), 37 | .product(name: "HTTPTypes", package: "swift-http-types"), 38 | .product(name: "HTTPTypesFoundation", package: "swift-http-types"), 39 | ] 40 | ), 41 | .executableTarget( 42 | name: "containertool", 43 | dependencies: [ 44 | .target(name: "ContainerRegistry"), 45 | .target(name: "VendorCNIOExtrasZlib"), 46 | .target(name: "Tar"), 47 | .product(name: "ArgumentParser", package: "swift-argument-parser"), 48 | ], 49 | swiftSettings: [.swiftLanguageMode(.v5)] 50 | ), 51 | .target( 52 | // Vendored from https://github.com/apple/swift-nio-extras 53 | name: "VendorCNIOExtrasZlib", 54 | dependencies: [], 55 | path: "Vendor/github.com/apple/swift-nio-extras/Sources/CNIOExtrasZlib", 56 | linkerSettings: [.linkedLibrary("z")] 57 | ), 58 | .target(name: "Tar"), 59 | .target( 60 | // Vendored from https://github.com/apple/swift-package-manager with modifications 61 | name: "Basics", 62 | path: "Vendor/github.com/apple/swift-package-manager/Sources/Basics" 63 | ), 64 | .plugin( 65 | name: "ContainerImageBuilder", 66 | capability: .command( 67 | intent: .custom( 68 | verb: "build-container-image", 69 | description: "Builds a container image for the specified target" 70 | ), 71 | permissions: [ 72 | .allowNetworkConnections( 73 | // scope: .all(ports: [443, 5000, 8080, 70000]), 74 | scope: .all(), 75 | reason: "This command publishes images to container registries over the network" 76 | ) 77 | ] 78 | ), 79 | dependencies: [.target(name: "containertool")] 80 | ), 81 | // Empty target which builds high-level, user-facing documentation about using the plugin from the command-line. 82 | .target( 83 | name: "swift-container-plugin", 84 | exclude: ["README.md"] 85 | ), 86 | .testTarget( 87 | name: "ContainerRegistryTests", 88 | dependencies: [.target(name: "ContainerRegistry")], 89 | resources: [.process("Resources")] 90 | ), 91 | .testTarget(name: "containertoolTests", dependencies: [.target(name: "containertool")]), 92 | .testTarget(name: "TarTests", dependencies: [.target(name: "Tar")]), 93 | ], 94 | swiftLanguageModes: [.v6] 95 | ) 96 | -------------------------------------------------------------------------------- /Plugins/ContainerImageBuilder/Pipe+lines.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import class Foundation.Pipe 16 | 17 | extension Pipe { 18 | var lines: AsyncThrowingStream { 19 | AsyncThrowingStream { continuation in 20 | self.fileHandleForReading.readabilityHandler = { [unowned self] fileHandle in 21 | // Reading blocks until data is available. We should not see 0 byte reads. 22 | let data = fileHandle.availableData 23 | if data.isEmpty { // EOF 24 | continuation.finish() 25 | 26 | // Clean up the handler to prevent repeated calls and continuation finishes for the same process. 27 | self.fileHandleForReading.readabilityHandler = nil 28 | return 29 | } 30 | 31 | let s = String(data: data, encoding: .utf8)! 32 | continuation.yield(s) 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Plugins/ContainerImageBuilder/main.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | import PackagePlugin 17 | 18 | enum PluginError: Error { 19 | case argumentError(String) 20 | case buildError 21 | case productNotExecutable(String) 22 | } 23 | 24 | extension PluginError: CustomStringConvertible { 25 | /// Description of the error 26 | public var description: String { 27 | switch self { 28 | case let .argumentError(s): return s 29 | case .buildError: return "Build failed" 30 | case let .productNotExecutable(productName): 31 | return "\(productName) is not an executable product and cannot be used as a container entrypoint." 32 | } 33 | } 34 | } 35 | 36 | @main struct ContainerImageBuilder: CommandPlugin { 37 | /// Main entry point of the plugin. 38 | /// - Parameters: 39 | /// - context: A `PluginContext` which gives access to Swift Package Manager. 40 | /// - arguments: Additional command line arguments for the plugin. 41 | /// - Throws: If the product cannot be built or if packaging and uploading it fails. 42 | func performCommand(context: PluginContext, arguments: [String]) async throws { 43 | var extractor = ArgumentExtractor(arguments) 44 | if extractor.extractFlag(named: "help") > 0 { 45 | print( 46 | """ 47 | USAGE: build-container-image 48 | 49 | OPTIONS 50 | --product Product to include in the image 51 | 52 | Other arguments are passed to the containertool helper. 53 | """ 54 | ) 55 | return 56 | } 57 | 58 | // The plugin must extract the --product argument, if present, so it can ask Swift Package Manager to rebuild it. 59 | // All other arguments can be passed straight through to the helper tool. 60 | // 61 | // * If --product is specified on the command line, use it. 62 | // * Otherwise, if the package only defines one product, use that. 63 | // * Otherwise there are multiple possible products, so the user must choose which one to use. 64 | 65 | let executableProducts = context.package.products.filter { $0 is ExecutableProduct }.map { $0.name } 66 | 67 | let productName: String 68 | if let productArg = extractor.extractOption(named: "product").first { 69 | guard executableProducts.contains(productArg) else { throw PluginError.productNotExecutable(productArg) } 70 | productName = productArg 71 | } else if executableProducts.count == 1 { 72 | productName = executableProducts[0] 73 | } else { 74 | throw PluginError.argumentError("Please specify which executable product to include in the image") 75 | } 76 | 77 | // Ask the plugin host (SwiftPM or an IDE) to build our product. 78 | Diagnostics.remark("Building product: \(productName)") 79 | let result = try packageManager.build( 80 | .product(productName), 81 | parameters: .init(configuration: .inherit, echoLogs: true) 82 | ) 83 | 84 | // Check the result. Ideally this would report more details. 85 | guard result.succeeded else { throw PluginError.buildError } 86 | 87 | // Get the list of built executables from the build result. 88 | let builtExecutables = result.builtArtifacts.filter { $0.kind == .executable } 89 | 90 | for built in builtExecutables { Diagnostics.remark("Built product: \(built.url.path)") } 91 | 92 | let resources = builtExecutables[0].url 93 | .deletingLastPathComponent() 94 | .appendingPathComponent( 95 | "\(context.package.displayName)_\(productName).resources" 96 | ) 97 | 98 | // Run a command line helper to upload the image 99 | let helperURL = try context.tool(named: "containertool").url 100 | let helperArgs = 101 | (FileManager.default.fileExists(atPath: resources.path) ? ["--resources", resources.path] : []) 102 | + extractor.remainingArguments 103 | + builtExecutables.map { $0.url.path } 104 | let helperEnv = ProcessInfo.processInfo.environment.filter { $0.key.starts(with: "CONTAINERTOOL_") } 105 | 106 | let err = Pipe() 107 | 108 | await withThrowingTaskGroup(of: Void.self) { group in 109 | group.addTask { 110 | enum LoggingState { 111 | // Normal output is logged at 'progress' level. 112 | case progress 113 | 114 | // If an error is detected, all output from that point onwards is logged at 'error' level, which is always printed. 115 | // Errors are reported even without the --verbose flag and cause the build to return a nonzero exit code. 116 | case error 117 | 118 | func log(_ msg: String) { 119 | let trimmed = msg.trimmingCharacters(in: .newlines) 120 | switch self { 121 | case .progress: Diagnostics.progress(trimmed) 122 | case .error: Diagnostics.error(trimmed) 123 | } 124 | } 125 | } 126 | 127 | var buf = "" 128 | var logger = LoggingState.progress 129 | 130 | for try await line in err.lines { 131 | buf.append(line) 132 | 133 | guard let (before, after) = buf.splitOn(first: "\n") else { 134 | continue 135 | } 136 | 137 | var msg = before 138 | buf = String(after) 139 | 140 | let errorLabel = "Error: " // SwiftArgumentParser adds this prefix to all errors which bubble up 141 | if msg.starts(with: errorLabel) { 142 | logger = .error 143 | msg.trimPrefix(errorLabel) 144 | } 145 | 146 | logger.log(String(msg)) 147 | } 148 | 149 | // Print any leftover output in the buffer, in case the child exited without sending a final newline. 150 | if !buf.isEmpty { 151 | logger.log(buf) 152 | } 153 | } 154 | 155 | group.addTask { 156 | try await run(command: helperURL, arguments: helperArgs, environment: helperEnv, errorPipe: err) 157 | } 158 | } 159 | } 160 | } 161 | 162 | extension Collection where Element: Equatable { 163 | func splitOn(first element: Element) -> (before: SubSequence, after: SubSequence)? { 164 | guard let idx = self.firstIndex(of: element) else { 165 | return nil 166 | } 167 | 168 | return (self[.. BearerChallenge { 71 | let nonQuote = try Regex(#"[^"]"#) 72 | let kv = Regex { 73 | Capture { OneOrMore { .word } } 74 | "=" 75 | "\"" 76 | Capture { OneOrMore { nonQuote } } 77 | "\"" 78 | } 79 | let commaKV = Regex { 80 | "," 81 | kv 82 | } 83 | 84 | var res = BearerChallenge() 85 | 86 | var s = Substring(s) 87 | 88 | guard let match = s.prefixMatch(of: kv) else { throw ChallengeParserError.prefixMatchFailed(String(s)) } 89 | 90 | switch match.1 { 91 | case "realm": res.realm = String(match.2) 92 | case "service": res.service = String(match.2) 93 | case "scope": res.scope.append(String(match.2)) 94 | default: res.other.append((String(match.1), String(match.2))) 95 | } 96 | s.trimPrefix(match.0) 97 | 98 | while let match = s.prefixMatch(of: commaKV) { 99 | switch match.1 { 100 | case "realm": res.realm = String(match.2) 101 | case "service": res.service = String(match.2) 102 | case "scope": res.scope.append(String(match.2)) 103 | default: res.other.append((String(match.1), String(match.2))) 104 | } 105 | s.trimPrefix(match.0) 106 | } 107 | 108 | if s != "" { throw ChallengeParserError.leftoverCharacters(String(s)) } 109 | 110 | return res 111 | } 112 | 113 | public enum AuthChallenge: Equatable { 114 | case none 115 | case basic(String) 116 | case bearer(String) 117 | 118 | init(challenge: String) { 119 | if challenge.lowercased().starts(with: "basic") { 120 | self = .basic(challenge) 121 | } else if challenge.lowercased().starts(with: "bearer") { 122 | self = .bearer(challenge) 123 | } else { 124 | self = .none 125 | } 126 | } 127 | } 128 | 129 | /// AuthHandler manages provides credentials for HTTP requests 130 | public struct AuthHandler { 131 | var username: String? 132 | var password: String? 133 | 134 | var auth: AuthorizationProvider? = nil 135 | /// Create an AuthHandler 136 | /// - Parameters: 137 | /// - username: Default username, used if no other suitable credentials are available. 138 | /// - password: Default password, used if no other suitable credentials are available. 139 | /// - auth: AuthorizationProvider capable of querying credential stores such as netrc files. 140 | public init(username: String? = nil, password: String? = nil, auth: AuthorizationProvider? = nil) { 141 | self.username = username 142 | self.password = password 143 | self.auth = auth 144 | } 145 | 146 | /// Get locally-configured credentials, such as netrc or username/password, for a host 147 | func localCredentials(forURL url: URL) -> String? { 148 | if let netrcEntry = auth?.httpAuthorizationHeader(for: url) { return netrcEntry } 149 | 150 | if let username, let password { 151 | let authorization = Data("\(username):\(password)".utf8).base64EncodedString() 152 | return "Basic \(authorization)" 153 | } 154 | 155 | // No suitable authentication methods available 156 | return nil 157 | } 158 | 159 | public func auth( 160 | registry: URL, 161 | repository: ImageReference.Repository, 162 | actions: [String], 163 | withScheme scheme: AuthChallenge, 164 | usingClient client: HTTPClient 165 | ) async throws -> String? { 166 | switch scheme { 167 | case .none: return nil 168 | case .basic: return localCredentials(forURL: registry) 169 | 170 | case .bearer(let challenge): 171 | // Preemptively offer suitable basic auth credentials to the token server. 172 | // Instead of challenging, public token servers often return anonymous tokens when no credentials are offered. 173 | // These tokens allow pull access to public repositories, but attempts to push will fail with 'unauthorized'. 174 | // There is no obvious prompt for the client to retry with authentication. 175 | var parsedChallenge = try parseChallenge( 176 | challenge.dropFirst("bearer".count).trimmingCharacters(in: .whitespacesAndNewlines) 177 | ) 178 | parsedChallenge.scope = ["repository:\(repository):\(actions.joined(separator: ","))"] 179 | guard let challengeURL = parsedChallenge.url else { return nil } 180 | var tokenRequest = HTTPRequest(url: challengeURL) 181 | if let credentials = localCredentials(forURL: challengeURL) { 182 | tokenRequest.headerFields[.authorization] = credentials 183 | } 184 | 185 | let (data, _) = try await client.executeRequestThrowing(tokenRequest, expectingStatus: .ok) 186 | let tokenResponse = try JSONDecoder().decode(BearerTokenResponse.self, from: data) 187 | return "Bearer \(tokenResponse.token)" 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/HTTPClient.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | #if canImport(FoundationNetworking) 17 | import FoundationNetworking 18 | #endif 19 | import HTTPTypes 20 | import HTTPTypesFoundation 21 | 22 | // HEAD does not include a response body so if an error is thrown, data will be nil 23 | public enum HTTPClientError: Error { 24 | case unexpectedStatusCode(status: HTTPResponse.Status, response: HTTPResponse, data: Data?) 25 | case unexpectedContentType(String) 26 | case missingContentType 27 | case missingResponseHeader(String) 28 | case authenticationChallenge(challenge: String, request: HTTPRequest, response: HTTPResponse) 29 | case unauthorized(request: HTTPRequest, response: HTTPResponse) 30 | } 31 | 32 | /// HTTPClient is an abstract HTTP client interface capable of uploads and downloads. 33 | public protocol HTTPClient { 34 | /// Execute an HTTP request with no request body. 35 | /// - Parameters: 36 | /// - request: The HTTP request to execute. 37 | /// - expectingStatus: The HTTP status code expected if the request is successful. 38 | /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. 39 | /// - Throws: If the server response is unexpected or indicates that an error occurred. 40 | func executeRequestThrowing(_ request: HTTPRequest, expectingStatus: HTTPResponse.Status) async throws -> ( 41 | Data, HTTPResponse 42 | ) 43 | 44 | /// Execute an HTTP request uploading a request body. 45 | /// - Parameters: 46 | /// - request: The HTTP request to execute. 47 | /// - uploading: The request body to upload. 48 | /// - expectingStatus: The HTTP status code expected if the request is successful. 49 | /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. 50 | /// - Throws: If the server response is unexpected or indicates that an error occurred. 51 | func executeRequestThrowing(_ request: HTTPRequest, uploading: Data, expectingStatus: HTTPResponse.Status) 52 | async throws -> (Data, HTTPResponse) 53 | } 54 | 55 | extension URLSession: HTTPClient { 56 | /// Check that a registry response has the correct status code and does not report an error. 57 | /// - Parameters: 58 | /// - request: The request made to the registry. 59 | /// - response: The response from the registry. 60 | /// - responseData: The raw response body data returned by the registry. 61 | /// - successfulStatus: The successful HTTP response expected from this request. 62 | /// - Returns: An HTTPResponse representing the response, if the response was valid. 63 | /// - Throws: If the server response is unexpected or indicates that an error occurred. 64 | func validateAPIResponseThrowing( 65 | request: HTTPRequest, 66 | response: HTTPResponse, 67 | responseData: Data, 68 | expectingStatus successfulStatus: HTTPResponse.Status 69 | ) throws -> HTTPResponse { 70 | // Convert errors into exceptions 71 | guard response.status == successfulStatus else { 72 | // If the response includes an authentication challenge the client can try again, 73 | // presenting the challenge response. 74 | if response.status == .unauthorized { 75 | if let authChallenge = response.headerFields[.wwwAuthenticate] { 76 | throw HTTPClientError.authenticationChallenge( 77 | challenge: authChallenge.trimmingCharacters(in: .whitespacesAndNewlines), 78 | request: request, 79 | response: response 80 | ) 81 | } 82 | } 83 | 84 | // A HEAD request has no response body and cannot be decoded 85 | if request.method == .head { 86 | throw HTTPClientError.unexpectedStatusCode(status: response.status, response: response, data: nil) 87 | } 88 | throw HTTPClientError.unexpectedStatusCode(status: response.status, response: response, data: responseData) 89 | } 90 | 91 | return response 92 | } 93 | 94 | /// Execute an HTTP request with no request body. 95 | /// - Parameters: 96 | /// - request: The HTTP request to execute. 97 | /// - success: The HTTP status code expected if the request is successful. 98 | /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. 99 | /// - Throws: If the server response is unexpected or indicates that an error occurred. 100 | public func executeRequestThrowing(_ request: HTTPRequest, expectingStatus success: HTTPResponse.Status) 101 | async throws -> (Data, HTTPResponse) 102 | { 103 | let (responseData, urlResponse) = try await data(for: request) 104 | let httpResponse = try validateAPIResponseThrowing( 105 | request: request, 106 | response: urlResponse, 107 | responseData: responseData, 108 | expectingStatus: success 109 | ) 110 | return (responseData, httpResponse) 111 | } 112 | 113 | /// Execute an HTTP request uploading a request body. 114 | /// - Parameters: 115 | /// - request: The HTTP request to execute. 116 | /// - payload: The request body to upload. 117 | /// - success: The HTTP status code expected if the request is successful. 118 | /// - Returns: An asynchronously-delivered tuple that contains the raw response body as a Data instance, and a HTTPResponse. 119 | /// - Throws: If the server response is unexpected or indicates that an error occurred. 120 | public func executeRequestThrowing( 121 | _ request: HTTPRequest, 122 | uploading payload: Data, 123 | expectingStatus success: HTTPResponse.Status 124 | ) async throws -> (Data, HTTPResponse) { 125 | let (responseData, urlResponse) = try await upload(for: request, from: payload) 126 | let httpResponse = try validateAPIResponseThrowing( 127 | request: request, 128 | response: urlResponse, 129 | responseData: responseData, 130 | expectingStatus: success 131 | ) 132 | return (responseData, httpResponse) 133 | } 134 | } 135 | 136 | extension HTTPRequest { 137 | /// Constructs a HTTPRequest pre-configured with method, url and content types. 138 | /// - Parameters: 139 | /// - method: HTTP method to use: "GET", "PUT" etc 140 | /// - url: The URL on which to operate. 141 | /// - accepting: A list of acceptable content-types. 142 | /// - contentType: The content-type of the request's body data, if any. 143 | /// - authorization: Authorization credentials for this request. 144 | init( 145 | method: HTTPRequest.Method, 146 | url: URL, 147 | accepting: [String] = [], 148 | contentType: String? = nil, 149 | withAuthorization authorization: String? = nil 150 | ) { 151 | self.init(url: url) 152 | self.method = method 153 | if let contentType { headerFields[.contentType] = contentType } 154 | if accepting.count > 0 { headerFields[values: .accept] = accepting } 155 | 156 | // The URLSession documentation warns not to do this: 157 | // https://developer.apple.com/documentation/foundation/urlsessionconfiguration/1411532-httpadditionalheaders#discussion 158 | // However this is the best option when URLSession does not support the server's authentication scheme: 159 | // https://developer.apple.com/forums/thread/89811 160 | if let authorization { headerFields[.authorization] = authorization } 161 | } 162 | 163 | static func get( 164 | _ url: URL, 165 | accepting: [String] = [], 166 | contentType: String? = nil, 167 | withAuthorization authorization: String? = nil 168 | ) -> HTTPRequest { 169 | .init(method: .get, url: url, accepting: accepting, contentType: contentType, withAuthorization: authorization) 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/ImageDestination.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.Data 16 | 17 | /// A destination, such as a registry, to which container images can be uploaded. 18 | public protocol ImageDestination { 19 | /// Checks whether a blob exists. 20 | /// 21 | /// - Parameters: 22 | /// - repository: Name of the destination repository. 23 | /// - digest: Digest of the requested blob. 24 | /// - Returns: True if the blob exists, otherwise false. 25 | /// - Throws: If the destination encounters an error. 26 | func blobExists( 27 | repository: ImageReference.Repository, 28 | digest: ImageReference.Digest 29 | ) async throws -> Bool 30 | 31 | /// Uploads a blob of unstructured data. 32 | /// 33 | /// - Parameters: 34 | /// - repository: Name of the destination repository. 35 | /// - mediaType: mediaType field for returned ContentDescriptor. 36 | /// On the wire, all blob uploads are `application/octet-stream'. 37 | /// - data: Object to be uploaded. 38 | /// - Returns: An ContentDescriptor object representing the 39 | /// uploaded blob. 40 | /// - Throws: If the upload fails. 41 | func putBlob( 42 | repository: ImageReference.Repository, 43 | mediaType: String, 44 | data: Data 45 | ) async throws -> ContentDescriptor 46 | 47 | /// Encodes and uploads a JSON object. 48 | /// 49 | /// - Parameters: 50 | /// - repository: Name of the destination repository. 51 | /// - mediaType: mediaType field for returned ContentDescriptor. 52 | /// On the wire, all blob uploads are `application/octet-stream'. 53 | /// - data: Object to be uploaded. 54 | /// - Returns: An ContentDescriptor object representing the 55 | /// uploaded blob. 56 | /// - Throws: If the blob cannot be encoded or the upload fails. 57 | /// 58 | /// Some JSON objects, such as ImageConfiguration, are stored 59 | /// in the registry as plain blobs with MIME type "application/octet-stream". 60 | /// This function encodes the data parameter and uploads it as a generic blob. 61 | func putBlob( 62 | repository: ImageReference.Repository, 63 | mediaType: String, 64 | data: Body 65 | ) async throws -> ContentDescriptor 66 | 67 | /// Encodes and uploads an image manifest. 68 | /// 69 | /// - Parameters: 70 | /// - repository: Name of the destination repository. 71 | /// - reference: Optional tag to apply to this manifest. 72 | /// - manifest: Manifest to be uploaded. 73 | /// - Returns: An ContentDescriptor object representing the 74 | /// uploaded blob. 75 | /// - Throws: If the blob cannot be encoded or the upload fails. 76 | /// 77 | /// Manifests are not treated as blobs by the distribution specification. 78 | /// They have their own MIME types and are uploaded to different 79 | /// registry endpoints than blobs. 80 | func putManifest( 81 | repository: ImageReference.Repository, 82 | reference: (any ImageReference.Reference)?, 83 | manifest: ImageManifest 84 | ) async throws -> ContentDescriptor 85 | 86 | /// Encodes and uploads an image index. 87 | /// 88 | /// - Parameters: 89 | /// - repository: Name of the destination repository. 90 | /// - reference: Optional tag to apply to this index. 91 | /// - index: Index to be uploaded. 92 | /// - Returns: An ContentDescriptor object representing the 93 | /// uploaded index. 94 | /// - Throws: If the index cannot be encoded or the upload fails. 95 | /// 96 | /// An index is a type of manifest. Manifests are not treated as blobs 97 | /// by the distribution specification. They have their own MIME types 98 | /// and are uploaded to different endpoint. 99 | func putIndex( 100 | repository: ImageReference.Repository, 101 | reference: (any ImageReference.Reference)?, 102 | index: ImageIndex 103 | ) async throws -> ContentDescriptor 104 | } 105 | 106 | extension ImageDestination { 107 | /// Uploads a blob of unstructured data. 108 | /// 109 | /// - Parameters: 110 | /// - repository: Name of the destination repository. 111 | /// - mediaType: mediaType field for returned ContentDescriptor. 112 | /// On the wire, all blob uploads are `application/octet-stream'. 113 | /// - data: Object to be uploaded. 114 | /// - Returns: An ContentDescriptor object representing the 115 | /// uploaded blob. 116 | /// - Throws: If the upload fails. 117 | public func putBlob( 118 | repository: ImageReference.Repository, 119 | mediaType: String = "application/octet-stream", 120 | data: Data 121 | ) async throws -> ContentDescriptor { 122 | try await putBlob(repository: repository, mediaType: mediaType, data: data) 123 | } 124 | 125 | /// Upload an image configuration record to the registry. 126 | /// - Parameters: 127 | /// - image: Reference to the image associated with the record. 128 | /// - configuration: An image configuration record 129 | /// - Returns: An `ContentDescriptor` referring to the blob stored in the registry. 130 | /// - Throws: If the blob upload fails. 131 | /// 132 | /// Image configuration records are stored as blobs in the registry. This function encodes the provided configuration record and stores it as a blob in the registry. 133 | public func putImageConfiguration( 134 | forImage image: ImageReference, 135 | configuration: ImageConfiguration 136 | ) async throws -> ContentDescriptor { 137 | try await putBlob( 138 | repository: image.repository, 139 | mediaType: "application/vnd.oci.image.config.v1+json", 140 | data: configuration 141 | ) 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/ImageReference+Digest.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | import HTTPTypes 17 | import struct Crypto.SHA256 18 | import struct Crypto.SHA512 19 | 20 | // The distribution spec says that Docker- prefix headers are no longer to be used, 21 | // but also specifies that the registry digest is returned in this header. 22 | // https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests 23 | extension HTTPField.Name { 24 | static let dockerContentDigest = Self("Docker-Content-Digest")! 25 | } 26 | 27 | extension ImageReference.Digest { 28 | /// Calculate the digest of a blob of data. 29 | /// - Parameters: 30 | /// - data: Blob of data to digest. 31 | /// - algorithm: Digest algorithm to use. 32 | public init( 33 | of data: Blob, 34 | algorithm: ImageReference.Digest.Algorithm = .sha256 35 | ) { 36 | // SHA256 is required; some registries might also support SHA512 37 | switch algorithm { 38 | case .sha256: 39 | let hash = SHA256.hash(data: data) 40 | let digest = hash.compactMap { String(format: "%02x", $0) }.joined() 41 | try! self.init("sha256:" + digest) 42 | 43 | case .sha512: 44 | let hash = SHA512.hash(data: data) 45 | let digest = hash.compactMap { String(format: "%02x", $0) }.joined() 46 | try! self.init("sha512:" + digest) 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/ImageSource.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.Data 16 | import class Foundation.JSONEncoder 17 | 18 | /// Create a JSONEncoder configured according to the requirements of the image specification. 19 | func containerJSONEncoder() -> JSONEncoder { 20 | let encoder = JSONEncoder() 21 | encoder.outputFormatting = [.sortedKeys, .prettyPrinted, .withoutEscapingSlashes] 22 | encoder.dateEncodingStrategy = .iso8601 23 | return encoder 24 | } 25 | 26 | /// A source, such as a registry, from which container images can be fetched. 27 | public protocol ImageSource { 28 | /// Fetches a blob of unstructured data. 29 | /// 30 | /// - Parameters: 31 | /// - repository: Name of the source repository. 32 | /// - digest: Digest of the blob. 33 | /// - Returns: The downloaded data. 34 | /// - Throws: If the blob download fails. 35 | func getBlob( 36 | repository: ImageReference.Repository, 37 | digest: ImageReference.Digest 38 | ) async throws -> Data 39 | 40 | /// Fetches an image manifest. 41 | /// 42 | /// - Parameters: 43 | /// - repository: Name of the source repository. 44 | /// - reference: Tag or digest of the manifest to fetch. 45 | /// - Returns: The downloaded manifest. 46 | /// - Throws: If the download fails or the manifest cannot be decoded. 47 | func getManifest( 48 | repository: ImageReference.Repository, 49 | reference: any ImageReference.Reference 50 | ) async throws -> (ImageManifest, ContentDescriptor) 51 | 52 | /// Fetches an image index. 53 | /// 54 | /// - Parameters: 55 | /// - repository: Name of the source repository. 56 | /// - reference: Tag or digest of the index to fetch. 57 | /// - Returns: The downloaded index. 58 | /// - Throws: If the download fails or the index cannot be decoded. 59 | func getIndex( 60 | repository: ImageReference.Repository, 61 | reference: any ImageReference.Reference 62 | ) async throws -> ImageIndex 63 | 64 | /// Fetches an image configuration from the registry. 65 | /// 66 | /// - Parameters: 67 | /// - image: Reference to the image containing the record. 68 | /// - digest: Digest of the configuration object to fetch. 69 | /// - Returns: The image confguration record. 70 | /// - Throws: If the download fails or the configuration record cannot be decoded. 71 | /// 72 | /// Image configuration records are stored as blobs in the registry. This function retrieves 73 | /// the requested blob and tries to decode it as a configuration record. 74 | func getImageConfiguration( 75 | forImage image: ImageReference, 76 | digest: ImageReference.Digest 77 | ) async throws -> ImageConfiguration 78 | } 79 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/RegistryClient+CheckAPI.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.URL 16 | 17 | extension RegistryClient { 18 | /// Checks whether the registry supports v2 of the distribution specification. 19 | /// - Returns: an `true` if the registry supports the distribution specification. 20 | /// - Throws: if the registry does not support the distribution specification. 21 | public static func checkAPI(client: HTTPClient, registryURL: URL) async throws -> AuthChallenge { 22 | // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#determining-support 23 | 24 | // The registry indicates that it supports the v2 protocol by returning a 200 OK response. 25 | // Many registries also set `Content-Type: application/json` and return empty JSON objects, 26 | // but this is not required and some do not. 27 | // The registry may require authentication on this endpoint. 28 | 29 | do { 30 | // Using the bare HTTP client because this is the only endpoint which does not include a repository path 31 | // and to avoid RegistryClient's auth handling 32 | let _ = try await client.executeRequestThrowing( 33 | .get(registryURL.distributionEndpoint, withAuthorization: nil), 34 | expectingStatus: .ok 35 | ) 36 | return .none 37 | 38 | } catch HTTPClientError.authenticationChallenge(let challenge, _, _) { return .init(challenge: challenge) } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/RegistryClient+ImageDestination.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.Data 16 | import struct Foundation.URL 17 | import HTTPTypes 18 | 19 | extension RegistryClient: ImageDestination { 20 | // Internal helper method to initiate a blob upload in 'two shot' mode 21 | func startBlobUploadSession(repository: ImageReference.Repository) async throws -> URL { 22 | // Upload in "two shot" mode. 23 | // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#post-then-put 24 | // - POST to obtain a session ID. 25 | // - Do not include the digest. 26 | // Response will include a 'Location' header telling us where to PUT the blob data. 27 | let httpResponse = try await executeRequestThrowing( 28 | .post(repository, path: "blobs/uploads/"), 29 | expectingStatus: .accepted, // expected response code for a "two-shot" upload 30 | decodingErrors: [.notFound] 31 | ) 32 | 33 | guard let location = httpResponse.response.headerFields[.location] else { 34 | throw HTTPClientError.missingResponseHeader("Location") 35 | } 36 | 37 | guard let locationURL = URL(string: location) else { 38 | throw RegistryClientError.invalidUploadLocation("\(location)") 39 | } 40 | 41 | // The location may be either an absolute URL or a relative URL 42 | // If it is relative we need to make it absolute 43 | guard locationURL.host != nil else { 44 | guard let absoluteURL = URL(string: location, relativeTo: registryURL) else { 45 | throw RegistryClientError.invalidUploadLocation("\(location)") 46 | } 47 | return absoluteURL 48 | } 49 | 50 | return locationURL 51 | } 52 | 53 | /// Checks whether a blob exists. 54 | /// 55 | /// - Parameters: 56 | /// - repository: Name of the destination repository. 57 | /// - digest: Digest of the requested blob. 58 | /// - Returns: True if the blob exists, otherwise false. 59 | /// - Throws: If the destination encounters an error. 60 | public func blobExists( 61 | repository: ImageReference.Repository, 62 | digest: ImageReference.Digest 63 | ) async throws -> Bool { 64 | do { 65 | let _ = try await executeRequestThrowing( 66 | .head(repository, path: "blobs/\(digest)"), 67 | decodingErrors: [.notFound] 68 | ) 69 | return true 70 | } catch HTTPClientError.unexpectedStatusCode(status: .notFound, _, _) { return false } 71 | } 72 | 73 | /// Uploads a blob to the registry. 74 | /// 75 | /// This function uploads a blob of unstructured data to the registry. 76 | /// - Parameters: 77 | /// - repository: Name of the destination repository. 78 | /// - mediaType: mediaType field for returned ContentDescriptor. 79 | /// On the wire, all blob uploads are `application/octet-stream'. 80 | /// - data: Object to be uploaded. 81 | /// - Returns: An ContentDescriptor object representing the 82 | /// uploaded blob. 83 | /// - Throws: If the blob cannot be encoded or the upload fails. 84 | public func putBlob( 85 | repository: ImageReference.Repository, 86 | mediaType: String = "application/octet-stream", 87 | data: Data 88 | ) async throws -> ContentDescriptor { 89 | // Ask the server to open a session and tell us where to upload our data 90 | let location = try await startBlobUploadSession(repository: repository) 91 | 92 | // Append the digest to the upload location, as the specification requires. 93 | // The server's URL is arbitrary and might already contain query items which we must not overwrite. 94 | // The URL could even point to a different host. 95 | let digest = ImageReference.Digest(of: data) 96 | let uploadURL = location.appending(queryItems: [.init(name: "digest", value: "\(digest)")]) 97 | 98 | let httpResponse = try await executeRequestThrowing( 99 | // All blob uploads have Content-Type: application/octet-stream on the wire, even if mediatype is different 100 | .put(repository, url: uploadURL, contentType: "application/octet-stream"), 101 | uploading: data, 102 | expectingStatus: .created, 103 | decodingErrors: [.badRequest, .notFound] 104 | ) 105 | 106 | // The registry could compute a different digest and we should use its value 107 | // as the canonical digest for linking blobs. If the registry sends a digest we 108 | // should check that it matches our locally-calculated digest. 109 | if let serverDigest = httpResponse.response.headerFields[.dockerContentDigest] { 110 | assert("\(digest)" == serverDigest) 111 | } 112 | return .init(mediaType: mediaType, digest: "\(digest)", size: Int64(data.count)) 113 | } 114 | 115 | /// Uploads a blob to the registry. 116 | /// 117 | /// This function converts an encodable blob to an `application/octet-stream', 118 | /// calculates its digest and uploads it to the registry. 119 | /// - Parameters: 120 | /// - repository: Name of the destination repository. 121 | /// - mediaType: mediaType field for returned ContentDescriptor. 122 | /// On the wire, all blob uploads are `application/octet-stream'. 123 | /// - data: Object to be uploaded. 124 | /// - Returns: An ContentDescriptor object representing the 125 | /// uploaded blob. 126 | /// - Throws: If the blob cannot be encoded or the upload fails. 127 | /// 128 | /// Some JSON objects, such as ImageConfiguration, are stored 129 | /// in the registry as plain blobs with MIME type "application/octet-stream". 130 | /// This function encodes the data parameter and uploads it as a generic blob. 131 | public func putBlob( 132 | repository: ImageReference.Repository, 133 | mediaType: String = "application/octet-stream", 134 | data: Body 135 | ) async throws -> ContentDescriptor { 136 | let encoded = try encoder.encode(data) 137 | return try await putBlob(repository: repository, mediaType: mediaType, data: encoded) 138 | } 139 | 140 | /// Encodes and uploads an image manifest. 141 | /// 142 | /// - Parameters: 143 | /// - repository: Name of the destination repository. 144 | /// - reference: Optional tag to apply to this manifest. 145 | /// - manifest: Manifest to be uploaded. 146 | /// - Returns: An ContentDescriptor object representing the 147 | /// uploaded manifest. 148 | /// - Throws: If the manifest cannot be encoded or the upload fails. 149 | /// 150 | /// Manifests are not treated as blobs by the distribution specification. 151 | /// They have their own MIME types and are uploaded to different 152 | public func putManifest( 153 | repository: ImageReference.Repository, 154 | reference: (any ImageReference.Reference)? = nil, 155 | manifest: ImageManifest 156 | ) async throws -> ContentDescriptor { 157 | // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests 158 | 159 | let encoded = try encoder.encode(manifest) 160 | let digest = ImageReference.Digest(of: encoded) 161 | let mediaType = manifest.mediaType ?? "application/vnd.oci.image.manifest.v1+json" 162 | 163 | let _ = try await executeRequestThrowing( 164 | .put( 165 | repository, 166 | path: "manifests/\(reference ?? digest)", 167 | contentType: mediaType 168 | ), 169 | uploading: encoded, 170 | expectingStatus: .created, 171 | decodingErrors: [.notFound] 172 | ) 173 | 174 | return ContentDescriptor( 175 | mediaType: mediaType, 176 | digest: "\(digest)", 177 | size: Int64(encoded.count) 178 | ) 179 | } 180 | 181 | /// Encodes and uploads an image index. 182 | /// 183 | /// - Parameters: 184 | /// - repository: Name of the destination repository. 185 | /// - reference: Optional tag to apply to this index. 186 | /// - index: Index to be uploaded. 187 | /// - Returns: An ContentDescriptor object representing the 188 | /// uploaded index. 189 | /// - Throws: If the index cannot be encoded or the upload fails. 190 | /// 191 | /// An index is a type of manifest. Manifests are not treated as blobs 192 | /// by the distribution specification. They have their own MIME types 193 | /// and are uploaded to different endpoint. 194 | public func putIndex( 195 | repository: ImageReference.Repository, 196 | reference: (any ImageReference.Reference)? = nil, 197 | index: ImageIndex 198 | ) async throws -> ContentDescriptor { 199 | // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pushing-manifests 200 | 201 | let encoded = try encoder.encode(index) 202 | let digest = ImageReference.Digest(of: encoded) 203 | let mediaType = index.mediaType ?? "application/vnd.oci.image.index.v1+json" 204 | 205 | let _ = try await executeRequestThrowing( 206 | .put( 207 | repository, 208 | path: "manifests/\(reference ?? digest)", 209 | contentType: mediaType 210 | ), 211 | uploading: encoded, 212 | expectingStatus: .created, 213 | decodingErrors: [.notFound] 214 | ) 215 | 216 | return ContentDescriptor( 217 | mediaType: mediaType, 218 | digest: "\(digest)", 219 | size: Int64(encoded.count) 220 | ) 221 | } 222 | } 223 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/RegistryClient+ImageSource.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.Data 16 | 17 | extension RegistryClient: ImageSource { 18 | /// Fetches an unstructured blob of data from the registry. 19 | /// 20 | /// - Parameters: 21 | /// - repository: Name of the repository containing the blob. 22 | /// - digest: Digest of the blob. 23 | /// - Returns: The downloaded data. 24 | /// - Throws: If the blob download fails. 25 | public func getBlob( 26 | repository: ImageReference.Repository, 27 | digest: ImageReference.Digest 28 | ) async throws -> Data { 29 | try await executeRequestThrowing( 30 | .get(repository, path: "blobs/\(digest)", accepting: ["application/octet-stream"]), 31 | decodingErrors: [.notFound] 32 | ) 33 | .data 34 | } 35 | 36 | /// Fetches an image manifest. 37 | /// 38 | /// - Parameters: 39 | /// - repository: Name of the source repository. 40 | /// - reference: Tag or digest of the manifest to fetch. 41 | /// - Returns: The downloaded manifest. 42 | /// - Throws: If the download fails or the manifest cannot be decoded. 43 | public func getManifest( 44 | repository: ImageReference.Repository, 45 | reference: any ImageReference.Reference 46 | ) async throws -> (ImageManifest, ContentDescriptor) { 47 | // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests 48 | let (data, response) = try await executeRequestThrowing( 49 | .get( 50 | repository, 51 | path: "manifests/\(reference)", 52 | accepting: [ 53 | "application/vnd.oci.image.manifest.v1+json", 54 | "application/vnd.docker.distribution.manifest.v2+json", 55 | ] 56 | ), 57 | decodingErrors: [.notFound] 58 | ) 59 | return ( 60 | try decoder.decode(ImageManifest.self, from: data), 61 | ContentDescriptor( 62 | mediaType: response.headerFields[.contentType] ?? "application/vnd.oci.image.manifest.v1+json", 63 | digest: "\(ImageReference.Digest(of: data))", 64 | size: Int64(data.count) 65 | ) 66 | ) 67 | } 68 | 69 | /// Fetches an image index. 70 | /// 71 | /// - Parameters: 72 | /// - repository: Name of the source repository. 73 | /// - reference: Tag or digest of the index to fetch. 74 | /// - Returns: The downloaded index. 75 | /// - Throws: If the download fails or the index cannot be decoded. 76 | public func getIndex( 77 | repository: ImageReference.Repository, 78 | reference: any ImageReference.Reference 79 | ) async throws -> ImageIndex { 80 | // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#pulling-manifests 81 | let (data, _) = try await executeRequestThrowing( 82 | .get( 83 | repository, 84 | path: "manifests/\(reference)", 85 | accepting: [ 86 | "application/vnd.oci.image.index.v1+json", 87 | "application/vnd.docker.distribution.manifest.list.v2+json", 88 | ] 89 | ), 90 | decodingErrors: [.notFound] 91 | ) 92 | return try decoder.decode(ImageIndex.self, from: data) 93 | } 94 | 95 | /// Get an image configuration record from the registry. 96 | /// - Parameters: 97 | /// - image: Reference to the image containing the record. 98 | /// - digest: Digest of the record. 99 | /// - Returns: The image confguration record stored in `repository` with digest `digest`. 100 | /// - Throws: If the blob cannot be decoded as an `ImageConfiguration`. 101 | /// 102 | /// Image configuration records are stored as blobs in the registry. This function retrieves the requested blob and tries to decode it as a configuration record. 103 | public func getImageConfiguration( 104 | forImage image: ImageReference, 105 | digest: ImageReference.Digest 106 | ) async throws -> ImageConfiguration { 107 | let data = try await getBlob(repository: image.repository, digest: digest) 108 | return try decoder.decode(ImageConfiguration.self, from: data) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/RegistryClient+Tags.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | extension RegistryClient { 16 | /// Fetches all tags defined on a particular repository. 17 | /// 18 | /// - Parameter repository: Name of the repository to list. 19 | /// - Returns: a list of tags. 20 | /// - Throws: If the tag request fails or the response cannot be decoded. 21 | public func getTags(repository: ImageReference.Repository) async throws -> Tags { 22 | // See https://github.com/opencontainers/distribution-spec/blob/main/spec.md#listing-tags 23 | let (data, _) = try await executeRequestThrowing( 24 | .get(repository, path: "tags/list"), 25 | decodingErrors: [.notFound] 26 | ) 27 | return try decoder.decode(Tags.self, from: data) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/ContainerRegistry/ScratchImage.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.Data 16 | import class Foundation.JSONEncoder 17 | 18 | /// ScratchImage is a special-purpose ImageSource which represents the scratch image. 19 | public struct ScratchImage { 20 | var encoder: JSONEncoder 21 | 22 | var architecture: String 23 | var os: String 24 | 25 | var configuration: ImageConfiguration 26 | var manifest: ImageManifest 27 | var manifestDescriptor: ContentDescriptor 28 | var index: ImageIndex 29 | 30 | public init(architecture: String, os: String) { 31 | self.encoder = containerJSONEncoder() 32 | 33 | self.architecture = architecture 34 | self.os = os 35 | 36 | self.configuration = ImageConfiguration( 37 | architecture: architecture, 38 | os: os, 39 | rootfs: .init(_type: "layers", diff_ids: []) 40 | ) 41 | let encodedConfiguration = try! encoder.encode(self.configuration) 42 | 43 | self.manifest = ImageManifest( 44 | schemaVersion: 2, 45 | config: ContentDescriptor( 46 | mediaType: "application/vnd.oci.image.config.v1+json", 47 | digest: "\(ImageReference.Digest(of: encodedConfiguration))", 48 | size: Int64(encodedConfiguration.count) 49 | ), 50 | layers: [] 51 | ) 52 | let encodedManifest = try! encoder.encode(self.manifest) 53 | 54 | self.manifestDescriptor = ContentDescriptor( 55 | mediaType: "application/vnd.oci.image.manifest.v1+json", 56 | digest: "\(ImageReference.Digest(of: encodedManifest))", 57 | size: Int64(encodedManifest.count) 58 | ) 59 | 60 | self.index = ImageIndex( 61 | schemaVersion: 2, 62 | mediaType: "application/vnd.oci.image.index.v1+json", 63 | manifests: [ 64 | ContentDescriptor( 65 | mediaType: "application/vnd.oci.image.manifest.v1+json", 66 | digest: "\(ImageReference.Digest(of: encodedManifest))", 67 | size: Int64(encodedManifest.count), 68 | platform: .init(architecture: architecture, os: os) 69 | ) 70 | ] 71 | ) 72 | } 73 | } 74 | 75 | extension ScratchImage: ImageSource { 76 | /// The scratch image has no data layers, so `getBlob` returns an empty data blob. 77 | /// 78 | /// - Parameters: 79 | /// - repository: Name of the repository containing the blob. 80 | /// - digest: Digest of the blob. 81 | /// - Returns: An empty blob. 82 | /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. 83 | public func getBlob( 84 | repository: ImageReference.Repository, 85 | digest: ImageReference.Digest 86 | ) async throws -> Data { 87 | Data() 88 | } 89 | 90 | /// Returns an empty manifest for the scratch image, with no image layers. 91 | /// 92 | /// - Parameters: 93 | /// - repository: Name of the source repository. 94 | /// - reference: Tag or digest of the manifest to fetch. 95 | /// - Returns: The downloaded manifest. 96 | /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. 97 | public func getManifest( 98 | repository: ImageReference.Repository, 99 | reference: any ImageReference.Reference 100 | ) async throws -> (ImageManifest, ContentDescriptor) { 101 | (self.manifest, self.manifestDescriptor) 102 | } 103 | 104 | /// Fetches an image index. 105 | /// 106 | /// - Parameters: 107 | /// - repository: Name of the source repository. 108 | /// - reference: Tag or digest of the index to fetch. 109 | /// - Returns: The downloaded index. 110 | /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. 111 | public func getIndex( 112 | repository: ImageReference.Repository, 113 | reference: any ImageReference.Reference 114 | ) async throws -> ImageIndex { 115 | self.index 116 | } 117 | 118 | /// Returns an almost empty image configuration scratch image. 119 | /// The processor architecture and operating system fields are populated, 120 | /// but the layer list is empty. 121 | /// 122 | /// - Parameters: 123 | /// - image: Reference to the image containing the record. 124 | /// - digest: Digest of the record. 125 | /// - Returns: A suitable configuration for the scratch image. 126 | /// - Throws: Does not throw, but signature must match the `ImageSource` protocol requirements. 127 | /// 128 | /// Image configuration records are stored as blobs in the registry. This function retrieves the requested blob and tries to decode it as a configuration record. 129 | public func getImageConfiguration( 130 | forImage image: ImageReference, 131 | digest: ImageReference.Digest 132 | ) async throws -> ImageConfiguration { 133 | self.configuration 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /Sources/containertool/ELFDetect.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import class Foundation.FileHandle 16 | import struct Foundation.URL 17 | 18 | struct ArrayField where T.Element == UInt8 { 19 | var start: Int 20 | var count: Int 21 | } 22 | 23 | struct IntField { 24 | var start: Int 25 | } 26 | 27 | extension Array where Element == UInt8 { 28 | subscript(idx: ArrayField<[UInt8]>) -> [UInt8] { 29 | [UInt8](self[idx.start..) -> UInt8 { 33 | self[idx.start] 34 | } 35 | 36 | subscript(idx: IntField, endianness endianness: ELF.Endianness) -> UInt16 { 37 | let (a, b) = (UInt16(self[idx.start]), UInt16(self[idx.start + 1])) 38 | 39 | switch endianness { 40 | case .littleEndian: 41 | return a &<< 0 &+ b &<< 8 42 | case .bigEndian: 43 | return a &<< 8 &+ b &<< 0 44 | } 45 | } 46 | } 47 | 48 | /// ELF header 49 | /// 50 | /// - https://en.wikipedia.org/wiki/Executable_and_Linkable_Format 51 | /// - https://refspecs.linuxbase.org/elf/elf.pdf 52 | /// 53 | /// This struct only defines enough fields to identify a valid ELF file 54 | /// and extract the type of object it contains, and the processor 55 | /// architecture and operating system ABI for which that object 56 | /// was created. 57 | struct ELF: Equatable { 58 | /// Minimum ELF header length is 52 bytes for a 32-bit ELF header. 59 | /// A 64-bit header is 64 bytes. A potential header must be at 60 | /// least 52 bytes or it cannot possibly be an ELF header. 61 | static let minHeaderLength = 52 62 | 63 | /// Multibyte ELF fields are stored in the native endianness of the target system. 64 | /// This field records the endianness of objects in the file. 65 | enum Endianness: UInt8 { 66 | case littleEndian = 0x01 67 | case bigEndian = 0x02 68 | } 69 | 70 | /// Offsets (addresses) are stored as 32-bit or 64-bit integers. 71 | /// This field records the offset size used in objects in the file. 72 | /// Variable offset sizes mean that some fields are found at different 73 | /// offsets in 32-bit and 64-bit ELF files. 74 | enum Encoding: UInt8 { 75 | case bits32 = 0x01 76 | case bits64 = 0x02 77 | } 78 | 79 | /// ELF files can hold a variety of different object types. 80 | /// This field records type of object in the file. 81 | /// The standard defines a number of fixed types but also 82 | /// reserves ranges of type numbers for to be used by 83 | /// specific operating systems and processors. 84 | enum Object: Equatable { 85 | case none 86 | case relocatable 87 | case executable 88 | case shared 89 | case core 90 | case reservedOS(UInt16) 91 | case reservedCPU(UInt16) 92 | case unknown(UInt16) 93 | 94 | init?(rawValue: UInt16) { 95 | switch rawValue { 96 | case 0x0000: self = .none 97 | case 0x0001: self = .relocatable 98 | case 0x0002: self = .executable 99 | case 0x0003: self = .shared 100 | case 0x0004: self = .core 101 | 102 | /// Reserved for OS-specific use 103 | case 0xfe00...0xfeff: self = .reservedOS(rawValue) 104 | 105 | /// Reserved for CPU-specific use 106 | case 0xff00...0xffff: self = .reservedCPU(rawValue) 107 | 108 | default: return nil 109 | } 110 | } 111 | } 112 | 113 | /// The ABI used by the object in this ELF file. The standard reserves values for a variety of ABIs and operating systems; only a few are implemented here. 114 | enum ABI: Equatable { 115 | case SysV 116 | case Linux 117 | case unknown(UInt8) 118 | 119 | init(rawValue: UInt8) { 120 | switch rawValue { 121 | case 0x00: self = .SysV 122 | case 0x03: self = .Linux 123 | default: self = .unknown(rawValue) 124 | } 125 | } 126 | } 127 | 128 | /// The processor architecture used by the object in this ELF file. Values are reserved for many ISAs; 129 | /// this enum includes cases for the linux-* host types for which Swift can currently be built: 130 | /// 131 | /// https://github.com/swiftlang/swift/blob/c6d1060778f35631000911372d7645dbd5cade0a/utils/build-script-impl#L458 132 | enum ISA: Equatable { 133 | case x86 134 | case powerpc 135 | case powerpc64 136 | case s390 // incluing s390x 137 | case arm // up to armv7 138 | case x86_64 139 | case aarch64 // armv8 onwards 140 | case riscv 141 | case unknown(UInt16) 142 | 143 | init(rawValue: UInt16) { 144 | switch rawValue { 145 | case 0x0003: self = .x86 146 | case 0x0014: self = .powerpc 147 | case 0x0015: self = .powerpc64 148 | case 0x0016: self = .s390 149 | case 0x0028: self = .arm 150 | case 0x003e: self = .x86_64 151 | case 0x00b7: self = .aarch64 152 | case 0x00f3: self = .riscv 153 | default: self = .unknown(rawValue) 154 | } 155 | } 156 | } 157 | 158 | var encoding: Encoding 159 | var endianness: Endianness 160 | var ABI: ABI 161 | var object: Object 162 | var ISA: ISA 163 | } 164 | 165 | extension ELF { 166 | /// ELF header field addresses 167 | /// 168 | /// The ELF format can store binaries for 32-bit and 64-bit systems, 169 | /// using little-endian and big-endian data encoding. 170 | /// 171 | /// All multibyte fields are stored using the endianness of the target 172 | /// system. Read the EI_DATA field to find the endianness of the file. 173 | /// 174 | /// Some fields are different sizes in 32-bit and 64-bit ELF files, but 175 | /// these occur after all the fields we need to read for basic file type 176 | /// identification, so all our offsets are the same on 32-bit and 64-bit systems. 177 | enum Field { 178 | /// ELF magic number: a string of 4 bytes, not a UInt32; no endianness 179 | static let EI_MAGIC = ArrayField<[UInt8]>(start: 0x0, count: 4) 180 | 181 | /// ELF class (word size): 1 byte 182 | static let EI_CLASS = IntField(start: 0x4) 183 | 184 | /// Data encoding (endianness): 1 byte 185 | static let EI_DATA = IntField(start: 0x5) 186 | 187 | // ELF version: 1 byte 188 | static let EI_VERSION = IntField(start: 0x6) 189 | 190 | // Operating system/ABI identification: 1 byte 191 | static let EI_OSABI = IntField(start: 0x7) 192 | 193 | // The following fields are multibyte, so endianness must be considered, 194 | // All the fields we need are the same length in 32-bit and 64-bit 195 | // ELF files, so their offsets do not change. 196 | 197 | /// Object type: 2 bytes 198 | static let EI_TYPE = IntField(start: 0x10) 199 | 200 | /// Machine ISA (processor architecture): 2 bytes 201 | static let EI_MACHINE = IntField(start: 0x12) 202 | } 203 | 204 | /// The initial magic number (4 bytes) which identifies an ELF file. 205 | /// 206 | /// The ELF magic number is *not* a multibyte integer. It is defined as a 207 | /// string of 4 individual bytes and is the same for little-endian and 208 | /// big-endian ELF files. 209 | static let ELFMagic = Array("\u{7f}ELF".utf8) 210 | 211 | /// Read enough of an ELF header from bytes to discover the object type, 212 | /// processor architecture and operating system ABI. 213 | static func read(_ bytes: [UInt8]) -> ELF? { 214 | // An ELF file starts with a magic number which is the same in either endianness. 215 | // The only defined ELF header version is 1. 216 | guard bytes.count >= minHeaderLength, bytes[Field.EI_MAGIC] == ELFMagic, bytes[Field.EI_VERSION] == 1 else { 217 | return nil 218 | } 219 | 220 | guard 221 | let encoding = Encoding(rawValue: bytes[Field.EI_CLASS]), 222 | let endianness = Endianness(rawValue: bytes[Field.EI_DATA]), 223 | let object = Object(rawValue: bytes[Field.EI_TYPE, endianness: endianness]) 224 | else { 225 | return nil 226 | } 227 | 228 | return ELF( 229 | encoding: encoding, 230 | endianness: endianness, 231 | ABI: .init(rawValue: bytes[Field.EI_OSABI]), 232 | object: object, 233 | ISA: .init(rawValue: bytes[Field.EI_MACHINE, endianness: endianness]) 234 | ) 235 | } 236 | } 237 | 238 | extension ELF { 239 | static func read(at path: URL) throws -> ELF? { 240 | let handle = try FileHandle(forReadingFrom: path) 241 | guard let header = try handle.read(upToCount: minHeaderLength) else { 242 | return nil 243 | } 244 | return ELF.read([UInt8](header)) 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /Sources/containertool/Extensions/Archive+appending.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import class Foundation.FileManager 16 | import struct Foundation.Data 17 | import struct Foundation.FileAttributeType 18 | import struct Foundation.URL 19 | 20 | import Tar 21 | 22 | extension URL { 23 | var isDirectory: Bool { 24 | (try? resourceValues(forKeys: [.isDirectoryKey]))?.isDirectory == true 25 | } 26 | } 27 | 28 | extension Archive { 29 | /// Append a file or directory tree to the archive. Directory trees are appended recursively. 30 | /// Parameters: 31 | /// - root: The path to the file or directory to add. 32 | /// Returns: A new archive made by appending `root` to the receiver. 33 | public func appendingRecursively(atPath root: String) throws -> Self { 34 | let url = URL(fileURLWithPath: root) 35 | if url.isDirectory { 36 | return try self.appendingDirectoryTree(at: url) 37 | } else { 38 | return try self.appendingFile(at: url) 39 | } 40 | } 41 | 42 | /// Append a single file to the archive. 43 | /// Parameters: 44 | /// - path: The path to the file to add. 45 | /// Returns: A new archive made by appending `path` to the receiver. 46 | func appendingFile(at path: URL) throws -> Self { 47 | try self.appendingFile(name: path.lastPathComponent, data: try [UInt8](Data(contentsOf: path))) 48 | } 49 | 50 | /// Recursively append a single directory tree to the archive. 51 | /// Parameters: 52 | /// - root: The path to the directory to add. 53 | /// Returns: A new archive made by appending `root` to the receiver. 54 | func appendingDirectoryTree(at root: URL) throws -> Self { 55 | var ret = self 56 | 57 | guard let enumerator = FileManager.default.enumerator(atPath: root.path) else { 58 | throw ("Unable to read \(root.path)") 59 | } 60 | 61 | for case let subpath as String in enumerator { 62 | // https://developer.apple.com/documentation/foundation/filemanager/1410452-attributesofitem 63 | // https://developer.apple.com/documentation/foundation/fileattributekey 64 | 65 | guard let filetype = enumerator.fileAttributes?[.type] as? FileAttributeType else { 66 | throw ("Unable to get file type for \(subpath)") 67 | } 68 | 69 | switch filetype { 70 | case .typeRegular: 71 | let resource = try [UInt8](Data(contentsOf: root.appending(path: subpath))) 72 | try ret.appendFile(name: subpath, prefix: root.lastPathComponent, data: resource) 73 | 74 | case .typeDirectory: 75 | try ret.appendDirectory(name: subpath, prefix: root.lastPathComponent) 76 | 77 | default: 78 | throw "Resource file \(subpath) of type \(filetype) is not supported" 79 | } 80 | } 81 | 82 | return ret 83 | } 84 | } 85 | -------------------------------------------------------------------------------- /Sources/containertool/Extensions/ELF+containerArchitecture.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | extension ELF.ISA { 16 | /// Converts the ELF architecture to the GOARCH string representation understood by the container runtime. 17 | /// Unsupported architectures are mapped to nil. 18 | var containerArchitecture: String? { 19 | switch self { 20 | case .x86_64: "amd64" 21 | case .aarch64: "arm64" 22 | default: nil 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/containertool/Extensions/Errors+CustomStringConvertible.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import ContainerRegistry 16 | 17 | extension HTTPClientError: Swift.CustomStringConvertible { 18 | /// A human-readable string representing an underlying HTTP protocol error 19 | public var description: String { 20 | switch self { 21 | case .unexpectedStatusCode(let status, _, _): 22 | return "Registry returned an unexpected HTTP error code: \(status)" 23 | case .unexpectedContentType(let contentType): 24 | return "Registry returned an unexpected HTTP content type: \(contentType)" 25 | case .missingContentType: return "Registry response did not include a content type" 26 | case .missingResponseHeader(let header): 27 | return "Registry response did not include an expected header: \(header)" 28 | case .authenticationChallenge(let challenge, _, _): 29 | return "Unhandled authentication challenge from registry: \(challenge)" 30 | case .unauthorized: return "Registry response: unauthorized" 31 | } 32 | } 33 | } 34 | 35 | extension DistributionErrorCode: Swift.CustomStringConvertible { 36 | /// A human-readable string representing a distribution protocol error code 37 | public var description: String { self.rawValue } 38 | } 39 | 40 | extension ContainerRegistry.DistributionError: Swift.CustomStringConvertible { 41 | /// A human-readable string describing an unhandled distribution protocol error 42 | public var description: String { if let message { return "\(code): \(message)" } else { return "\(code)" } } 43 | } 44 | 45 | extension ContainerRegistry.DistributionErrors: Swift.CustomStringConvertible { 46 | /// A human-readable string describing a collection of unhandled distribution protocol errors 47 | public var description: String { errors.map { $0.description }.joined(separator: "\n") } 48 | } 49 | 50 | extension ContainerRegistry.ImageReference.Repository.ValidationError: Swift.CustomStringConvertible { 51 | /// A human-readable string describing an image reference validation error 52 | public var description: String { 53 | switch self { 54 | case .emptyString: 55 | return "Invalid reference format: repository name cannot be empty" 56 | case .containsUppercaseLetters(let rawValue): 57 | return "Invalid reference format: repository name (\(rawValue)) must be lowercase" 58 | case .invalidReferenceFormat(let rawValue): 59 | return "Invalid reference format: repository name (\(rawValue)) contains invalid characters" 60 | } 61 | } 62 | } 63 | 64 | extension ContainerRegistry.ImageReference.Tag.ValidationError: Swift.CustomStringConvertible { 65 | /// A human-readable string describing an image reference validation error 66 | public var description: String { 67 | switch self { 68 | case .emptyString: 69 | return "Invalid reference format: tag cannot be empty" 70 | case .tooLong(let rawValue): 71 | return "Invalid reference format: tag (\(rawValue)) is too long" 72 | case .invalidReferenceFormat(let rawValue): 73 | return "Invalid reference format: tag (\(rawValue)) contains invalid characters" 74 | } 75 | } 76 | } 77 | 78 | extension ContainerRegistry.ImageReference.Digest.ValidationError: Swift.CustomStringConvertible { 79 | /// A human-readable string describing an image reference validation error 80 | public var description: String { 81 | switch self { 82 | case .emptyString: 83 | return "Invalid reference format: digest cannot be empty" 84 | case .invalidReferenceFormat(let rawValue): 85 | return "Invalid reference format: digest (\(rawValue)) is not a valid digest" 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /Sources/containertool/Extensions/NetrcError+CustomStringConvertible.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Basics 16 | 17 | extension Basics.NetrcError: Swift.CustomStringConvertible { 18 | /// Description of an error in the .netrc file. 19 | public var description: String { 20 | switch self { 21 | case .machineNotFound: return "No entry for host in .netrc" 22 | case .invalidDefaultMachinePosition: return "Invalid .netrc - 'default' must be the last entry in the file" 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /Sources/containertool/Extensions/RegistryClient+CopyBlobs.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import ContainerRegistry 16 | 17 | extension ImageSource { 18 | /// Copies a blob from another registry to this one. 19 | /// - Parameters: 20 | /// - digest: The digest of the blob to copy. 21 | /// - sourceRepository: The repository from which the blob should be copied. 22 | /// - destClient: The client to which the blob should be copied. 23 | /// - destRepository: The repository on this registry to which the blob should be copied. 24 | /// - Throws: If the copy cannot be completed. 25 | func copyBlob( 26 | digest: ImageReference.Digest, 27 | fromRepository sourceRepository: ImageReference.Repository, 28 | toClient destClient: ImageDestination, 29 | toRepository destRepository: ImageReference.Repository 30 | ) async throws { 31 | if try await destClient.blobExists(repository: destRepository, digest: digest) { 32 | log("Layer \(digest): already exists") 33 | return 34 | } 35 | 36 | log("Layer \(digest): fetching") 37 | let blob = try await getBlob(repository: sourceRepository, digest: digest) 38 | 39 | log("Layer \(digest): pushing") 40 | let uploaded = try await destClient.putBlob(repository: destRepository, data: blob) 41 | log("Layer \(digest): done") 42 | 43 | guard "\(digest)" == uploaded.digest else { 44 | throw RegistryClientError.digestMismatch(expected: "\(digest)", registry: uploaded.digest) 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /Sources/containertool/Extensions/RegistryClient+Layers.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.Data 16 | import ContainerRegistry 17 | 18 | extension ImageSource { 19 | // A single-architecture image may begin with a manifest; a multi-architecture 20 | // image begins with an index which points to one or more manifests. The client 21 | // could receive either type of object: 22 | // * if the registry sends a manifest, this function returns it 23 | // * if the registry sends an index, this function chooses the 24 | // appropriate architecture-specific manifest and returns it. 25 | func getImageManifest( 26 | forImage image: ImageReference, 27 | architecture: String 28 | ) async throws -> (ImageManifest, ContentDescriptor) { 29 | do { 30 | // Try to retrieve a manifest. If the object with this reference is actually an index, the content-type will not match and 31 | // an error will be thrown. 32 | return try await getManifest(repository: image.repository, reference: image.reference) 33 | } catch { 34 | // Try again, treating the top level object as an index. 35 | // This could be more efficient if the exception thrown by getManifest() included the data it was unable to parse 36 | let index = try await getIndex(repository: image.repository, reference: image.reference) 37 | guard let manifest = index.manifests.first(where: { $0.platform?.architecture == architecture }) else { 38 | throw "Could not find a suitable base image for \(architecture)" 39 | } 40 | // The index should not point to another index; if it does, this call will throw a final error to be handled by the caller. 41 | return try await getManifest( 42 | repository: image.repository, 43 | reference: ImageReference.Digest(manifest.digest) 44 | ) 45 | } 46 | } 47 | } 48 | 49 | extension ImageDestination { 50 | // A layer is a tarball, optionally compressed using gzip or zstd 51 | // See https://github.com/opencontainers/image-spec/blob/main/media-types.md 52 | func uploadLayer( 53 | repository: ImageReference.Repository, 54 | contents: [UInt8], 55 | mediaType: String = "application/vnd.oci.image.layer.v1.tar+gzip" 56 | ) async throws -> (descriptor: ContentDescriptor, diffID: ImageReference.Digest) { 57 | // The diffID is the hash of the unzipped layer tarball 58 | let diffID = ImageReference.Digest(of: contents) 59 | // The layer blob is the gzipped tarball; the descriptor is the hash of this gzipped blob 60 | let blob = Data(gzip(contents)) 61 | let descriptor = try await putBlob(repository: repository, mediaType: mediaType, data: blob) 62 | return (descriptor: descriptor, diffID: diffID) 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/containertool/Extensions/RegistryClient+publish.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import struct Foundation.Date 16 | import struct Foundation.URL 17 | 18 | import ContainerRegistry 19 | import Tar 20 | 21 | func publishContainerImage( 22 | baseImage: ImageReference, 23 | destinationImage: ImageReference, 24 | source: Source, 25 | destination: Destination, 26 | architecture: String, 27 | os: String, 28 | resources: [String], 29 | tag: String?, 30 | verbose: Bool, 31 | executableURL: URL 32 | ) async throws -> ImageReference { 33 | 34 | // MARK: Find the base image 35 | 36 | let (baseImageManifest, baseImageDescriptor) = try await source.getImageManifest( 37 | forImage: baseImage, 38 | architecture: architecture 39 | ) 40 | try log("Found base image manifest: \(ImageReference.Digest(baseImageDescriptor.digest))") 41 | 42 | let baseImageConfiguration = try await source.getImageConfiguration( 43 | forImage: baseImage, 44 | digest: ImageReference.Digest(baseImageManifest.config.digest) 45 | ) 46 | log("Found base image configuration: \(baseImageManifest.config.digest)") 47 | 48 | // MARK: Upload resource layers 49 | 50 | var resourceLayers: [(descriptor: ContentDescriptor, diffID: ImageReference.Digest)] = [] 51 | for resourceDir in resources { 52 | let resourceTardiff = try Archive().appendingRecursively(atPath: resourceDir).bytes 53 | let resourceLayer = try await destination.uploadLayer( 54 | repository: destinationImage.repository, 55 | contents: resourceTardiff 56 | ) 57 | 58 | if verbose { 59 | log("resource layer: \(resourceLayer.descriptor.digest) (\(resourceLayer.descriptor.size) bytes)") 60 | } 61 | 62 | resourceLayers.append(resourceLayer) 63 | } 64 | 65 | // MARK: Upload the application layer 66 | 67 | let applicationLayer = try await destination.uploadLayer( 68 | repository: destinationImage.repository, 69 | contents: try Archive().appendingFile(at: executableURL).bytes 70 | ) 71 | if verbose { 72 | log("application layer: \(applicationLayer.descriptor.digest) (\(applicationLayer.descriptor.size) bytes)") 73 | } 74 | 75 | // MARK: Create the application configuration 76 | 77 | let timestamp = Date(timeIntervalSince1970: 0).ISO8601Format() 78 | 79 | // Inherit the configuration of the base image - UID, GID, environment etc - 80 | // and override the entrypoint. 81 | var inheritedConfiguration = baseImageConfiguration.config ?? .init() 82 | inheritedConfiguration.Entrypoint = ["/\(executableURL.lastPathComponent)"] 83 | inheritedConfiguration.Cmd = [] 84 | inheritedConfiguration.WorkingDir = "/" 85 | 86 | let configuration = ImageConfiguration( 87 | created: timestamp, 88 | architecture: architecture, 89 | os: os, 90 | config: inheritedConfiguration, 91 | rootfs: .init( 92 | _type: "layers", 93 | // The diff_id is the digest of the _uncompressed_ layer archive. 94 | // It is used by the runtime, which might not store the layers in 95 | // the compressed form in which it received them from the registry. 96 | diff_ids: baseImageConfiguration.rootfs.diff_ids 97 | + resourceLayers.map { "\($0.diffID)" } 98 | + ["\(applicationLayer.diffID)"] 99 | ), 100 | history: [.init(created: timestamp, created_by: "containertool")] 101 | ) 102 | 103 | let configurationBlobReference = try await destination.putImageConfiguration( 104 | forImage: destinationImage, 105 | configuration: configuration 106 | ) 107 | 108 | if verbose { 109 | log("image configuration: \(configurationBlobReference.digest) (\(configurationBlobReference.size) bytes)") 110 | } 111 | 112 | // MARK: Create application manifest 113 | 114 | let manifest = ImageManifest( 115 | schemaVersion: 2, 116 | mediaType: "application/vnd.oci.image.manifest.v1+json", 117 | config: configurationBlobReference, 118 | layers: baseImageManifest.layers 119 | + resourceLayers.map { $0.descriptor } 120 | + [applicationLayer.descriptor] 121 | ) 122 | 123 | // MARK: Upload base image 124 | 125 | // Copy the base image layers to the destination repository 126 | // Layers could be checked and uploaded concurrently 127 | // This could also happen in parallel with the application image build 128 | for layer in baseImageManifest.layers { 129 | try await source.copyBlob( 130 | digest: ImageReference.Digest(layer.digest), 131 | fromRepository: baseImage.repository, 132 | toClient: destination, 133 | toRepository: destinationImage.repository 134 | ) 135 | } 136 | 137 | // MARK: Upload application manifest 138 | 139 | let manifestDescriptor = try await destination.putManifest( 140 | repository: destinationImage.repository, 141 | reference: destinationImage.reference, 142 | manifest: manifest 143 | ) 144 | 145 | if verbose { 146 | log("manifest: \(manifestDescriptor.digest) (\(manifestDescriptor.size) bytes)") 147 | } 148 | 149 | // MARK: Create application index 150 | 151 | let index = ImageIndex( 152 | schemaVersion: 2, 153 | mediaType: "application/vnd.oci.image.index.v1+json", 154 | manifests: [ 155 | ContentDescriptor( 156 | mediaType: manifestDescriptor.mediaType, 157 | digest: manifestDescriptor.digest, 158 | size: Int64(manifestDescriptor.size), 159 | platform: .init(architecture: architecture, os: os) 160 | ) 161 | ] 162 | ) 163 | 164 | // MARK: Upload application manifest 165 | 166 | let indexDescriptor = try await destination.putIndex( 167 | repository: destinationImage.repository, 168 | reference: destinationImage.reference, 169 | index: index 170 | ) 171 | 172 | if verbose { 173 | log("index: \(indexDescriptor.digest) (\(indexDescriptor.size) bytes)") 174 | } 175 | 176 | // Use the index digest if the user did not provide a human-readable tag 177 | // To support multiarch images, we should also create an an index pointing to 178 | // this manifest. 179 | let reference: ImageReference.Reference 180 | if let tag { 181 | reference = try ImageReference.Tag(tag) 182 | } else { 183 | reference = try ImageReference.Digest(indexDescriptor.digest) 184 | } 185 | 186 | var result = destinationImage 187 | result.reference = reference 188 | return result 189 | } 190 | -------------------------------------------------------------------------------- /Sources/containertool/Logging.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Foundation 16 | 17 | /// A text output stream which writes to standard error 18 | struct StdErrOutputStream: TextOutputStream { 19 | /// Writes a string to standard error. 20 | /// - Parameter string: String to be written. 21 | public mutating func write(_ string: String) { fputs(string, stderr) } 22 | } 23 | 24 | /// Logs a message to standard error. 25 | /// - Parameter message: Message to be logged. 26 | public func log(_ message: String) { 27 | var stdError = StdErrOutputStream() 28 | print(message, to: &stdError) 29 | } 30 | -------------------------------------------------------------------------------- /Sources/containertool/gzip.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | //===----------------------------------------------------------------------===// 16 | // 17 | // This source file is part of the SwiftNIO open source project 18 | // 19 | // Copyright (c) 2020-2021 Apple Inc. and the SwiftNIO project authors 20 | // Licensed under Apache License v2.0 21 | // 22 | // See LICENSE.txt for license information 23 | // See CONTRIBUTORS.txt for the list of SwiftNIO project authors 24 | // 25 | // SPDX-License-Identifier: Apache-2.0 26 | // 27 | //===----------------------------------------------------------------------===// 28 | 29 | // Adapted NIOHTTPCompression/HTTPCompression.swift in swift-nio-extras 30 | import VendorCNIOExtrasZlib 31 | 32 | func gzip(_ bytes: [UInt8]) -> [UInt8] { 33 | var stream = z_stream() 34 | stream.zalloc = nil 35 | stream.zfree = nil 36 | stream.opaque = nil 37 | 38 | // Force identical gzip headers to be created on Linux and macOS. 39 | // 40 | // RFC1952 defines operating system codes which can be embedded in the gzip header. 41 | // 42 | // * Initially, zlib generated a default gzip header with the 43 | // OS field set to `Unknown` (255). 44 | // * https://github.com/madler/zlib/commit/0484693e1723bbab791c56f95597bd7dbe867d03 45 | // changed the default to `Unix` (3). 46 | // * https://github.com/madler/zlib/commit/ce12c5cd00628bf8f680c98123a369974d32df15 47 | // changed the default to use a value based on the OS detected 48 | // at compile time. After this, zlib on Linux continued to 49 | // use `Unix` (3) whereas macOS started to use `Apple` (19). 50 | // 51 | // According to RFC1952 Section 2.3.1.2. (Compliance), `Unknown` 52 | // 255 should be used by default where the OS on which the file 53 | // was created is not known. 54 | // 55 | // Different versions of zlib might still produce different 56 | // compressed output for the same input, but using the same default 57 | // value removes one one source of differences between platforms. 58 | 59 | let gz_os_unknown = Int32(255) 60 | var header = gz_header() 61 | header.os = gz_os_unknown 62 | 63 | let windowBits: Int32 = 15 + 16 64 | let level = Z_DEFAULT_COMPRESSION 65 | let memLevel: Int32 = 8 66 | let rc = CNIOExtrasZlib_deflateInit2(&stream, level, Z_DEFLATED, windowBits, memLevel, Z_DEFAULT_STRATEGY) 67 | deflateSetHeader(&stream, &header) 68 | 69 | precondition(rc == Z_OK, "Unexpected return from zlib init: \(rc)") 70 | 71 | var inputBuffer = bytes 72 | 73 | // calculate the upper bound size for the output buffer 74 | let bufferSize = Int(deflateBound(&stream, UInt(inputBuffer.count))) 75 | var outputBuffer: [UInt8] = .init(repeating: 0, count: bufferSize) 76 | 77 | var count = 0 78 | 79 | inputBuffer.withUnsafeMutableBufferPointer { inputPtr in 80 | stream.avail_in = UInt32(inputPtr.count) 81 | stream.next_in = inputPtr.baseAddress! 82 | 83 | outputBuffer.withUnsafeMutableBufferPointer { outputPtr in 84 | stream.avail_out = UInt32(outputPtr.count) 85 | stream.next_out = outputPtr.baseAddress! 86 | 87 | let rc = deflate(&stream, Z_FINISH) 88 | precondition(rc != Z_STREAM_ERROR, "Unexpected return from zlib deflate: \(rc)") 89 | deflateEnd(&stream) 90 | 91 | count = outputPtr.count - Int(stream.avail_out) 92 | } 93 | } 94 | 95 | return Array(outputBuffer[.. Adding the same dependency to your project more than once will prevent it from building. 12 | > If this happens, delete the duplicate dependency lines from `Package.swift` and rebuild. 13 | 14 | Recent versions of `swift package` support the `add-dependency` command: 15 | 16 | ```shell 17 | swift package add-dependency https://github.com/apple/swift-container-plugin --from 1.0.0 18 | ``` 19 | 20 | ### Install the plugin by manually editing `Package.swift` 21 | 22 | If you cannot use the `swift package add-dependency` comand, append the following lines to your project's `Package.swift` file: 23 | 24 | ```swift 25 | package.dependencies += [ 26 | .package(url: "https://github.com/apple/swift-container-plugin", from: "1.0.0"), 27 | ] 28 | ``` 29 | 30 | ### Check that the plugin is available 31 | 32 | After installation, Swift Package Manager should show that the `ContainerImageBuilder` is now available: 33 | 34 | ```shell 35 | % swift package plugin --list 36 | ‘build-container-image’ (plugin ‘ContainerImageBuilder’ in package ‘swift-container-plugin) 37 | ``` 38 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 12 | 13 | 14 | CDDefaultCodeListingLanguage 15 | shell 16 | CFBundleName 17 | swift-container-plugin 18 | CFBundleDisplayName 19 | swift-container-plugin 20 | CFBundleIdentifier 21 | com.apple.ContainerPlugin 22 | CFBundlePackageType 23 | DOCS 24 | CDDefaultModuleKind 25 | Tool 26 | 27 | 28 | 29 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/Swift-Container-Plugin.md: -------------------------------------------------------------------------------- 1 | # Swift Container Plugin 2 | 3 | @Metadata { 4 | @TechnologyRoot() 5 | } 6 | 7 | Build and publish container images using Swift Package Manager. 8 | 9 | ## Overview 10 | 11 | Container images are the standard way to package cloud software today. Once you have packaged your server in a container image, you can deploy it on any container-based public or private cloud service, or run it locally using a desktop container runtime. 12 | 13 | Use Swift Container Plugin to build and publish container images for your Swift services in one streamlined workflow with Swift Package Manager. 14 | 15 | ![Swift Container Plugin flow diagram](swift-container-plugin-flow-diagram) 16 | 17 | 1. [Add the plugin to your project's dependencies](doc:Adding-the-plugin-to-your-project) in `Package.swift`. 18 | 2. [Build and package your service](doc:build) using Swift Package Manager. 19 | - If you are building on macOS, [use a Swift SDK](doc:requirements) to cross-compile a Linux executable. 20 | - If you are building on Linux, use your native Swift compiler to build a Linux executable. If you have special requirements such as building a static executable, or cross-compiling to a different processor architecture, use a suitable Swift SDK. 21 | 3. The plugin automatically packages your executable in a container image and publishes it to your chosen container registry. 22 | 4. [Run your container image](doc:run) on any container-based platform. 23 | 24 | ### Usage 25 | 26 | Swift Container Plugin can package any executable product defined in `Package.swift`. 27 | 28 | #### Build and publish a container image 29 | 30 | After adding the plugin to your project, you can build and publish a container image in one step. 31 | Here is how to build the [HelloWorld example](https://github.com/apple/swift-container-plugin/tree/main/Examples/HelloWorldHummingbird) as a static executable for Linux running on the `x86_64` architecture: 32 | 33 | ``` 34 | % swift package --swift-sdk x86_64-swift-linux-musl \ 35 | build-container-image --repository registry.example.com/myservice 36 | ... 37 | Plugin ‘ContainerImageBuilder’ wants permission to allow all network connections on all ports. 38 | Stated reason: “This command publishes images to container registries over the network”. 39 | Allow this plugin to allow all network connections on all ports? (yes/no) yes 40 | ... 41 | Building for debugging... 42 | Build of product 'containertool' complete! (4.95s) 43 | ... 44 | Build of product 'hello-world' complete! (5.51s) 45 | ... 46 | [ContainerImageBuilder] Found base image manifest: sha256:7bd643386c6e65cbf52f6e2c480b7a76bce8102b562d33ad2aff7c81b7169a42 47 | [ContainerImageBuilder] Found base image configuration: sha256:b904a448fde1f8088913d7ad5121c59645b422e6f94c13d922107f027fb7a5b4 48 | [ContainerImageBuilder] Built application layer 49 | [ContainerImageBuilder] Uploading application layer 50 | [ContainerImageBuilder] Layer sha256:dafa2b0c44d2cfb0be6721f079092ddf15dc8bc537fb07fe7c3264c15cb2e8e6: already exists 51 | [ContainerImageBuilder] Layer sha256:2565d8e736345fc7ba44f9b3900c5c20eda761eee01e01841ac7b494f9db5cf6: already exists 52 | [ContainerImageBuilder] Layer sha256:2c179bb2e4fe6a3b8445fbeb0ce5351cf24817cb0b068c75a219b12434c54a58: already exists 53 | registry.example.com/myservice@sha256:a3f75d0932d052dd9d448a1c9040b16f9f2c2ed9190317147dee95a218faf1df 54 | ``` 55 | 56 | #### Run the image 57 | 58 | Deploy your service in the cloud, or use a standards-compliant container runtime to run it locally: 59 | 60 | ``` 61 | % podman run -p 8080:8080 registry.example.com/myservice@sha256:a3f75d0932d052dd9d448a1c9040b16f9f2c2ed9190317147dee95a218faf1df 62 | Trying to pull registry.example.com/myservice@sha256:a3f75d0932d052dd9d448a1c9040b16f9f2c2ed9190317147dee95a218faf1df... 63 | ... 64 | 2024-05-26T22:57:50+0000 info HummingBird : [HummingbirdCore] Server started and listening on 0.0.0.0:8080 65 | ``` 66 | 67 | ### Find out more 68 | 69 | * Take a look at [more examples](https://github.com/apple/swift-container-plugin/tree/main/Examples). 70 | 71 | * Watch some talks: 72 | 73 | * [How to put Swift in a box](https://fosdem.org/2025/schedule/event/fosdem-2025-5116-how-to-put-swift-in-a-box-building-container-images-with-swift-container-plugin/) at [FOSDEM 2025](https://fosdem.org/2025/schedule/track/swift/). 74 | * [Swift to the cloud in a single step](https://www.youtube.com/watch?v=9AaINsCfZzw) at [ServerSide.Swift 2024](https://www.serversideswift.info/2024/speakers/euan-harris/). 75 | 76 | ## Topics 77 | 78 | ### Essentials 79 | - 80 | - 81 | - 82 | 83 | ### Building and running 84 | - 85 | - 86 | 87 | ### Reference 88 | - 89 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/_Resources/swift-container-plugin-flow-diagram.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apple/swift-container-plugin/9417b634d9d7afc3816505c05226b96691d1708c/Sources/swift-container-plugin/Documentation.docc/_Resources/swift-container-plugin-flow-diagram.png -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/authentication.md: -------------------------------------------------------------------------------- 1 | # Set up your registry credentials 2 | 3 | Configure the plugin to authenticate to your container registry. 4 | 5 | ## Overview 6 | 7 | Many container registries require authentication in order to push images, or even pull them. 8 | The plugin reads your registry credentials from a `.netrc` file in your home directory. 9 | Add a record into the `.netrc` file for each registry you use. 10 | The plugin chooses the correct record based on the hostname of the registry's authentication server. 11 | 12 | > For some registries, such as Docker Hub [(see example)](), the authentication server hostname might not be the same as the registry hostname you use when pushing and pulling images. 13 | 14 | The following example shows placeholder values for the registry `registry.example.com`: 15 | 16 | ``` 17 | machine registry.example.com 18 | login myuser 19 | password mypassword 20 | ``` 21 | 22 | The following examples show how to set up the plugin for some popular registry providers. 23 | 24 | ### Docker Hub 25 | 26 | > Don't use your Docker Hub account password to push and pull images. 27 | > Create a Personal Access Token, which has restricted privileges, for each integration you use. 28 | > By using separate tokens, you can monitor them independently and revoke one at any time. 29 | To create a `.netrc` entry for Docker Hub: 30 | 31 | 1. Log into Docker Hub and [generate a Personal Access Token](https://docs.docker.com/security/for-developers/access-tokens/) for Swift Container Plugin. 32 | 33 | 2. **Set the token's access permissions to *Read & Write*.** 34 | 35 | 3. Copy the token and add it, together with your Docker ID, to your `.netrc` file under the machine name `auth.docker.io`: 36 | 37 | The final `.netrc` entry should be similar to this: 38 | 39 | ``` 40 | machine auth.docker.io 41 | login mydockerid 42 | password dckr_pat_B3FwrU... 43 | ``` 44 | 45 | ### GitHub Container Registry 46 | 47 | > GitHub Container Registry only supports authentication using a [Personal Access Token (classic)](https://docs.github.com/en/packages/working-with-a-github-packages-registry/working-with-the-container-registry#authenticating-to-the-container-registry). 48 | > A fine-grained personal access token cannot be used. 49 | 50 | To create a `.netrc` entry for Github Container Registry: 51 | 52 | 1. Log into GitHub and [generate a Personal Access Token (classic)](https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/managing-your-personal-access-tokens#creating-a-personal-access-token-classic) for Swift Container Plugin. 53 | 54 | 2. **Select the *write:packages* scope.** 55 | 56 | 3. Copy the token and add it, together with your GitHub username, to your `.netrc` file: 57 | 58 | The final `.netrc` entry should be similar to this: 59 | 60 | ``` 61 | machine ghcr.io 62 | login mygithubusername 63 | password ghp_fAOsWl... 64 | ``` 65 | 66 | ### Amazon Elastic Container Registry 67 | 68 | > Amazon Elastic Container Registry uses [short-lived authorization tokens](https://docs.aws.amazon.com/AmazonECR/latest/userguide/registry_auth.html#registry-auth-token) which expire after 12 hours. 69 | > 70 | > To generate an ECR authentication token, you must [first install the AWS CLI tools.](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html) 71 | 72 | To create a `.netrc` entry for Amazon Elastic Container Registry: 73 | 74 | 1. Use the `aws` CLI tool to [generate an authentication token](https://docs.aws.amazon.com/AmazonECR/latest/userguide/registry_auth.html#registry-auth-token). 75 | You'll need to know the name of the [AWS region](https://docs.aws.amazon.com/global-infrastructure/latest/regions/aws-regions.html) in which your registry is hosted. 76 | Registries in different AWS regions are separate and require different authentication tokens. 77 | 78 | For example, the following command generates a token for ECR in the `us-west-2` region: 79 | ``` 80 | aws ecr get-login-password --region us-west-2 81 | ``` 82 | 83 | 2. Copy the token and add it to your `.netrc` file. 84 | * The format of the machine name is: 85 | 86 | ``` 87 | .dkr.ecr..amazonaws.com 88 | ``` 89 | 90 | You can [find your AWS account ID](https://docs.aws.amazon.com/accounts/latest/reference/manage-acct-identifiers.html) in the AWS Management Console or by running the following command: 91 | ``` 92 | aws sts get-caller-identity \ 93 | --query Account \ 94 | --output text 95 | ``` 96 | * **The login name must be `AWS`**. 97 | * The token is a large encoded string. 98 | It must appear in the `.netrc` file as a single line, with no breaks. 99 | 100 | The final `.netrc` entry should be similar to this: 101 | 102 | ``` 103 | machine 123456789012.dkr.ecr.us-west-2.amazonaws.com 104 | login AWS 105 | password eyJwYXlsb2FkIj... 106 | ``` 107 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/build-container-image.md: -------------------------------------------------------------------------------- 1 | # build-container-image plugin 2 | 3 | Wrap a binary in a container image and publish it. 4 | 5 | ## Overview 6 | 7 | `build-container-image` is a Swift Package Manager [command plugin](https://github.com/swiftlang/swift-package-manager/blob/main/Documentation/Plugins.md#using-a-package-plugin) which packages a product defined in `Package.swift` in a container image and publishes it to repository on a container registry. 8 | 9 | ### Usage 10 | 11 | `swift package build-container-image []` 12 | 13 | ### Options 14 | 15 | - term `--product `: 16 | The name of the product to package. 17 | 18 | If `Package.swift` defines only one product, it will be selected by default. 19 | 20 | ### Source and destination repository options 21 | 22 | - term `--default-registry `: 23 | The default registry hostname. (default: `docker.io`) 24 | 25 | If the repository path does not contain a registry hostname, the default registry will be prepended to it. 26 | 27 | - term `--repository `: 28 | Destination image repository. 29 | 30 | If the repository path does not begin with a registry hostname, the default registry will be prepended to the path. 31 | The destination repository must be specified, either by setting the `--repository` option or the `CONTAINERTOOL_REPOSITORY` environment variable. 32 | 33 | - term `--tag `: 34 | The tag to apply to the destination image. 35 | 36 | The `latest` tag is automatically updated to refer to the published image. 37 | 38 | - term `--from `: 39 | Base image reference. (default: `swift:slim`) 40 | 41 | ### Image build options 42 | 43 | - term `--resources `: 44 | Add the file or directory at `resources` to the image. 45 | Directories are added recursively. 46 | 47 | If the `product` being packaged has a [resource bundle](https://developer.apple.com/documentation/xcode/bundling-resources-with-a-swift-package) it will be added to the image automatically. 48 | 49 | ### Image configuration options 50 | 51 | - term `--architecture `: 52 | CPU architecture required to run the image. 53 | 54 | If the base image is `scratch`, the final image will have no base layer and will consist only of the application layer and resource bundle layer, if the product has a resource bundle. 55 | 56 | - term `--os `: 57 | Operating system required to run the image. (default: `linux`) 58 | 59 | ### Authentication options 60 | 61 | - term `--default-username `: 62 | Default username to use when logging into the registry. 63 | 64 | This username is used if there is no matching `.netrc` entry for the registry, there is no `.netrc` file, or the `--disable-netrc` option is set. 65 | The same username is used for the source and destination registries. 66 | 67 | - term `--default-password `: 68 | Default password to use when logging into the registry. 69 | 70 | This password is used if there is no matching `.netrc` entry for the registry, there is no `.netrc` file, or the `--disable-netrc` option is set. 71 | The same password is used for the source and destination registries. 72 | 73 | - term `--enable-netrc/--disable-netrc`: 74 | Load credentials from a netrc file (default: `--enable-netrc`) 75 | 76 | - term `--netrc-file `: 77 | The path to the `.netrc` file. 78 | 79 | - term `--allow-insecure-http `: 80 | Connect to the container registry using plaintext HTTP. (values: `source`, `destination`, `both`) 81 | 82 | ### Options 83 | 84 | - term `-v, --verbose`: 85 | Verbose output. 86 | 87 | - term `-h, --help`: 88 | Show help information. 89 | 90 | ### Environment 91 | 92 | - term `CONTAINERTOOL_DEFAULT_REGISTRY`: 93 | Default image registry hostname, used when the repository path does not contain a registry hostname. 94 | (default: `docker.io`) 95 | 96 | - term `CONTAINERTOOL_REPOSITORY`: 97 | The destination image repository. 98 | 99 | If the path does not begin with a registry hostname, the default registry will be prepended to the path. 100 | The destination repository must be specified, either by setting the `--repository` option or the `CONTAINERTOOL_REPOSITORY` environment variable. 101 | 102 | - term `CONTAINERTOOL_BASE_IMAGE`: 103 | Base image on which to layer the application. 104 | (default: `swift:slim`) 105 | 106 | - term `CONTAINERTOOL_ARCHITECTURE`: 107 | CPU architecture. 108 | 109 | - term `CONTAINERTOOL_OS`: 110 | Operating system. 111 | (default: `Linux`) 112 | 113 | - term `CONTAINERTOOL_DEFAULT_USERNAME`: 114 | Default username to use when logging into the registry. 115 | 116 | This username is used if there is no matching `.netrc` entry for the registry, there is no `.netrc` file, or the `--disable-netrc` option is set. 117 | The same username is used for the source and destination registries. 118 | 119 | - term `CONTAINERTOOL_DEFAULT_PASSWORD`: 120 | Default password to use when logging into the registry. 121 | 122 | This password is used if there is no matching `.netrc` entry for the registry, there is no `.netrc` file, or the `--disable-netrc` option is set. 123 | The same password is used for the source and destination registries. 124 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/build.md: -------------------------------------------------------------------------------- 1 | # Build and package your service 2 | 3 | Build a container image and publish it to a registry. 4 | 5 | ## Overview 6 | 7 | The plugin exposes the command `build-container-image` which you invoke to build your service, package it in a container image and publish it to a container registry, in a single command: 8 | 9 | ```shell 10 | % swift package --swift-sdk x86_64-swift-linux-musl \ 11 | build-container-image --from swift:slim --repository registry.example.com/myservice 12 | ``` 13 | 14 | * The `--swift-sdk` argument specifies the Swift SDK with which to build the executable. In this case we are using the Static Linux SDK, [installed earlier](), to build an statically-linked x86_64 Linux executable. 15 | * The `--from` argument specifies the base image on which our service will run. `swift:slim` is the default, but you can choose your own base image or use `scratch` if your service does not require a base image at all. 16 | * The `--repository` argument specifies the repository to which the plugin will publish the finished image. 17 | 18 | > Note: on macOS, the plugin needs permission to connect to the network to publish the image to the registry. 19 | > 20 | > ``` 21 | > Plugin ‘ContainerImageBuilder’ wants permission to allow all network connections on all ports. 22 | > Stated reason: “This command publishes images to container registries over the network”. 23 | > Allow this plugin to allow all network connections on all ports? (yes/no) 24 | > ``` 25 | > 26 | > Type `yes` to continue. 27 | 28 | ``` 29 | Building for debugging... 30 | Build of product 'containertool' complete! (4.95s) 31 | ... 32 | Build of product 'hello-world' complete! (5.51s) 33 | ... 34 | [ContainerImageBuilder] Found base image manifest: sha256:7bd643386c6e65cbf52f6e2c480b7a76bce8102b562d33ad2aff7c81b7169a42 35 | [ContainerImageBuilder] Found base image configuration: sha256:b904a448fde1f8088913d7ad5121c59645b422e6f94c13d922107f027fb7a5b4 36 | [ContainerImageBuilder] Built application layer 37 | [ContainerImageBuilder] Uploading application layer 38 | [ContainerImageBuilder] Layer sha256:dafa2b0c44d2cfb0be6721f079092ddf15dc8bc537fb07fe7c3264c15cb2e8e6: already exists 39 | [ContainerImageBuilder] Layer sha256:2565d8e736345fc7ba44f9b3900c5c20eda761eee01e01841ac7b494f9db5cf6: already exists 40 | [ContainerImageBuilder] Layer sha256:2c179bb2e4fe6a3b8445fbeb0ce5351cf24817cb0b068c75a219b12434c54a58: already exists 41 | registry.example.com/myservice@sha256:a3f75d0932d052dd9d448a1c9040b16f9f2c2ed9190317147dee95a218faf1df 42 | ``` 43 | 44 | When the plugin finishes, it prints a reference identifying the new image. 45 | Any standard container runtime can use the reference to pull and run your service. 46 | 47 | ### Default registry 48 | 49 | If you don't include a registry name in the `--repository` argument, the plugin will publish your image to Docker Hub by default. 50 | 51 | You can override the default registry by using the `--default-registry` argument or setting the `CONTAINERTOOL_DEFAULT_REGISTRY` environment variable. 52 | 53 | The following examples show how to publish images to some popular registry providers. 54 | 55 | ### Docker Hub 56 | 57 | The following example publishes an image to a repository named `mydockerid/example` on Docker Hub. 58 | 59 | The repository will be created if it does not already exist. 60 | 61 | ``` 62 | swift package --swift-sdk x86_64-swift-linux-musl build-container-image \ 63 | --repository mydockerid/example 64 | ``` 65 | 66 | ### GitHub Container Registry 67 | 68 | The following example publishes an image to a repository named `mydockerid/example` on GitHub Container Registry. 69 | 70 | The repository will be created if it does not already exist. 71 | 72 | ``` 73 | swift package --swift-sdk x86_64-swift-linux-musl build-container-image \ 74 | --repository ghcr.io/mygithubusername/example 75 | ``` 76 | 77 | ### Amazon Elastic Container Registry 78 | 79 | The following example publishes an image to the repository named `example/test` on Amazon Elastic Container Registry. 80 | 81 | **The repository must already exist before you push to it.** 82 | Create a repository using the [Amazon ECR Console](https://docs.aws.amazon.com/AmazonECR/latest/userguide/repository-create.html). 83 | 84 | ``` 85 | swift package --swift-sdk x86_64-swift-linux-musl build-container-image \ 86 | --repository 123456789012.dkr.ecr.us-west-2.amazonaws.com/example/test 87 | ``` 88 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/requirements.md: -------------------------------------------------------------------------------- 1 | # Install a toolchain for cross-compilation 2 | 3 | Install an open source Swift toolchain and a compatible Swift SDK to build Linux executables on macOS. 4 | 5 | ## Overview 6 | 7 | * Swift Container Plugin runs on macOS and Linux and requires Swift 6.0 or later. 8 | * On macOS you must install a cross-compilation Swift SDK, such as the [Swift Static Linux SDK](https://www.swift.org/documentation/articles/static-linux-getting-started.html), in order to build executables which can run on Linux-based cloud infrastructure. 9 | * The Swift Static Linux SDK requires the [open source Swift toolchain](https://www.swift.org/install/macos/) to be installed. 10 | * A container runtime is not required to build an image, but you will need one wherever you want to run the image. 11 | 12 | ### Install an open source Swift toolchain 13 | 14 | Follow the instructions at [https://www.swift.org/install/macos/](https://www.swift.org/install/macos/) to install the open source Swift toolchain. The easiest way to do this is to use the [Swiftly swift toolchain installer](https://www.swift.org/install/macos/swiftly/). 15 | 16 | ### Install a Swift SDK for cross-compilation which matches the open source toolchain 17 | 18 | If you are running on macOS, you can use a [Swift SDK](https://github.com/apple/swift-evolution/blob/main/proposals/0387-cross-compilation-destinations.md) to cross-compile your server executable for Linux. Either: 19 | 20 | * Install the [Static Linux SDK from swift.org](https://www.swift.org/documentation/articles/static-linux-getting-started.html) 21 | * Use [Swift SDK Generator](https://github.com/apple/swift-sdk-generator) to build and install a custom SDK 22 | 23 | The following example illustrates installing the Swift Static Linux SDK: 24 | ``` 25 | % swift sdk install https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz --checksum 111c6f7d280a651208b8c74c0521dd99365d785c1976a6e23162f55f65379ac6 26 | Downloading a Swift SDK bundle archive from `https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz`... 27 | Downloading 28 | 100% [=============================================================] 29 | Downloading swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz 30 | 31 | Swift SDK bundle archive successfully downloaded from `https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz`. 32 | Verifying if checksum of the downloaded archive is valid... 33 | Downloaded archive has a valid checksum. 34 | Swift SDK bundle at `https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz` is assumed to be an archive, unpacking... 35 | Swift SDK bundle at `https://download.swift.org/swift-6.1-release/static-sdk/swift-6.1-RELEASE/swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle.tar.gz` successfully installed as swift-6.1-RELEASE_static-linux-0.0.1.artifactbundle. 36 | ``` 37 | 38 | After the installation is complete, check that the SDK is available: 39 | 40 | ```shell 41 | % swift sdk list 42 | swift-6.1-RELEASE_static-linux-0.0.1 43 | ``` 44 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Documentation.docc/run.md: -------------------------------------------------------------------------------- 1 | # Run your service 2 | 3 | Run the container you built on cloud infrastructure or locally on a desktop container runtime. 4 | ## Overview 5 | 6 | Swift Container Plugin builds standards-compliant container images which can run in public or private cloud infrastructure, or locally on a desktop container runtime. 7 | 8 | The following command uses `podman` to run the service locally, making it available on port 8080: 9 | 10 | ``` 11 | % podman run -p 8080:8080 registry.example.com/myservice@sha256:a3f75d0932d052dd9d448a1c9040b16f9f2c2ed9190317147dee95a218faf1df 12 | Trying to pull registry.example.com/myservice@sha256:a3f75d0932d052dd9d448a1c9040b16f9f2c2ed9190317147dee95a218faf1df... 13 | ... 14 | 2024-05-26T22:57:50+0000 info HummingBird : [HummingbirdCore] Server started and listening on 0.0.0.0:8080 15 | ``` 16 | 17 | When the service has started, we can access it with a web browser or `curl`: 18 | ``` 19 | % curl localhost:8080 20 | Hello World, from Hummingbird on Ubuntu 24.04.2 LTS 21 | ``` 22 | 23 | ### Build and run in one step 24 | 25 | Swift Container Plugin prints a reference to the newly built image on standard output. 26 | You can pipe the image reference to a deployment command or pass it as an argument using shell command substitution. 27 | 28 | This allows you to build and deploy a container image in one shell command, using a convenient pattern offered by tools such as [ko.build](https://ko.build): 29 | 30 | ``` 31 | % podman run -p 8080:8080 \ 32 | $(swift package --swift-sdk x86_64-linux-swift-musl \ 33 | build-container-image --repository registry.example.com/myservice) 34 | Trying to pull registry.example.com/myservice@sha256:a3f75d0932d052dd9d448a1c9040b16f9f2c2ed9190317147dee95a218faf1df... 35 | ... 36 | 2024-05-26T22:57:50+0000 info HummingBird : [HummingbirdCore] Server started and listening on 0.0.0.0:8080 37 | ``` 38 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/Empty.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | // This is an empty source file used to make SwiftContainerPluginDocumentation a valid 16 | // documentation build target. 17 | 18 | // SwiftContainerPluginDocumentation is an otherwise empty target that includes high-level, 19 | // user-facing documentation about using the Swift Container Plugin from the command-line. 20 | 21 | // To preview the documentation, run the following command: 22 | // 23 | // swift package --disable-sandbox preview-documentation --target swift-container-plugin --analyze 24 | -------------------------------------------------------------------------------- /Sources/swift-container-plugin/README.md: -------------------------------------------------------------------------------- 1 | # Swift Container Plugin User Documentation 2 | 3 | This is an otherwise empty target that includes high-level, user-facing documentation about using the plugin from the command-line. 4 | 5 | To preview the documentation, run the following command: 6 | 7 | ```bash 8 | swift package --disable-sandbox preview-documentation --target swift-container-plugin --analyze 9 | ``` 10 | 11 | 12 | -------------------------------------------------------------------------------- /Tests/ContainerRegistryTests/AuthTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import Basics 16 | import Foundation 17 | import Testing 18 | 19 | struct AuthTests { 20 | // SwiftPM's NetrcAuthorizationProvider does not throw an error if the .netrc file 21 | // does not exist. For simplicity the local vendored version does the same. 22 | @Test func testNonexistentNetrc() async throws { 23 | // Construct a URL to a nonexistent file in the bundle directory 24 | let netrcURL = Bundle.module.resourceURL!.appendingPathComponent("netrc.nonexistent") 25 | #expect(!FileManager.default.fileExists(atPath: netrcURL.path)) 26 | 27 | let authProvider = try NetrcAuthorizationProvider(netrcURL) 28 | #expect(authProvider.authentication(for: URL(string: "https://hub.example.com")!) == nil) 29 | } 30 | 31 | @Test func testEmptyNetrc() async throws { 32 | let netrcURL = Bundle.module.url(forResource: "netrc", withExtension: "empty")! 33 | let authProvider = try NetrcAuthorizationProvider(netrcURL) 34 | #expect(authProvider.authentication(for: URL(string: "https://hub.example.com")!) == nil) 35 | } 36 | 37 | @Test func testBasicNetrc() async throws { 38 | let netrcURL = Bundle.module.url(forResource: "netrc", withExtension: "basic")! 39 | let authProvider = try NetrcAuthorizationProvider(netrcURL) 40 | #expect(authProvider.authentication(for: URL(string: "https://nothing.example.com")!) == nil) 41 | 42 | guard let (user, password) = authProvider.authentication(for: URL(string: "https://hub.example.com")!) else { 43 | Issue.record("Expected to find a username and password") 44 | return 45 | } 46 | #expect(user == "swift") 47 | #expect(password == "password") 48 | } 49 | 50 | // The default entry is used if no specific entry matches 51 | @Test func testComplexNetrcWithDefault() async throws { 52 | let netrcURL = Bundle.module.url(forResource: "netrc", withExtension: "default")! 53 | let authProvider = try NetrcAuthorizationProvider(netrcURL) 54 | 55 | guard let (user, password) = authProvider.authentication(for: URL(string: "https://nothing.example.com")!) 56 | else { 57 | Issue.record("Expected to find a username and password") 58 | return 59 | } 60 | #expect(user == "defaultlogin") 61 | #expect(password == "defaultpassword") 62 | } 63 | 64 | // The default entry must be last in the file 65 | @Test func testComplexNetrcWithInvalidDefault() async throws { 66 | let netrcURL = Bundle.module.url(forResource: "netrc", withExtension: "invaliddefault")! 67 | #expect { try NetrcAuthorizationProvider(netrcURL) } throws: { error in 68 | error as! NetrcError == NetrcError.invalidDefaultMachinePosition 69 | } 70 | } 71 | 72 | // If there are multiple entries for the same host, the last one wins 73 | @Test func testComplexNetrcOverriddenEntry() async throws { 74 | let netrcURL = Bundle.module.url(forResource: "netrc", withExtension: "default")! 75 | let authProvider = try NetrcAuthorizationProvider(netrcURL) 76 | 77 | guard let (user, password) = authProvider.authentication(for: URL(string: "https://hub.example.com")!) else { 78 | Issue.record("Expected to find a username and password") 79 | return 80 | } 81 | #expect(user == "swift2") 82 | #expect(password == "password2") 83 | } 84 | 85 | // A singleton entry in a netrc file with defaults and overriden entries continues to work as in the simple case 86 | @Test func testComplexNetrcSingletonEntry() async throws { 87 | let netrcURL = Bundle.module.url(forResource: "netrc", withExtension: "default")! 88 | let authProvider = try NetrcAuthorizationProvider(netrcURL) 89 | 90 | guard let (user, password) = authProvider.authentication(for: URL(string: "https://another.example.com")!) 91 | else { 92 | Issue.record("Expected to find a username and password") 93 | return 94 | } 95 | #expect(user == "anotherlogin") 96 | #expect(password == "anotherpassword") 97 | } 98 | } 99 | -------------------------------------------------------------------------------- /Tests/ContainerRegistryTests/Resources/netrc.basic: -------------------------------------------------------------------------------- 1 | machine hub.example.com 2 | login swift 3 | password password 4 | -------------------------------------------------------------------------------- /Tests/ContainerRegistryTests/Resources/netrc.default: -------------------------------------------------------------------------------- 1 | machine hub.example.com 2 | login swift 3 | password password 4 | 5 | machine another.example.com 6 | login anotherlogin 7 | password anotherpassword 8 | 9 | machine hub.example.com 10 | login swift2 11 | password password2 12 | 13 | default 14 | login defaultlogin 15 | password defaultpassword 16 | 17 | -------------------------------------------------------------------------------- /Tests/ContainerRegistryTests/Resources/netrc.empty: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/apple/swift-container-plugin/9417b634d9d7afc3816505c05226b96691d1708c/Tests/ContainerRegistryTests/Resources/netrc.empty -------------------------------------------------------------------------------- /Tests/ContainerRegistryTests/Resources/netrc.invaliddefault: -------------------------------------------------------------------------------- 1 | default 2 | login defaultlogin 3 | password defaultpassword 4 | 5 | machine hub.example.com 6 | login swift 7 | password password 8 | 9 | machine another.example.com 10 | login anotherlogin 11 | password anotherpassword 12 | 13 | machine hub.example.com 14 | login swift2 15 | password password2 16 | 17 | -------------------------------------------------------------------------------- /Tests/ContainerRegistryTests/SmokeTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | import class Foundation.ProcessInfo 16 | @testable import ContainerRegistry 17 | import Testing 18 | 19 | struct SmokeTests { 20 | // These are basic tests to exercise the main registry operations. 21 | // The tests assume that a fresh, empty registry instance is available at 22 | // http://$REGISTRY_HOST:$REGISTRY_PORT 23 | 24 | var client: RegistryClient 25 | let registryHost = ProcessInfo.processInfo.environment["REGISTRY_HOST"] ?? "localhost" 26 | let registryPort = ProcessInfo.processInfo.environment["REGISTRY_PORT"] ?? "5000" 27 | 28 | /// Registry client fixture created for each test 29 | init() async throws { 30 | client = try await RegistryClient(registry: "\(registryHost):\(registryPort)", insecure: true) 31 | } 32 | 33 | @Test func testGetTags() async throws { 34 | let repository = try ImageReference.Repository("testgettags") 35 | 36 | // registry:2 does not validate the contents of the config or image blobs 37 | // so a smoke test can use simple data. Other registries are not so forgiving. 38 | let config_descriptor = try await client.putBlob( 39 | repository: repository, 40 | mediaType: "application/vnd.docker.container.image.v1+json", 41 | data: "testconfiguration".data(using: .utf8)! 42 | ) 43 | 44 | // Initially there will be no tags 45 | do { 46 | _ = try await client.getTags(repository: repository) 47 | Issue.record("Getting tags for an untagged blob should have thrown an error") 48 | } catch { 49 | // Expect to receive an error 50 | } 51 | 52 | // We need to create a manifest referring to the blob, which can then be tagged 53 | let test_manifest = ImageManifest( 54 | schemaVersion: 2, 55 | mediaType: "application/vnd.oci.image.manifest.v1+json", 56 | config: config_descriptor, 57 | layers: [] 58 | ) 59 | 60 | // After setting a tag, we should be able to retrieve it 61 | let _ = try await client.putManifest( 62 | repository: repository, 63 | reference: ImageReference.Tag("latest"), 64 | manifest: test_manifest 65 | ) 66 | let firstTag = try await client.getTags(repository: repository).tags.sorted() 67 | #expect(firstTag == ["latest"]) 68 | 69 | // After setting another tag, the original tag should still exist 70 | let _ = try await client.putManifest( 71 | repository: repository, 72 | reference: ImageReference.Tag("additional_tag"), 73 | manifest: test_manifest 74 | ) 75 | let secondTag = try await client.getTags(repository: repository) 76 | #expect(secondTag.tags.sorted() == ["additional_tag", "latest"].sorted()) 77 | } 78 | 79 | @Test func testGetNonexistentBlob() async throws { 80 | let repository = try ImageReference.Repository("testgetnonexistentblob") 81 | 82 | do { 83 | let _ = try await client.getBlob( 84 | repository: repository, 85 | digest: ImageReference.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") 86 | ) 87 | Issue.record("should have thrown") 88 | } catch {} 89 | } 90 | 91 | @Test func testCheckNonexistentBlob() async throws { 92 | let repository = try ImageReference.Repository("testchecknonexistentblob") 93 | 94 | let exists = try await client.blobExists( 95 | repository: repository, 96 | digest: ImageReference.Digest("sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa") 97 | ) 98 | #expect(!exists) 99 | } 100 | 101 | @Test func testPutAndGetBlob() async throws { 102 | let repository = try ImageReference.Repository("testputandgetblob") 103 | 104 | let blob_data = "test".data(using: .utf8)! 105 | 106 | let descriptor = try await client.putBlob(repository: repository, data: blob_data) 107 | #expect(descriptor.digest == "sha256:9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08") 108 | 109 | let exists = try await client.blobExists( 110 | repository: repository, 111 | digest: ImageReference.Digest(descriptor.digest) 112 | ) 113 | #expect(exists) 114 | 115 | let blob = try await client.getBlob(repository: repository, digest: ImageReference.Digest(descriptor.digest)) 116 | #expect(blob == blob_data) 117 | } 118 | 119 | @Test func testPutAndGetTaggedManifest() async throws { 120 | let repository = try ImageReference.Repository("testputandgettaggedmanifest") 121 | 122 | // registry:2 does not validate the contents of the config or image blobs 123 | // so a smoke test can use simple data. Other registries are not so forgiving. 124 | let config_data = "configuration".data(using: .utf8)! 125 | let config_descriptor = try await client.putBlob( 126 | repository: repository, 127 | mediaType: "application/vnd.docker.container.image.v1+json", 128 | data: config_data 129 | ) 130 | 131 | let image_data = "image_layer".data(using: .utf8)! 132 | let image_descriptor = try await client.putBlob( 133 | repository: repository, 134 | mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", 135 | data: image_data 136 | ) 137 | 138 | let test_manifest = ImageManifest( 139 | schemaVersion: 2, 140 | mediaType: "application/vnd.oci.image.manifest.v1+json", 141 | config: config_descriptor, 142 | layers: [image_descriptor] 143 | ) 144 | 145 | let _ = try await client.putManifest( 146 | repository: repository, 147 | reference: ImageReference.Tag("latest"), 148 | manifest: test_manifest 149 | ) 150 | 151 | let (manifest, _) = try await client.getManifest( 152 | repository: repository, 153 | reference: ImageReference.Tag("latest") 154 | ) 155 | #expect(manifest.schemaVersion == 2) 156 | #expect(manifest.config.mediaType == "application/vnd.docker.container.image.v1+json") 157 | #expect(manifest.layers.count == 1) 158 | #expect(manifest.layers[0].mediaType == "application/vnd.docker.image.rootfs.diff.tar.gzip") 159 | } 160 | 161 | @Test func testPutAndGetAnonymousManifest() async throws { 162 | let repository = try ImageReference.Repository("testputandgetanonymousmanifest") 163 | 164 | // registry:2 does not validate the contents of the config or image blobs 165 | // so a smoke test can use simple data. Other registries are not so forgiving. 166 | let config_data = "configuration".data(using: .utf8)! 167 | let config_descriptor = try await client.putBlob( 168 | repository: repository, 169 | mediaType: "application/vnd.docker.container.image.v1+json", 170 | data: config_data 171 | ) 172 | 173 | let image_data = "image_layer".data(using: .utf8)! 174 | let image_descriptor = try await client.putBlob( 175 | repository: repository, 176 | mediaType: "application/vnd.docker.image.rootfs.diff.tar.gzip", 177 | data: image_data 178 | ) 179 | 180 | let test_manifest = ImageManifest( 181 | schemaVersion: 2, 182 | mediaType: "application/vnd.oci.image.manifest.v1+json", 183 | config: config_descriptor, 184 | layers: [image_descriptor] 185 | ) 186 | 187 | let descriptor = try await client.putManifest( 188 | repository: repository, 189 | manifest: test_manifest 190 | ) 191 | 192 | let (manifest, _) = try await client.getManifest( 193 | repository: repository, 194 | reference: ImageReference.Digest(descriptor.digest) 195 | ) 196 | #expect(manifest.schemaVersion == 2) 197 | #expect(manifest.config.mediaType == "application/vnd.docker.container.image.v1+json") 198 | #expect(manifest.layers.count == 1) 199 | #expect(manifest.layers[0].mediaType == "application/vnd.docker.image.rootfs.diff.tar.gzip") 200 | } 201 | 202 | @Test func testPutAndGetImageConfiguration() async throws { 203 | let repository = try ImageReference.Repository("testputandgetimageconfiguration") 204 | let image = try ImageReference( 205 | registry: "registry", 206 | repository: repository, 207 | reference: ImageReference.Tag("latest") 208 | ) 209 | 210 | let configuration = ImageConfiguration( 211 | created: "1996-12-19T16:39:57-08:00", 212 | author: "test", 213 | architecture: "x86_64", 214 | os: "Linux", 215 | rootfs: .init(_type: "layers", diff_ids: ["abc123", "def456"]), 216 | history: [.init(created: "1996-12-19T16:39:57-08:00", author: "test", created_by: "smoketest")] 217 | ) 218 | let config_descriptor = try await client.putImageConfiguration( 219 | forImage: image, 220 | configuration: configuration 221 | ) 222 | 223 | let downloaded = try await client.getImageConfiguration( 224 | forImage: image, 225 | digest: ImageReference.Digest(config_descriptor.digest) 226 | ) 227 | 228 | #expect(configuration == downloaded) 229 | } 230 | } 231 | -------------------------------------------------------------------------------- /Tests/containertoolTests/ZlibTests.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftContainerPlugin open source project 4 | // 5 | // Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | 15 | @testable import containertool 16 | import struct Crypto.SHA256 17 | import Testing 18 | 19 | // Check that compressing the same data on macOS and Linux produces the same output. 20 | struct ZlibTests { 21 | @Test func testGzipHeader() async throws { 22 | let data = "test" 23 | let result = gzip([UInt8](data.utf8)) 24 | #expect( 25 | "\(SHA256.hash(data: result))" 26 | == "SHA256 digest: 7dff8d09129482017247cb373e8138772e852a1a02f097d1440387055d2be69c" 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Vendor/github.com/apple/swift-nio-extras/Sources/CNIOExtrasZlib/empty.c: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftNIO open source project 4 | // 5 | // Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftNIO project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | -------------------------------------------------------------------------------- /Vendor/github.com/apple/swift-nio-extras/Sources/CNIOExtrasZlib/include/CNIOExtrasZlib.h: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the SwiftNIO open source project 4 | // 5 | // Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors 6 | // Licensed under Apache License v2.0 7 | // 8 | // See LICENSE.txt for license information 9 | // See CONTRIBUTORS.txt for the list of SwiftNIO project authors 10 | // 11 | // SPDX-License-Identifier: Apache-2.0 12 | // 13 | //===----------------------------------------------------------------------===// 14 | #ifndef C_NIO_ZLIB_H 15 | #define C_NIO_ZLIB_H 16 | 17 | #include 18 | 19 | static inline int CNIOExtrasZlib_deflateInit2(z_streamp strm, 20 | int level, 21 | int method, 22 | int windowBits, 23 | int memLevel, 24 | int strategy) { 25 | return deflateInit2(strm, level, method, windowBits, memLevel, strategy); 26 | } 27 | 28 | static inline int CNIOExtrasZlib_inflateInit2(z_streamp strm, int windowBits) { 29 | return inflateInit2(strm, windowBits); 30 | } 31 | 32 | static inline Bytef *CNIOExtrasZlib_voidPtr_to_BytefPtr(void *in) { 33 | return (Bytef *)in; 34 | } 35 | 36 | #endif 37 | -------------------------------------------------------------------------------- /Vendor/github.com/apple/swift-package-manager/Sources/Basics/AuthorizationProvider.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift open source project 4 | // 5 | // Copyright (c) 2021-2023 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See http://swift.org/LICENSE.txt for license information 9 | // See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors 10 | // 11 | //===----------------------------------------------------------------------===// 12 | 13 | // Adapted from Sources/Basics/AuthorizationProvider.swift 14 | // Keychain and AuthorizationWriter removed. 15 | // Use of Filesystem and AbsolutePath removed. 16 | 17 | import struct Foundation.Data 18 | import struct Foundation.Date 19 | import struct Foundation.URL 20 | #if canImport(Security) 21 | import Security 22 | #endif 23 | 24 | public protocol AuthorizationProvider: Sendable { 25 | func authentication(for url: URL) -> (user: String, password: String)? 26 | } 27 | 28 | public enum AuthorizationProviderError: Error { 29 | case invalidURLHost 30 | case notFound 31 | case cannotEncodePassword 32 | case other(String) 33 | } 34 | 35 | public extension AuthorizationProvider { 36 | @Sendable 37 | func httpAuthorizationHeader(for url: URL) -> String? { 38 | guard let (user, password) = self.authentication(for: url) else { 39 | return nil 40 | } 41 | let authString = "\(user):\(password)" 42 | guard let authData = authString.data(using: .utf8) else { 43 | return nil 44 | } 45 | return "Basic \(authData.base64EncodedString())" 46 | } 47 | } 48 | 49 | // MARK: - netrc 50 | 51 | public final class NetrcAuthorizationProvider: AuthorizationProvider { 52 | let netrc: Netrc? 53 | 54 | public init(_ path: URL) throws { 55 | self.netrc = try NetrcAuthorizationProvider.load(path) 56 | } 57 | 58 | public func authentication(for url: URL) -> (user: String, password: String)? { 59 | return self.machine(for: url).map { (user: $0.login, password: $0.password) } 60 | } 61 | 62 | private func machine(for url: URL) -> Basics.Netrc.Machine? { 63 | // Since updates are appended to the end of the file, we 64 | // take the _last_ match to use the most recent entry. 65 | if let machine = NetrcAuthorizationProvider.machine(for: url), 66 | let existing = self.netrc?.machines.last(where: { $0.name.lowercased() == machine }) 67 | { 68 | return existing 69 | } 70 | 71 | // No match found. Use the first default if any. 72 | if let existing = self.netrc?.machines.first(where: { $0.isDefault }) { 73 | return existing 74 | } 75 | 76 | return .none 77 | } 78 | 79 | private static func load(_ path: URL) throws -> Netrc? { 80 | do { 81 | let content = try? String(contentsOf: path, encoding: .utf8) 82 | return try NetrcParser.parse(content ?? "") 83 | } catch NetrcError.machineNotFound { 84 | // Thrown by parse() if .netrc is empty. 85 | // SwiftPM suppresses this error, so we will follow suit. 86 | return .none 87 | } 88 | } 89 | 90 | private static func machine(for url: URL) -> String? { 91 | guard let host = url.host?.lowercased() else { 92 | return nil 93 | } 94 | return host.isEmpty ? nil : host 95 | } 96 | } 97 | -------------------------------------------------------------------------------- /Vendor/github.com/apple/swift-package-manager/Sources/Basics/Netrc.swift: -------------------------------------------------------------------------------- 1 | //===----------------------------------------------------------------------===// 2 | // 3 | // This source file is part of the Swift open source project 4 | // 5 | // Copyright (c) 2022 Apple Inc. and the Swift project authors 6 | // Licensed under Apache License v2.0 with Runtime Library Exception 7 | // 8 | // See http://swift.org/LICENSE.txt for license information 9 | // See http://swift.org/CONTRIBUTORS.txt for the list of Swift project authors 10 | // 11 | //===----------------------------------------------------------------------===// 12 | 13 | import Foundation 14 | 15 | /// Representation of Netrc configuration 16 | public struct Netrc: Sendable { 17 | /// Representation of `machine` connection settings & `default` connection settings. 18 | /// If `default` connection settings present, they will be last element. 19 | public let machines: [Machine] 20 | 21 | fileprivate init(machines: [Machine]) { 22 | self.machines = machines 23 | } 24 | 25 | /// Returns auth information 26 | /// 27 | /// - Parameters: 28 | /// - url: The url to retrieve authorization information for. 29 | public func authorization(for url: URL) -> Authorization? { 30 | guard let index = machines.firstIndex(where: { $0.name == url.host }) ?? machines 31 | .firstIndex(where: { $0.isDefault }) 32 | else { 33 | return .none 34 | } 35 | let machine = self.machines[index] 36 | return Authorization(login: machine.login, password: machine.password) 37 | } 38 | 39 | /// Representation of connection settings 40 | public struct Machine: Equatable, Sendable { 41 | public let name: String 42 | public let login: String 43 | public let password: String 44 | 45 | public var isDefault: Bool { 46 | self.name == "default" 47 | } 48 | 49 | public init(name: String, login: String, password: String) { 50 | self.name = name 51 | self.login = login 52 | self.password = password 53 | } 54 | 55 | init?(for match: NSTextCheckingResult, string: String, variant: String = "") { 56 | guard let name = RegexUtil.Token.machine.capture(in: match, string: string) ?? RegexUtil.Token.default 57 | .capture(in: match, string: string), 58 | let login = RegexUtil.Token.login.capture(prefix: variant, in: match, string: string), 59 | let password = RegexUtil.Token.password.capture(prefix: variant, in: match, string: string) 60 | else { 61 | return nil 62 | } 63 | self = Machine(name: name, login: login, password: password) 64 | } 65 | } 66 | 67 | /// Representation of authorization information 68 | public struct Authorization: Equatable { 69 | public let login: String 70 | public let password: String 71 | 72 | public init(login: String, password: String) { 73 | self.login = login 74 | self.password = password 75 | } 76 | } 77 | } 78 | 79 | public struct NetrcParser { 80 | /// Parses stringified netrc content 81 | /// 82 | /// - Parameters: 83 | /// - content: The content to parse 84 | public static func parse(_ content: String) throws -> Netrc { 85 | let content = self.trimComments(from: content) 86 | let regex = try! NSRegularExpression(pattern: RegexUtil.netrcPattern, options: []) 87 | let matches = regex.matches( 88 | in: content, 89 | options: [], 90 | range: NSRange(content.startIndex ..< content.endIndex, in: content) 91 | ) 92 | 93 | let machines: [Netrc.Machine] = matches.compactMap { 94 | Netrc.Machine(for: $0, string: content, variant: "lp") ?? Netrc 95 | .Machine(for: $0, string: content, variant: "pl") 96 | } 97 | 98 | if let defIndex = machines.firstIndex(where: { $0.isDefault }) { 99 | guard defIndex == machines.index(before: machines.endIndex) else { 100 | throw NetrcError.invalidDefaultMachinePosition 101 | } 102 | } 103 | guard machines.count > 0 else { 104 | throw NetrcError.machineNotFound 105 | } 106 | return Netrc(machines: machines) 107 | } 108 | 109 | /// Utility method to trim comments from netrc content 110 | /// - Parameter text: String text of netrc file 111 | /// - Returns: String text of netrc file *sans* comments 112 | private static func trimComments(from text: String) -> String { 113 | let regex = try! NSRegularExpression(pattern: RegexUtil.comments, options: .anchorsMatchLines) 114 | let nsString = text as NSString 115 | let range = NSRange(location: 0, length: nsString.length) 116 | let matches = regex.matches(in: text, range: range) 117 | var trimmedCommentsText = text 118 | matches.forEach { 119 | trimmedCommentsText = trimmedCommentsText 120 | .replacingOccurrences(of: nsString.substring(with: $0.range), with: "") 121 | } 122 | return trimmedCommentsText 123 | } 124 | } 125 | 126 | public enum NetrcError: Error, Equatable { 127 | case machineNotFound 128 | case invalidDefaultMachinePosition 129 | } 130 | 131 | private enum RegexUtil { 132 | fileprivate enum Token: String, CaseIterable { 133 | case machine, login, password, account, macdef, `default` 134 | 135 | func capture(prefix: String = "", in match: NSTextCheckingResult, string: String) -> String? { 136 | guard let range = Range(match.range(withName: prefix + rawValue), in: string) else { return nil } 137 | return String(string[range]) 138 | } 139 | } 140 | 141 | static let comments: String = "\\#[\\s\\S]*?.*$" 142 | static let `default`: String = #"(?:\s*(?default))"# 143 | static let accountOptional: String = #"(?:\s*account\s+\S++)?"# 144 | static let loginPassword: String = 145 | #"\#(namedTrailingCapture("login", prefix: "lp"))\#(accountOptional)\#(namedTrailingCapture("password", prefix: "lp"))"# 146 | static let passwordLogin: String = 147 | #"\#(namedTrailingCapture("password", prefix: "pl"))\#(accountOptional)\#(namedTrailingCapture("login", prefix: "pl"))"# 148 | static let netrcPattern = 149 | #"(?:(?:(\#(namedTrailingCapture("machine"))|\#(namedMatch("default"))))(?:\#(loginPassword)|\#(passwordLogin)))"# 150 | 151 | static func namedMatch(_ string: String) -> String { 152 | #"(?:\s*(?<\#(string)>\#(string)))"# 153 | } 154 | 155 | static func namedTrailingCapture(_ string: String, prefix: String = "") -> String { 156 | #"\s*\#(string)\s+(?<\#(prefix + string)>\S++)"# 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /scripts/generate-contributors-list.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ##===----------------------------------------------------------------------===## 3 | ## 4 | ## This source file is part of the SwiftContainerPlugin open source project 5 | ## 6 | ## Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 7 | ## Licensed under Apache License v2.0 8 | ## 9 | ## See LICENSE.txt for license information 10 | ## See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 11 | ## 12 | ## SPDX-License-Identifier: Apache-2.0 13 | ## 14 | ##===----------------------------------------------------------------------===## 15 | ##===----------------------------------------------------------------------===## 16 | ## 17 | ## This source file is part of the SwiftNIO open source project 18 | ## 19 | ## Copyright (c) 2017-2018 Apple Inc. and the SwiftNIO project authors 20 | ## Licensed under Apache License v2.0 21 | ## 22 | ## See LICENSE.txt for license information 23 | ## See CONTRIBUTORS.txt for the list of SwiftNIO project authors 24 | ## 25 | ## SPDX-License-Identifier: Apache-2.0 26 | ## 27 | ##===----------------------------------------------------------------------===## 28 | 29 | set -eu 30 | here="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 31 | contributors=$( cd "$here"/.. && git shortlog -es | cut -f2 | sed 's/^/- /' ) 32 | 33 | cat > "$here/../CONTRIBUTORS.txt" <<- EOF 34 | For the purpose of tracking copyright, this is the list of individuals and 35 | organizations who have contributed source code to SwiftContainerPlugin. 36 | 37 | For employees of an organization/company where the copyright of work done 38 | by employees of that company is held by the company itself, only the company 39 | needs to be listed here. 40 | 41 | ## COPYRIGHT HOLDERS 42 | 43 | - Apple Inc. (all contributors with '@apple.com') 44 | 45 | ### Contributors 46 | 47 | $contributors 48 | 49 | **Updating this list** 50 | 51 | Please do not edit this file manually. It is generated using \`bash ./scripts/generate-contributors-list.sh\`. If a name is misspelled or appearing multiple times: add an entry in \`./.mailmap\` 52 | EOF 53 | -------------------------------------------------------------------------------- /scripts/run-integration-tests.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ##===----------------------------------------------------------------------===## 3 | ## 4 | ## This source file is part of the SwiftContainerPlugin open source project 5 | ## 6 | ## Copyright (c) 2024 Apple Inc. and the SwiftContainerPlugin project authors 7 | ## Licensed under Apache License v2.0 8 | ## 9 | ## See LICENSE.txt for license information 10 | ## See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 11 | ## 12 | ## SPDX-License-Identifier: Apache-2.0 13 | ## 14 | ##===----------------------------------------------------------------------===## 15 | 16 | 17 | log() { printf -- "** %s\n" "$*" >&2; } 18 | error() { printf -- "** ERROR: %s\n" "$*" >&2; } 19 | fatal() { error "$@"; exit 1; } 20 | 21 | set -euo pipefail 22 | 23 | RUNTIME=${RUNTIME-"docker"} 24 | 25 | # Start a registry on an ephemeral port 26 | REGISTRY_ID=$($RUNTIME run -d --rm -p 127.0.0.1::5000 registry:2) 27 | export REGISTRY_HOST="localhost" 28 | REGISTRY_PORT=$($RUNTIME port "$REGISTRY_ID" 5000/tcp | sed -E 's/^.+:([[:digit:]]+)$/\1/') 29 | export REGISTRY_PORT 30 | log "Registry $REGISTRY_ID listening on $REGISTRY_HOST:$REGISTRY_PORT" 31 | 32 | # Delete the registry after running the tests, regardless of the outcome 33 | cleanup() { 34 | log "Deleting registry $REGISTRY_ID" 35 | $RUNTIME rm -f "$REGISTRY_ID" 36 | } 37 | trap cleanup EXIT 38 | 39 | log "Running smoke tests" 40 | swift test --filter 'SmokeTests' 41 | -------------------------------------------------------------------------------- /scripts/test-containertool-elf-detection.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ##===----------------------------------------------------------------------===## 3 | ## 4 | ## This source file is part of the SwiftContainerPlugin open source project 5 | ## 6 | ## Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 7 | ## Licensed under Apache License v2.0 8 | ## 9 | ## See LICENSE.txt for license information 10 | ## See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 11 | ## 12 | ## SPDX-License-Identifier: Apache-2.0 13 | ## 14 | ##===----------------------------------------------------------------------===## 15 | 16 | # 17 | # This script assumes that the Static Linux SDK has already been installed 18 | # 19 | 20 | log() { printf -- "** %s\n" "$*" >&2; } 21 | error() { printf -- "** ERROR: %s\n" "$*" >&2; } 22 | fatal() { error "$@"; exit 1; } 23 | 24 | set -euo pipefail 25 | 26 | RUNTIME=${RUNTIME-"docker"} 27 | 28 | # 29 | # Create a test package 30 | # 31 | PKGPATH=$(mktemp -d) 32 | swift package --package-path "$PKGPATH" init --type executable --name hello 33 | 34 | cleanup() { 35 | log "Deleting temporary package $PKGPATH" 36 | rm -rf "$PKGPATH" 37 | } 38 | trap cleanup EXIT 39 | 40 | # 41 | # Build and package an x86_64 binary 42 | # 43 | swift build --package-path "$PKGPATH" --swift-sdk x86_64-swift-linux-musl 44 | FILETYPE=$(file "$PKGPATH/.build/x86_64-swift-linux-musl/debug/hello") 45 | log "Executable type: $FILETYPE" 46 | 47 | IMGREF=$(swift run containertool --repository localhost:5000/elf_test "$PKGPATH/.build/x86_64-swift-linux-musl/debug/hello" --from scratch) 48 | $RUNTIME pull --platform=linux/amd64 "$IMGREF" 49 | IMGARCH=$($RUNTIME inspect "$IMGREF" --format "{{.Architecture}}") 50 | if [ "$IMGARCH" = "amd64" ] ; then 51 | log "x86_64 detection: PASSED" 52 | else 53 | fatal "x86_64 detection: FAILED - image architecture was $IMGARCH; expected amd64" 54 | fi 55 | 56 | # 57 | # Build and package an aarch64 binary 58 | # 59 | swift build --package-path "$PKGPATH" --swift-sdk aarch64-swift-linux-musl 60 | FILETYPE=$(file "$PKGPATH/.build/x86_64-swift-linux-musl/debug/hello") 61 | log "Executable type: $FILETYPE" 62 | 63 | IMGREF=$(swift run containertool --repository localhost:5000/elf_test "$PKGPATH/.build/aarch64-swift-linux-musl/debug/hello" --from scratch) 64 | $RUNTIME pull --platform=linux/arm64 "$IMGREF" 65 | IMGARCH=$($RUNTIME inspect "$IMGREF" --format "{{.Architecture}}") 66 | if [ "$IMGARCH" = "arm64" ] ; then 67 | log "aarch64 detection: PASSED" 68 | else 69 | fatal "aarch64 detection: FAILED - image architecture was $IMGARCH; expected arm64" 70 | fi 71 | -------------------------------------------------------------------------------- /scripts/test-containertool-resources.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ##===----------------------------------------------------------------------===## 3 | ## 4 | ## This source file is part of the SwiftContainerPlugin open source project 5 | ## 6 | ## Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 7 | ## Licensed under Apache License v2.0 8 | ## 9 | ## See LICENSE.txt for license information 10 | ## See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 11 | ## 12 | ## SPDX-License-Identifier: Apache-2.0 13 | ## 14 | ##===----------------------------------------------------------------------===## 15 | 16 | log() { printf -- "** %s\n" "$*" >&2; } 17 | error() { printf -- "** ERROR: %s\n" "$*" >&2; } 18 | fatal() { error "$@"; exit 1; } 19 | 20 | set -euo pipefail 21 | 22 | RUNTIME=${RUNTIME-"docker"} 23 | PKGPATH=$(mktemp -d) 24 | 25 | # 26 | # Package an example payload with resources. This test only checks 27 | # that the correct files are in the container image and does not run 28 | # it, so the payload does not need to be an executable. 29 | # 30 | touch "$PKGPATH/hello" 31 | mkdir -p "$PKGPATH/resourcedir" 32 | touch "$PKGPATH/resourcedir/resource1.txt" "$PKGPATH/resourcedir/resource2.txt" "$PKGPATH/resourcedir/resource3.txt" 33 | touch "$PKGPATH/resourcefile.dat" 34 | swift run containertool \ 35 | "$PKGPATH/hello" \ 36 | --repository localhost:5000/resource_test \ 37 | --from scratch \ 38 | --resources "$PKGPATH/resourcedir" \ 39 | --resources "$PKGPATH/resourcefile.dat" 40 | 41 | $RUNTIME rm -f resource-test 42 | $RUNTIME create --pull always --name resource-test localhost:5000/resource_test:latest 43 | 44 | cleanup() { 45 | log "Deleting temporary package $PKGPATH" 46 | rm -rf "$PKGPATH" 47 | 48 | log "Deleting resource-test container" 49 | $RUNTIME rm -f resource-test 50 | } 51 | trap cleanup EXIT 52 | 53 | 54 | for resource in \ 55 | /hello \ 56 | /resourcedir/resource1.txt \ 57 | /resourcedir/resource2.txt \ 58 | /resourcedir/resource3.txt \ 59 | /resourcefile.dat 60 | do 61 | # This will return a non-zero exit code if the file does not exist 62 | if $RUNTIME cp resource-test:$resource - > /dev/null ; then 63 | log "$resource: OK" 64 | else 65 | fatal "$resource: FAILED" 66 | fi 67 | done 68 | 69 | -------------------------------------------------------------------------------- /scripts/test-plugin-output-streaming.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ##===----------------------------------------------------------------------===## 3 | ## 4 | ## This source file is part of the SwiftContainerPlugin open source project 5 | ## 6 | ## Copyright (c) 2025 Apple Inc. and the SwiftContainerPlugin project authors 7 | ## Licensed under Apache License v2.0 8 | ## 9 | ## See LICENSE.txt for license information 10 | ## See CONTRIBUTORS.txt for the list of SwiftContainerPlugin project authors 11 | ## 12 | ## SPDX-License-Identifier: Apache-2.0 13 | ## 14 | ##===----------------------------------------------------------------------===## 15 | 16 | # Test that error output streamed from containertool is printed correctly by the plugin. 17 | 18 | set -euo pipefail 19 | 20 | log() { printf -- "** %s\n" "$*" >&2; } 21 | error() { printf -- "** ERROR: %s\n" "$*" >&2; } 22 | fatal() { error "$@"; exit 1; } 23 | 24 | # Work in a temporary directory, deleted after the test finishes 25 | PKGPATH=$(mktemp -d) 26 | cleanup() { 27 | log "Deleting temporary package $PKGPATH" 28 | rm -rf "$PKGPATH" 29 | } 30 | trap cleanup EXIT 31 | 32 | # Create a test project which depends on this checkout of the plugin repository 33 | REPO_ROOT=$(git rev-parse --show-toplevel) 34 | swift package --package-path "$PKGPATH" init --type executable --name hello 35 | cat >> "$PKGPATH/Package.swift" <&1 | tee "$PKGPATH/output" 46 | set -o pipefail 47 | 48 | # This checks that the output lines are not broken, but not that they appear in the correct order 49 | grep -F -x -e "error: Please specify the destination repository using --repository or CONTAINERTOOL_REPOSITORY" \ 50 | -e "error: Usage: containertool [] " \ 51 | -e "error: See 'containertool --help' for more information." "$PKGPATH/output" 52 | 53 | log Plugin error output: PASSED 54 | 55 | --------------------------------------------------------------------------------