├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ ├── ci.yml │ └── tag.yml ├── .gitignore ├── CODEOWNERS ├── Dockerfile ├── LICENSE ├── OWNERS.md ├── README.md ├── claimconditions.go ├── claimconditions_test.go ├── context.go ├── context_test.go ├── example ├── .dev │ └── functions.yaml ├── conditions │ ├── README.md │ ├── composition.yaml │ ├── functions.yaml │ ├── xr.yaml │ └── xrd.yaml ├── context │ ├── README.md │ ├── composition.yaml │ ├── environmentConfigs.yaml │ ├── functions.yaml │ ├── xr.yaml │ └── xrd.yaml ├── custom-delims │ ├── composition.yaml │ ├── functions.yaml │ └── xr.yaml ├── extra-resources │ ├── composition.yaml │ ├── extraResources.yaml │ ├── functions.yaml │ └── xr.yaml ├── filesystem │ ├── README.md │ ├── claim.yaml │ ├── composition-fs.yaml │ ├── functions.yaml │ ├── templates.tmpl │ └── xrd.yaml ├── functions │ ├── fromYaml │ │ ├── composition.yaml │ │ ├── functions.yaml │ │ └── xr.yaml │ ├── getComposedResource │ │ ├── README.md │ │ ├── composition.yaml │ │ ├── functions.yaml │ │ ├── observed.yaml │ │ └── xr.yaml │ ├── getCompositeResource │ │ ├── README.md │ │ ├── composition.yaml │ │ ├── functions.yaml │ │ └── xr.yaml │ ├── getResourceCondition │ │ ├── README.md │ │ ├── composition.yaml │ │ ├── functions.yaml │ │ ├── observed.yaml │ │ └── xr.yaml │ ├── include │ │ ├── README.md │ │ ├── composition.yaml │ │ ├── functions.yaml │ │ └── xr.yaml │ └── toYaml │ │ ├── composition.yaml │ │ ├── functions.yaml │ │ └── xr.yaml ├── inline │ ├── composition.yaml │ ├── functions.yaml │ └── xr.yaml └── recursive │ ├── composition-real.yaml │ ├── composition-wrapper.yaml │ ├── functions.yaml │ └── xr.yaml ├── extraresources.go ├── fn.go ├── fn_test.go ├── function_maps.go ├── function_maps_test.go ├── go.mod ├── go.sum ├── input ├── generate.go └── v1beta1 │ ├── input.go │ └── zz_generated.deepcopy.go ├── main.go ├── package ├── crossplane.yaml └── input │ └── gotemplating.fn.crossplane.io_gotemplates.yaml ├── renovate.json ├── template.go └── testdata └── templates ├── ..shouldBeSkipped ├── _helpers.tpl └── resource.yaml └── templates.yaml /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Help us diagnose and fix bugs in this Function 4 | labels: bug 5 | --- 6 | 13 | 14 | ### What happened? 15 | 19 | 20 | 21 | ### How can we reproduce it? 22 | 27 | 28 | ### What environment did it happen in? 29 | Function version: 30 | 31 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Help us make this Function more useful 4 | labels: enhancement 5 | --- 6 | 13 | 14 | ### What problem are you facing? 15 | 20 | 21 | ### How could this Function help solve your problem? 22 | 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Description of your changes 10 | 11 | 21 | 22 | Fixes # 23 | 24 | I have: 25 | 26 | - [ ] Read and followed Crossplane's [contribution process]. 27 | - [ ] Added or updated unit tests for my change. 28 | 29 | [contribution process]: https://git.io/fj2m9 30 | [docs]: https://docs.crossplane.io/contribute/contribute 31 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release-* 8 | pull_request: {} 9 | workflow_dispatch: 10 | inputs: 11 | version: 12 | description: Package version (e.g. v0.1.0) 13 | required: false 14 | 15 | env: 16 | # Common versions 17 | GO_VERSION: '1.23.8' 18 | GOLANGCI_VERSION: 'v1.62.0' 19 | DOCKER_BUILDX_VERSION: 'v0.23.0' 20 | 21 | # These environment variables are important to the Crossplane CLI install.sh 22 | # script. They determine what version it installs. 23 | XP_CHANNEL: master # TODO(negz): Pin to stable once v1.14 is released. 24 | XP_VERSION: current # TODO(negz): Pin to a version once v1.14 is released. 25 | 26 | # This CI job will automatically push new builds to xpkg.upbound.io if the 27 | # XPKG_ACCESS_ID and XPKG_TOKEN secrets are set in the GitHub respository (or 28 | # organization) settings. Create a token at https://accounts.upbound.io. 29 | XPKG_ACCESS_ID: ${{ secrets.XPKG_ACCESS_ID }} 30 | 31 | # The package to push, without a version tag. The default matches GitHub. For 32 | # example xpkg.upbound.io/crossplane/function-template-go. 33 | XPKG: xpkg.upbound.io/${{ github.repository}} 34 | CROSSPLANE_REGORG: ghcr.io/${{ github.repository}} # xpkg.crossplane.io/crossplane-contrib 35 | 36 | # The package version to push. The default is 0.0.0-gitsha. 37 | XPKG_VERSION: ${{ inputs.version }} 38 | 39 | jobs: 40 | lint: 41 | runs-on: ubuntu-24.04 42 | steps: 43 | - name: Checkout 44 | uses: actions/checkout@v4 45 | 46 | - name: Setup Go 47 | uses: actions/setup-go@v5 48 | with: 49 | go-version: ${{ env.GO_VERSION }} 50 | cache: false # The golangci-lint action does its own caching. 51 | 52 | - name: Check go mod tidy 53 | run: go mod tidy && git diff --exit-code go.mod go.sum 54 | 55 | - name: Lint 56 | uses: golangci/golangci-lint-action@v6 57 | with: 58 | version: ${{ env.GOLANGCI_VERSION }} 59 | 60 | unit-test: 61 | runs-on: ubuntu-24.04 62 | steps: 63 | - name: Checkout 64 | uses: actions/checkout@v4 65 | 66 | - name: Setup Go 67 | uses: actions/setup-go@v5 68 | with: 69 | go-version: ${{ env.GO_VERSION }} 70 | 71 | - name: Run Unit Tests 72 | run: go test -v -cover ./... 73 | 74 | # We want to build most packages for the amd64 and arm64 architectures. To 75 | # speed this up we build single-platform packages in parallel. We then upload 76 | # those packages to GitHub as a build artifact. The push job downloads those 77 | # artifacts and pushes them as a single multi-platform package. 78 | build: 79 | runs-on: ubuntu-24.04 80 | strategy: 81 | fail-fast: true 82 | matrix: 83 | arch: 84 | - amd64 85 | - arm64 86 | steps: 87 | - name: Setup QEMU 88 | uses: docker/setup-qemu-action@v3 89 | with: 90 | platforms: all 91 | 92 | - name: Setup Docker Buildx 93 | uses: docker/setup-buildx-action@v3 94 | with: 95 | version: ${{ env.DOCKER_BUILDX_VERSION }} 96 | install: true 97 | 98 | - name: Checkout 99 | uses: actions/checkout@v4 100 | 101 | # We ask Docker to use GitHub Action's native caching support to speed up 102 | # the build, per https://docs.docker.com/build/cache/backends/gha/. 103 | - name: Build Runtime 104 | id: image 105 | uses: docker/build-push-action@v6 106 | with: 107 | context: . 108 | platforms: linux/${{ matrix.arch }} 109 | cache-from: type=gha 110 | cache-to: type=gha,mode=max 111 | target: image 112 | build-args: 113 | GO_VERSION=${{ env.GO_VERSION }} 114 | outputs: type=docker,dest=runtime-${{ matrix.arch }}.tar 115 | 116 | - name: Setup the Crossplane CLI 117 | run: "curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh" 118 | 119 | - name: Build Package 120 | run: ./crossplane xpkg build --package-file=${{ matrix.arch }}.xpkg --package-root=package/ --embed-runtime-image-tarball=runtime-${{ matrix.arch }}.tar 121 | 122 | - name: Upload Single-Platform Package 123 | uses: actions/upload-artifact@v4 124 | with: 125 | name: package-${{ matrix.arch }} 126 | path: "*.xpkg" 127 | if-no-files-found: error 128 | retention-days: 1 129 | 130 | # This job downloads the single-platform packages built by the build job, and 131 | # pushes them as a multi-platform package. We only push the package it the 132 | # XPKG_ACCESS_ID and XPKG_TOKEN secrets were provided. 133 | push: 134 | runs-on: ubuntu-24.04 135 | needs: 136 | - build 137 | steps: 138 | - name: Checkout 139 | uses: actions/checkout@v4 140 | 141 | - name: Download Single-Platform Packages 142 | uses: actions/download-artifact@v4 143 | with: 144 | path: . 145 | merge-multiple: true 146 | pattern: "!*.dockerbuild" # This gets uploaded by docker/build-push-action but must be skipped: https://github.com/actions/toolkit/pull/1874 147 | 148 | - name: Setup the Crossplane CLI 149 | run: "curl -sL https://raw.githubusercontent.com/crossplane/crossplane/master/install.sh | sh" 150 | 151 | - name: Login to Upbound 152 | uses: docker/login-action@v3 153 | if: env.XPKG_ACCESS_ID != '' 154 | with: 155 | registry: xpkg.upbound.io 156 | username: ${{ secrets.XPKG_ACCESS_ID }} 157 | password: ${{ secrets.XPKG_TOKEN }} 158 | 159 | # If a version wasn't explicitly passed as a workflow_dispatch input we 160 | # default to version v0.0.0--, for example 161 | # v0.0.0-20231101115142-1091066df799. This is a simple implementation of 162 | # Go's pseudo-versions: https://go.dev/ref/mod#pseudo-versions. 163 | - name: Set Default Multi-Platform Package Version 164 | if: env.XPKG_VERSION == '' 165 | run: echo "XPKG_VERSION=v0.0.0-$(date -d@$(git show -s --format=%ct) +%Y%m%d%H%M%S)-$(git rev-parse --short=12 HEAD)" >> $GITHUB_ENV 166 | 167 | - name: Push Multi-Platform Package to Upbound 168 | if: env.XPKG_ACCESS_ID != '' 169 | run: "./crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.XPKG }}:${{ env.XPKG_VERSION }}" 170 | 171 | - name: Login to GHCR 172 | uses: docker/login-action@v3.3.0 173 | with: 174 | registry: ghcr.io 175 | username: ${{ github.repository_owner }} 176 | password: ${{ secrets.GITHUB_TOKEN }} 177 | 178 | - name: Push Multi-Platform Package to GHCR 179 | if: env.XPKG_ACCESS_ID != '' 180 | run: "./crossplane --verbose xpkg push --package-files $(echo *.xpkg|tr ' ' ,) ${{ env.CROSSPLANE_REGORG }}:${{ env.XPKG_VERSION }}" 181 | -------------------------------------------------------------------------------- /.github/workflows/tag.yml: -------------------------------------------------------------------------------- 1 | name: Tag 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Release version (e.g. v0.1.0)' 8 | required: true 9 | message: 10 | description: 'Tag message' 11 | required: true 12 | 13 | jobs: 14 | create-tag: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 20 | 21 | - name: Create Tag 22 | uses: negz/create-tag@39bae1e0932567a58c20dea5a1a0d18358503320 # v1 23 | with: 24 | version: ${{ github.event.inputs.version }} 25 | message: ${{ github.event.inputs.message }} 26 | token: ${{ secrets.GITHUB_TOKEN }} 27 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # If you prefer the allow list template instead of the deny list, see community template: 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore 3 | # 4 | # Binaries for programs and plugins 5 | *.exe 6 | *.exe~ 7 | *.dll 8 | *.so 9 | *.dylib 10 | 11 | # Test binary, built with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | 17 | # Dependency directories (remove the comment below to include it) 18 | # vendor/ 19 | 20 | # Go workspace file 21 | go.work 22 | 23 | # Binaries 24 | function-go-templating 25 | 26 | # IDE 27 | .idea 28 | .idea/** -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | 2 | # SPDX-FileCopyrightText: 2025 The Crossplane Authors 3 | # 4 | # SPDX-License-Identifier: CC0-1.0 5 | 6 | # This file controls automatic PR reviewer assignment. See the following docs: 7 | # 8 | # * https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners 9 | # * https://docs.github.com/en/organizations/organizing-members-into-teams/managing-code-review-settings-for-your-team 10 | # 11 | # The goal of this file is for most PRs to automatically and fairly have one 12 | # maintainer set as PR reviewers. All maintainers have permission to approve 13 | # and merge PRs. All PRs must be approved by at least one maintainer before being merged. 14 | # 15 | # Where possible, prefer explicitly specifying a maintainer who is a subject 16 | # matter expert for a particular part of the codebase rather than using fallback 17 | # owners. Fallback owners are listed at the bottom of this file. 18 | # 19 | # See also OWNERS.md for governance details 20 | 21 | # Fallback owners 22 | * @sergenyalcin @turkenf @negz 23 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # syntax=docker/dockerfile:1 2 | 3 | # We use the latest Go 1.x version unless asked to use something else. 4 | # The GitHub Actions CI job sets this argument for a consistent Go version. 5 | ARG GO_VERSION=1 6 | 7 | # Setup the base environment. The BUILDPLATFORM is set automatically by Docker. 8 | # The --platform=${BUILDPLATFORM} flag tells Docker to build the function using 9 | # the OS and architecture of the host running the build, not the OS and 10 | # architecture that we're building the function for. 11 | FROM --platform=${BUILDPLATFORM} golang:${GO_VERSION} AS build 12 | 13 | WORKDIR /fn 14 | 15 | # Most functions don't want or need CGo support, so we disable it. 16 | ENV CGO_ENABLED=0 17 | 18 | # We run go mod download in a separate step so that we can cache its results. 19 | # This lets us avoid re-downloading modules if we don't need to. The type=target 20 | # mount tells Docker to mount the current directory read-only in the WORKDIR. 21 | # The type=cache mount tells Docker to cache the Go modules cache across builds. 22 | RUN --mount=target=. --mount=type=cache,target=/go/pkg/mod go mod download 23 | 24 | # The TARGETOS and TARGETARCH args are set by docker. We set GOOS and GOARCH to 25 | # these values to ask Go to compile a binary for these architectures. If 26 | # TARGETOS and TARGETOS are different from BUILDPLATFORM, Go will cross compile 27 | # for us (e.g. compile a linux/amd64 binary on a linux/arm64 build machine). 28 | ARG TARGETOS 29 | ARG TARGETARCH 30 | 31 | # Build the function binary. The type=target mount tells Docker to mount the 32 | # current directory read-only in the WORKDIR. The type=cache mount tells Docker 33 | # to cache the Go modules cache across builds. 34 | RUN --mount=target=. \ 35 | --mount=type=cache,target=/go/pkg/mod \ 36 | --mount=type=cache,target=/root/.cache/go-build \ 37 | GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o /function . 38 | 39 | # Produce the Function image. We use a very lightweight 'distroless' image that 40 | # does not include any of the build tools used in previous stages. 41 | FROM gcr.io/distroless/static-debian12:nonroot AS image 42 | WORKDIR / 43 | COPY --from=build /function /function 44 | EXPOSE 9443 45 | USER nonroot:nonroot 46 | ENTRYPOINT ["/function"] 47 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /OWNERS.md: -------------------------------------------------------------------------------- 1 | 6 | 7 | # OWNERS 8 | 9 | This page lists all maintainers for **this** repository. Each repository in the 10 | [Crossplane Contrib organization](https://github.com/crossplane-contrib/) will list their 11 | repository maintainers in their own `OWNERS.md` file. 12 | 13 | ## Maintainers 14 | * Nic Cope ([negz](https://github.com/negz)) 15 | * Sergen Yalcin ([sergenyalcin](https://github.com/sergenyalcin)) 16 | * Fatih Turken ([turkenf](https://github.com/turkenf)) 17 | 18 | See [CODEOWNERS](./CODEOWNERS) for automatic PR assignment. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # function-go-templating 2 | 3 | [![CI](https://github.com/crossplane-contrib/function-go-templating/actions/workflows/ci.yml/badge.svg)](https://github.com/crossplane-contrib/function-go-templating/actions/workflows/ci.yml) ![GitHub release (latest SemVer)](https://img.shields.io/github/release/crossplane-contrib/function-go-templating) 4 | 5 | This [composition function][docs-functions] allows you to compose Crossplane 6 | resources using [Go templates][go-templates]. If you've written a [Helm 7 | chart][helm-chart] before, using this function will be a familiar experience. 8 | 9 | Here's an example: 10 | 11 | ```yaml 12 | apiVersion: apiextensions.crossplane.io/v1 13 | kind: Composition 14 | metadata: 15 | name: example 16 | spec: 17 | compositeTypeRef: 18 | apiVersion: example.crossplane.io/v1beta1 19 | kind: XR 20 | mode: Pipeline 21 | pipeline: 22 | - step: create-a-bucket 23 | functionRef: 24 | name: function-go-templating 25 | input: 26 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 27 | kind: GoTemplate 28 | source: Inline 29 | inline: 30 | template: | 31 | apiVersion: s3.aws.upbound.io/v1beta1 32 | kind: Bucket 33 | metadata: 34 | annotations: 35 | gotemplating.fn.crossplane.io/composition-resource-name: bucket 36 | spec: 37 | forProvider: 38 | region: {{ .observed.composite.resource.spec.region }} 39 | - step: automatically-detect-ready-composed-resources 40 | functionRef: 41 | name: function-auto-ready 42 | ``` 43 | 44 | ## Using this function 45 | 46 | This function can load templates from two sources: `Inline` and `FileSystem`. 47 | 48 | Use the `Inline` source to specify a simple template inline in your Composition. 49 | Multiple YAML manifests can be specified using the `---` document separator. 50 | 51 | Use the `FileSystem` source to specify a directory of templates. The 52 | `FileSystem` source treats all files under the specified directory as templates. 53 | 54 | The templates are passed a [`RunFunctionRequest`][bsr] as data. This means that 55 | you can access the composite resource, any composed resources, and the function 56 | pipeline context using notation like: 57 | 58 | - `{{ .observed.composite.resource.metadata.name }}` 59 | - `{{ .desired.composite.resource.status.widgets }}` 60 | - `{{ (index .desired.composed "resource-name").resource.spec.widgets }}` 61 | - `{{ index .context "apiextensions.crossplane.io/environment" }}` 62 | - `{{ index .extraResources "some-bucket-by-name" }}` 63 | 64 | This function supports all of Go's [built-in template functions][builtin]. The 65 | above examples use the `index` function to access keys like `resource-name` that 66 | contain periods, hyphens and other special characters. Like Helm, this function 67 | also supports [Sprig template functions][sprig] as well as [additional functions](#additional-functions). 68 | 69 | To return desired composite resource connection details, include a template that 70 | produces the special `CompositeConnectionDetails` resource: 71 | 72 | ```yaml 73 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 74 | kind: CompositeConnectionDetails 75 | data: 76 | connection-secret-key: connection-secret-value 77 | ``` 78 | 79 | > Note: The value of the connection secret value must be base64 encoded. This is already the case if you are referencing a key from a mananged resource's `connectionDetails` field. However, if you want to include a connection secret value from somewhere else, you will need to use the `b64enc` Sprig function: 80 | ```yaml 81 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 82 | kind: CompositeConnectionDetails 83 | data: 84 | server-endpoint: {{ (index $.observed.resources "my-server").resource.status.atProvider.endpoint | b64enc }} 85 | ``` 86 | 87 | To mark a desired composed resource as ready, use the 88 | `gotemplating.fn.crossplane.io/ready` annotation: 89 | 90 | ```yaml 91 | apiVersion: s3.aws.upbound.io/v1beta1 92 | kind: Bucket 93 | metadata: 94 | annotations: 95 | gotemplating.fn.crossplane.io/composition-resource-name: bucket 96 | gotemplating.fn.crossplane.io/ready: "True" 97 | spec: {} 98 | ``` 99 | 100 | See the [example](example) directory for examples that you can run locally using 101 | the Crossplane CLI: 102 | 103 | ```shell 104 | $ crossplane beta render xr.yaml composition.yaml functions.yaml 105 | ``` 106 | 107 | See the [composition functions documentation][docs-functions] to learn more 108 | about `crossplane beta render`. 109 | 110 | ### ExtraResources 111 | 112 | By defining one or more special `ExtraResources`, you can ask Crossplane to 113 | retrieve additional resources from the local cluster and make them available to 114 | your templates. See the [docs][extra-resources] for more information. 115 | 116 | ```yaml 117 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 118 | kind: ExtraResources 119 | requirements: 120 | some-foo-by-name: 121 | # Resources can be requested either by name 122 | apiVersion: example.com/v1beta1 123 | kind: Foo 124 | matchName: "some-extra-foo" 125 | some-foo-by-labels: 126 | # Or by label. 127 | apiVersion: example.com/v1beta1 128 | kind: Foo 129 | matchLabels: 130 | app: my-app 131 | some-bar-by-a-computed-label: 132 | # But you can also generate them dynamically using the template, for example: 133 | apiVersion: example.com/v1beta1 134 | kind: Bar 135 | matchLabels: 136 | foo: {{ .observed.composite.resource.name }} 137 | ``` 138 | 139 | This will result in Crossplane retrieving the requested resources and making 140 | them available to your templates under the `extraResources` key, with the 141 | following format: 142 | 143 | ```json5 144 | { 145 | "extraResources": { 146 | "some-foo-by-name": [ 147 | // ... the requested bucket if found, empty otherwise ... 148 | ], 149 | "some-foo-by-labels": [ 150 | // ... the requested buckets if found, empty otherwise ... 151 | ], 152 | // ... any other requested extra resources ... 153 | } 154 | } 155 | ``` 156 | 157 | So, you can access the retrieved resources in your templates like this, for 158 | example: 159 | 160 | ```yaml 161 | {{- $someExtraResources := index .extraResources "some-extra-resources-key" }} 162 | {{- range $i, $extraResource := $someExtraResources.items }} 163 | # 164 | # Do something for each retrieved extraResource 165 | # 166 | {{- end }} 167 | ``` 168 | 169 | ### Writing to the Context 170 | 171 | This function can write to the Composition [Context](https://docs.crossplane.io/latest/concepts/compositions/#function-pipeline-context). Subsequent pipeline steps will be able to access the data. 172 | 173 | ```yaml 174 | --- 175 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 176 | kind: Context 177 | data: 178 | region: {{ $spec.region }} 179 | id: field 180 | array: 181 | - "1" 182 | - "2" 183 | ``` 184 | 185 | To update Context data, match an existing key. For example, [function-environment-configs](https://github.com/crossplane-contrib/function-environment-configs) 186 | stores data under the key `apiextensions.crossplane.io/environment`. 187 | 188 | In this case, Environment fields `update` and `nestedEnvUpdate.hello` would be updated with new values. 189 | 190 | ```yaml 191 | --- 192 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 193 | kind: Context 194 | data: 195 | "apiextensions.crossplane.io/environment": 196 | kind: Environment 197 | apiVersion: internal.crossplane.io/v1alpha1 198 | update: environment 199 | nestedEnvUpdate: 200 | hello: world 201 | otherContextData: 202 | test: field 203 | ``` 204 | 205 | For more information, see the example in [context](example/context). 206 | 207 | ### Updating status or creating composed resources with the composite resource's type 208 | 209 | This function applies special logic if a resource with the composite resource's type is found in the template. 210 | 211 | If the resource name is not set (the `gotemplating.fn.crossplane.io/composition-resource-name` meta annotation is not present), then the function **does not create composed resources** with the composite resource's type. In this case only the composite resource's **status is updated**. 212 | 213 | For example, the following composition does not create composed resources. Rather, it updates the composite resource's status to include `dummy: cool-status`. 214 | 215 | ```yaml 216 | apiVersion: apiextensions.crossplane.io/v1 217 | kind: Composition 218 | metadata: 219 | name: example-update-status 220 | spec: 221 | compositeTypeRef: 222 | apiVersion: example.crossplane.io/v1beta1 223 | kind: XR 224 | mode: Pipeline 225 | pipeline: 226 | - step: render-templates 227 | functionRef: 228 | name: function-go-templating 229 | input: 230 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 231 | kind: GoTemplate 232 | source: Inline 233 | inline: 234 | template: | 235 | apiVersion: example.crossplane.io/v1beta1 236 | kind: XR 237 | status: 238 | dummy: cool-status 239 | ``` 240 | 241 | On the other hand, if the resource name is set (using the `gotemplating.fn.crossplane.io/composition-resource-name` meta annotation), then the function **creates composed resources** with the composite resource's type. 242 | 243 | For example, the following composition will create a composed resource: 244 | 245 | ```yaml 246 | apiVersion: apiextensions.crossplane.io/v1 247 | kind: Composition 248 | metadata: 249 | name: example-allow-recursion 250 | spec: 251 | compositeTypeRef: 252 | apiVersion: example.crossplane.io/v1beta1 253 | kind: XR 254 | mode: Pipeline 255 | pipeline: 256 | - step: render-templates 257 | functionRef: 258 | name: function-go-templating 259 | input: 260 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 261 | kind: GoTemplate 262 | source: Inline 263 | inline: 264 | template: | 265 | apiVersion: example.crossplane.io/v1beta1 266 | kind: XR 267 | metadata: 268 | annotations: 269 | {{ setResourceNameAnnotation "recursive-xr" }} 270 | spec: 271 | compositionRef: 272 | name: example-other # make sure to avoid infinite recursion 273 | ``` 274 | 275 | > [!WARNING] 276 | > This can lead to infinite recursion. Make sure to terminate the recursion by specifying a different `compositionRef` at some point. 277 | 278 | For more information, see the example in [recursive](example/recursive). 279 | 280 | ## Setting Conditions on the Claim and Composite 281 | 282 | Starting with Crossplane 1.17, Composition authors can set custom Conditions on the 283 | Composite and the Claim. 284 | 285 | Add a `ClaimConditions` to your template to set Conditions: 286 | 287 | ```yaml 288 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 289 | kind: ClaimConditions 290 | conditions: 291 | # Guide to ClaimConditions fields: 292 | # Type of the condition, e.g. DatabaseReady. 293 | # 'Healthy', 'Ready' and 'Synced' are reserved for use by Crossplane and this function will raise an error if used 294 | # - type: 295 | # Status of the condition. String of "True"/"False"/"Unknown" 296 | # status: 297 | # Machine-readable PascalCase reason, for example "ErrorProvisioning" 298 | # reason: 299 | # Optional Target. Publish Condition only to the Composite, or the Composite and the Claim (CompositeAndClaim). 300 | # Defaults to Composite 301 | # target: 302 | # Optional message: 303 | # message: 304 | - type: TestCondition 305 | status: "False" 306 | reason: InstallFail 307 | message: "failed to install" 308 | target: CompositeAndClaim 309 | - type: ConditionTrue 310 | status: "True" 311 | reason: TrueCondition 312 | message: we are true 313 | target: Composite 314 | - type: DatabaseReady 315 | status: "True" 316 | reason: Ready 317 | message: Database is ready 318 | target: CompositeAndClaim 319 | ``` 320 | 321 | ## Additional functions 322 | 323 | | Name | Description | 324 | |------------------------------------------------------------------|--------------------------------------------------------------| 325 | | [`randomChoice`](example/inline) | Randomly selects one of a given strings | 326 | | [`toYaml`](example/functions/toYaml) | Marshals any object into a YAML string | 327 | | [`fromYaml`](example/functions/fromYaml) | Unmarshals a YAML string into an object | 328 | | [`getResourceCondition`](example/functions/getResourceCondition) | Helper function to retreive conditions of resources | 329 | | [`getComposedResource`](example/functions/getComposedResource) | Helper function to retrieve observed composed resources | 330 | | [`getCompositeResource`](example/functions/getCompositeResource) | Helper function to retreive the observed composite resource | 331 | | [`setResourceNameAnnotation`](example/inline) | Returns the special resource-name annotation with given name | 332 | | [`include`](example/functions/include) | Outputs template as a string | 333 | 334 | ## Developing this function 335 | 336 | This function uses [Go][go], [Docker][docker], and the [Crossplane CLI][cli] to 337 | build functions. 338 | 339 | ```shell 340 | # Run code generation - see input/generate.go 341 | $ go generate ./... 342 | 343 | # Run tests - see fn_test.go 344 | $ go test ./... 345 | 346 | # Build the function's runtime image - see Dockerfile 347 | $ docker build . --tag=runtime 348 | 349 | # Build a function package - see package/crossplane.yaml 350 | $ crossplane xpkg build -f package --embed-runtime-image=runtime 351 | ``` 352 | 353 | [docs-functions]: https://docs.crossplane.io/v1.14/concepts/composition-functions/ 354 | [go-templates]: https://pkg.go.dev/text/template 355 | [helm-chart]: https://helm.sh/docs/chart_template_guide/getting_started/ 356 | [bsr]: https://buf.build/crossplane/crossplane/docs/main:apiextensions.fn.proto.v1beta1#apiextensions.fn.proto.v1beta1.RunFunctionRequest 357 | [builtin]: https://pkg.go.dev/text/template#hdr-Functions 358 | [sprig]: http://masterminds.github.io/sprig/ 359 | [go]: https://go.dev 360 | [docker]: https://www.docker.com 361 | [cli]: https://docs.crossplane.io/latest/cli 362 | [extra-resources]: https://docs.crossplane.io/latest/concepts/composition-functions/#how-composition-functions-work 363 | -------------------------------------------------------------------------------- /claimconditions.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 5 | "github.com/crossplane/function-sdk-go/errors" 6 | fnv1 "github.com/crossplane/function-sdk-go/proto/v1" 7 | "github.com/crossplane/function-sdk-go/response" 8 | corev1 "k8s.io/api/core/v1" 9 | ) 10 | 11 | // A CompositionTarget is the target of a composition event or condition. 12 | type CompositionTarget string 13 | 14 | // A TargetedCondition represents a condition produced by the composition 15 | // process. It can target either the XR only, or both the XR and the claim. 16 | type TargetedCondition struct { 17 | xpv1.Condition `json:",inline"` 18 | Target CompositionTarget `json:"target"` 19 | } 20 | 21 | // Composition event and condition targets. 22 | const ( 23 | CompositionTargetComposite CompositionTarget = "Composite" 24 | CompositionTargetCompositeAndClaim CompositionTarget = "CompositeAndClaim" 25 | ) 26 | 27 | // UpdateClaimConditions updates Conditions in the Claim and Composite 28 | func UpdateClaimConditions(rsp *fnv1.RunFunctionResponse, conditions ...TargetedCondition) error { 29 | if rsp == nil { 30 | return nil 31 | } 32 | for _, c := range conditions { 33 | if xpv1.IsSystemConditionType(xpv1.ConditionType(c.Type)) { 34 | response.Fatal(rsp, errors.Errorf("cannot set ClaimCondition type: %s is a reserved Crossplane Condition", c.Type)) 35 | return errors.New("error updating response") 36 | } 37 | co := transformCondition(c) 38 | UpdateResponseWithCondition(rsp, co) 39 | } 40 | return nil 41 | } 42 | 43 | // transformCondition converts a TargetedCondition to be compatible with the Protobuf SDK 44 | func transformCondition(tc TargetedCondition) *fnv1.Condition { 45 | c := &fnv1.Condition{ 46 | Type: string(tc.Condition.Type), 47 | Reason: string(tc.Condition.Reason), 48 | Target: transformTarget(tc.Target), 49 | } 50 | 51 | switch tc.Condition.Status { 52 | case corev1.ConditionTrue: 53 | c.Status = fnv1.Status_STATUS_CONDITION_TRUE 54 | case corev1.ConditionFalse: 55 | c.Status = fnv1.Status_STATUS_CONDITION_FALSE 56 | case corev1.ConditionUnknown: 57 | fallthrough 58 | default: 59 | c.Status = fnv1.Status_STATUS_CONDITION_UNKNOWN 60 | } 61 | 62 | if tc.Message != "" { 63 | c.Message = &tc.Message 64 | } 65 | return c 66 | } 67 | 68 | // transformTarget converts the input into a target Go SDK Enum 69 | // Default to TARGET_COMPOSITE 70 | func transformTarget(ct CompositionTarget) *fnv1.Target { 71 | if ct == CompositionTargetCompositeAndClaim { 72 | return fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum().Enum() 73 | } 74 | return fnv1.Target_TARGET_COMPOSITE.Enum() 75 | } 76 | 77 | // UpdateResponseWithCondition updates the RunFunctionResponse with a Condition 78 | func UpdateResponseWithCondition(rsp *fnv1.RunFunctionResponse, c *fnv1.Condition) { 79 | if rsp == nil { 80 | return 81 | } 82 | if rsp.GetConditions() == nil { 83 | rsp.Conditions = make([]*fnv1.Condition, 0, 1) 84 | } 85 | if c != nil { 86 | rsp.Conditions = append(rsp.GetConditions(), c) 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /claimconditions_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 8 | "github.com/crossplane/crossplane-runtime/pkg/test" 9 | "github.com/crossplane/function-sdk-go/errors" 10 | fnv1 "github.com/crossplane/function-sdk-go/proto/v1" 11 | "github.com/google/go-cmp/cmp" 12 | "github.com/google/go-cmp/cmp/cmpopts" 13 | v1 "k8s.io/api/core/v1" 14 | "k8s.io/utils/ptr" 15 | ) 16 | 17 | func Test_UpdateClaimConditions(t *testing.T) { 18 | type args struct { 19 | rsp *fnv1.RunFunctionResponse 20 | c []TargetedCondition 21 | } 22 | type want struct { 23 | rsp *fnv1.RunFunctionResponse 24 | err error 25 | } 26 | cases := map[string]struct { 27 | reason string 28 | args args 29 | want want 30 | }{ 31 | "EmptyResponseNoConditions": { 32 | reason: "When No Response or Conditions are provided, return a nil response", 33 | args: args{}, 34 | want: want{}, 35 | }, 36 | "ErrorOnReadyReservedType": { 37 | reason: "Return an error if a Reserved Condition Type is being used", 38 | args: args{ 39 | rsp: &fnv1.RunFunctionResponse{}, 40 | c: []TargetedCondition{ 41 | { 42 | Condition: xpv1.Condition{ 43 | Message: "Ready Message", 44 | Status: v1.ConditionTrue, 45 | Type: "Ready", 46 | }, 47 | Target: CompositionTargetComposite, 48 | }, 49 | }, 50 | }, 51 | want: want{ 52 | rsp: &fnv1.RunFunctionResponse{ 53 | Results: []*fnv1.Result{ 54 | { 55 | Severity: fnv1.Severity_SEVERITY_FATAL, 56 | Message: "cannot set ClaimCondition type: Ready is a reserved Crossplane Condition", 57 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 58 | }, 59 | }, 60 | }, 61 | err: errors.New("error updating response"), 62 | }, 63 | }, 64 | "SuccessfullyAddConditions": { 65 | reason: "Add Conditions Successfully", 66 | args: args{ 67 | rsp: &fnv1.RunFunctionResponse{}, 68 | c: []TargetedCondition{ 69 | { 70 | Condition: xpv1.Condition{ 71 | Message: "Creating Resource", 72 | Status: v1.ConditionFalse, 73 | Type: "NetworkReady", 74 | }, 75 | Target: CompositionTargetCompositeAndClaim, 76 | }, 77 | { 78 | Condition: xpv1.Condition{ 79 | Message: "Ready Message", 80 | Status: v1.ConditionTrue, 81 | Type: "DatabaseReady", 82 | }, 83 | Target: CompositionTargetComposite, 84 | }, 85 | { 86 | Condition: xpv1.Condition{ 87 | Message: "No Target should add CompositeAndClaim", 88 | Status: v1.ConditionTrue, 89 | Type: "NoTarget", 90 | }, 91 | }, 92 | }, 93 | }, 94 | want: want{ 95 | rsp: &fnv1.RunFunctionResponse{ 96 | Conditions: []*fnv1.Condition{ 97 | { 98 | Message: ptr.To("Creating Resource"), 99 | Status: fnv1.Status_STATUS_CONDITION_FALSE, 100 | Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 101 | Type: "NetworkReady", 102 | }, 103 | { 104 | Message: ptr.To("Ready Message"), 105 | Status: fnv1.Status_STATUS_CONDITION_TRUE, 106 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 107 | Type: "DatabaseReady", 108 | }, 109 | { 110 | Message: ptr.To("No Target should add CompositeAndClaim"), 111 | Status: fnv1.Status_STATUS_CONDITION_TRUE, 112 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 113 | Type: "NoTarget", 114 | }, 115 | }, 116 | }, 117 | }, 118 | }, 119 | } 120 | for name, tc := range cases { 121 | t.Run(name, func(t *testing.T) { 122 | err := UpdateClaimConditions(tc.args.rsp, tc.args.c...) 123 | if diff := cmp.Diff(tc.args.rsp, tc.want.rsp, cmpopts.IgnoreUnexported(fnv1.RunFunctionResponse{}, fnv1.Result{}, fnv1.Condition{})); diff != "" { 124 | t.Errorf("%s\nUpdateClaimConditions(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 125 | } 126 | if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 127 | t.Errorf("%s\nUpdateClaimConditions(...): -want err, +got err:\n%s", tc.reason, diff) 128 | } 129 | 130 | }) 131 | } 132 | } 133 | 134 | func Test_transformCondition(t *testing.T) { 135 | type args struct { 136 | tc TargetedCondition 137 | } 138 | cases := map[string]struct { 139 | reason string 140 | args args 141 | want *fnv1.Condition 142 | }{ 143 | "Basic": { 144 | reason: "Basic Target", 145 | args: args{ 146 | tc: TargetedCondition{ 147 | Condition: xpv1.Condition{ 148 | Message: "Basic Message", 149 | Status: v1.ConditionTrue, 150 | Type: "TestType", 151 | }, 152 | Target: CompositionTargetComposite, 153 | }, 154 | }, 155 | want: &fnv1.Condition{ 156 | Message: ptr.To("Basic Message"), 157 | Status: fnv1.Status_STATUS_CONDITION_TRUE, 158 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 159 | Type: "TestType", 160 | }, 161 | }, 162 | "Defaults": { 163 | reason: "Default Settings", 164 | args: args{ 165 | tc: TargetedCondition{ 166 | Condition: xpv1.Condition{}, 167 | }, 168 | }, 169 | want: &fnv1.Condition{ 170 | Status: fnv1.Status_STATUS_CONDITION_UNKNOWN, 171 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 172 | }, 173 | }, 174 | "StatusFalseNoTarget": { 175 | reason: "When Status is false and no target set", 176 | args: args{ 177 | tc: TargetedCondition{ 178 | Condition: xpv1.Condition{ 179 | Message: "Basic Message", 180 | Status: v1.ConditionFalse, 181 | Type: "TestType", 182 | }, 183 | }, 184 | }, 185 | want: &fnv1.Condition{ 186 | Message: ptr.To("Basic Message"), 187 | Status: fnv1.Status_STATUS_CONDITION_FALSE, 188 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 189 | Type: "TestType", 190 | }, 191 | }, 192 | } 193 | for name, tc := range cases { 194 | t.Run(name, func(t *testing.T) { 195 | if got := transformCondition(tc.args.tc); !reflect.DeepEqual(got, tc.want) { 196 | t.Errorf("transformCondition() = %v, want %v", got, tc.want) 197 | } 198 | }) 199 | } 200 | } 201 | 202 | func Test_transformTarget(t *testing.T) { 203 | type args struct { 204 | ct CompositionTarget 205 | } 206 | cases := map[string]struct { 207 | reason string 208 | args args 209 | want *fnv1.Target 210 | }{ 211 | "DefaultToComposite": { 212 | reason: "unknown target will default to Composite", 213 | args: args{ 214 | ct: "COMPOSE", 215 | }, 216 | want: fnv1.Target_TARGET_COMPOSITE.Enum(), 217 | }, 218 | "Composite": { 219 | reason: "Composite target correctly set", 220 | args: args{ 221 | ct: "Composite", 222 | }, 223 | want: fnv1.Target_TARGET_COMPOSITE.Enum(), 224 | }, 225 | "CompositeAndClaim": { 226 | reason: "CompositeAndClaim target correctly set", 227 | args: args{ 228 | ct: "CompositeAndClaim", 229 | }, 230 | want: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 231 | }, 232 | } 233 | for name, tc := range cases { 234 | t.Run(name, func(t *testing.T) { 235 | if got := transformTarget(tc.args.ct); !reflect.DeepEqual(got, tc.want) { 236 | t.Errorf("transformTarget() = %v, want %v", got, tc.want) 237 | } 238 | }) 239 | } 240 | } 241 | 242 | func Test_UpdateResponseWithCondition(t *testing.T) { 243 | type args struct { 244 | rsp *fnv1.RunFunctionResponse 245 | c *fnv1.Condition 246 | } 247 | cases := map[string]struct { 248 | reason string 249 | args args 250 | want *fnv1.RunFunctionResponse 251 | }{ 252 | "EmptyResponseNoConditions": { 253 | reason: "When No Response or Conditions are provided, return a nil response", 254 | args: args{}, 255 | }, 256 | "ResponseWithNoConditions": { 257 | reason: "A response with no conditions should initialize an array before adding the condition", 258 | args: args{ 259 | rsp: &fnv1.RunFunctionResponse{}, 260 | }, 261 | want: &fnv1.RunFunctionResponse{ 262 | Conditions: []*fnv1.Condition{}, 263 | }, 264 | }, 265 | "ResponseAddCondition": { 266 | reason: "A response with no conditions should initialize an array before adding the condition", 267 | args: args{ 268 | rsp: &fnv1.RunFunctionResponse{}, 269 | c: &fnv1.Condition{ 270 | Message: ptr.To("Basic Message"), 271 | Status: fnv1.Status_STATUS_CONDITION_FALSE, 272 | Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 273 | Type: "TestType", 274 | }, 275 | }, 276 | want: &fnv1.RunFunctionResponse{ 277 | Conditions: []*fnv1.Condition{ 278 | { 279 | Message: ptr.To("Basic Message"), 280 | Status: fnv1.Status_STATUS_CONDITION_FALSE, 281 | Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 282 | Type: "TestType", 283 | }, 284 | }, 285 | }, 286 | }, 287 | "ResponseAppCondition": { 288 | reason: "A response with existing conditions should append the condition", 289 | args: args{ 290 | rsp: &fnv1.RunFunctionResponse{ 291 | Conditions: []*fnv1.Condition{ 292 | { 293 | Message: ptr.To("Existing Message"), 294 | Status: fnv1.Status_STATUS_CONDITION_TRUE, 295 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 296 | Type: "ExistingTestType", 297 | }, 298 | }, 299 | }, 300 | c: &fnv1.Condition{ 301 | Message: ptr.To("Basic Message"), 302 | Status: fnv1.Status_STATUS_CONDITION_FALSE, 303 | Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 304 | Type: "TestType", 305 | }, 306 | }, 307 | want: &fnv1.RunFunctionResponse{ 308 | Conditions: []*fnv1.Condition{ 309 | { 310 | Message: ptr.To("Existing Message"), 311 | Status: fnv1.Status_STATUS_CONDITION_TRUE, 312 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 313 | Type: "ExistingTestType", 314 | }, 315 | { 316 | Message: ptr.To("Basic Message"), 317 | Status: fnv1.Status_STATUS_CONDITION_FALSE, 318 | Target: fnv1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 319 | Type: "TestType", 320 | }, 321 | }, 322 | }, 323 | }, 324 | } 325 | for name, tc := range cases { 326 | t.Run(name, func(t *testing.T) { 327 | UpdateResponseWithCondition(tc.args.rsp, tc.args.c) 328 | if diff := cmp.Diff(tc.args.rsp, tc.want, cmpopts.IgnoreUnexported(fnv1.RunFunctionResponse{}, fnv1.Condition{})); diff != "" { 329 | t.Errorf("%s\nUpdateResponseWithCondition(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 330 | } 331 | }) 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /context.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "dario.cat/mergo" 5 | "github.com/crossplane/function-sdk-go/errors" 6 | fnv1 "github.com/crossplane/function-sdk-go/proto/v1" 7 | ) 8 | 9 | // MergeContext merges existing Context with new values provided 10 | func (f *Function) MergeContext(req *fnv1.RunFunctionRequest, val map[string]interface{}) (map[string]interface{}, error) { 11 | mergedContext := req.GetContext().AsMap() 12 | if len(val) == 0 { 13 | return mergedContext, nil 14 | } 15 | if err := mergo.Merge(&mergedContext, val, mergo.WithOverride); err != nil { 16 | return mergedContext, errors.Wrapf(err, "cannot merge data %T", req) 17 | } 18 | return mergedContext, nil 19 | } 20 | -------------------------------------------------------------------------------- /context_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/crossplane/crossplane-runtime/pkg/logging" 7 | fnv1 "github.com/crossplane/function-sdk-go/proto/v1" 8 | "github.com/crossplane/function-sdk-go/resource" 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "google.golang.org/protobuf/testing/protocmp" 12 | ) 13 | 14 | func TestMergeContext(t *testing.T) { 15 | type args struct { 16 | val map[string]interface{} 17 | req *fnv1.RunFunctionRequest 18 | } 19 | type want struct { 20 | us map[string]any 21 | err error 22 | } 23 | 24 | cases := map[string]struct { 25 | reason string 26 | args args 27 | want want 28 | }{ 29 | "NoContextAtKey": { 30 | reason: "When there is no existing context data at the key to merge, return the value", 31 | args: args{ 32 | req: &fnv1.RunFunctionRequest{ 33 | Context: nil, 34 | }, 35 | val: map[string]interface{}{"hello": "world"}, 36 | }, 37 | want: want{ 38 | us: map[string]interface{}{"hello": "world"}, 39 | err: nil, 40 | }, 41 | }, 42 | "SuccessfulMerge": { 43 | reason: "Confirm that keys are merged with source overwriting destination", 44 | args: args{ 45 | req: &fnv1.RunFunctionRequest{ 46 | Context: resource.MustStructJSON(`{"apiextensions.crossplane.io/environment":{"complex":{"a":"b","c":{"d":"e","f":"1","overWrite": "fromContext"}}}}`), 47 | }, 48 | val: map[string]interface{}{ 49 | "newKey": "newValue", 50 | "apiextensions.crossplane.io/environment": map[string]any{ 51 | "complex": map[string]any{ 52 | "c": map[string]any{ 53 | "overWrite": "fromFunction", 54 | }, 55 | }, 56 | }, 57 | }, 58 | }, 59 | want: want{ 60 | us: map[string]interface{}{ 61 | "apiextensions.crossplane.io/environment": map[string]any{ 62 | "complex": map[string]any{ 63 | "a": "b", 64 | "c": map[string]any{ 65 | "d": "e", 66 | "f": "1", 67 | "overWrite": "fromFunction", 68 | }, 69 | }, 70 | }, 71 | "newKey": "newValue"}, 72 | err: nil, 73 | }, 74 | }, 75 | } 76 | for name, tc := range cases { 77 | t.Run(name, func(t *testing.T) { 78 | f := &Function{ 79 | log: logging.NewNopLogger(), 80 | } 81 | rsp, err := f.MergeContext(tc.args.req, tc.args.val) 82 | 83 | if diff := cmp.Diff(tc.want.us, rsp, protocmp.Transform()); diff != "" { 84 | t.Errorf("%s\nf.MergeContext(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 85 | } 86 | 87 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 88 | t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) 89 | } 90 | }) 91 | } 92 | 93 | } 94 | -------------------------------------------------------------------------------- /example/.dev/functions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Use this function yaml when developing and executing the function directly instead of in a container 3 | # E.g. `go run . --insecure --debug --address=":9443"` 4 | 5 | apiVersion: pkg.crossplane.io/v1beta1 6 | kind: Function 7 | metadata: 8 | name: function-go-templating 9 | annotations: 10 | render.crossplane.io/runtime: Development 11 | render.crossplane.io/runtime-development-target: localhost:9443 12 | spec: 13 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 14 | -------------------------------------------------------------------------------- /example/conditions/README.md: -------------------------------------------------------------------------------- 1 | # Writing to the Composite or Claim Status 2 | 3 | function-go-templating can write to the Composite or Claim Status. See [Communication Between Composition Functions and the Claim](https://github.com/crossplane/crossplane/blob/main/design/one-pager-fn-claim-conditions.md) for more information. 4 | 5 | ## Testing This Function Locally 6 | 7 | You can run your function locally and test it using [`crossplane render`](https://docs.crossplane.io/latest/cli/command-reference/#render) 8 | with these example manifests. 9 | 10 | ```shell 11 | crossplane render \ 12 | xr.yaml composition.yaml functions.yaml 13 | ``` 14 | 15 | ## Debugging This Function 16 | 17 | First we need to run the command in debug mode. In a terminal Window Run: 18 | 19 | ```shell 20 | # Run the function locally 21 | $ go run . --insecure --debug 22 | ``` 23 | 24 | Next, set the go-templating function `render.crossplane.io/runtime: Development` annotation so that 25 | `crossplane render` communicates with the local process instead of downloading an image: 26 | 27 | ```yaml 28 | apiVersion: pkg.crossplane.io/v1beta1 29 | kind: Function 30 | metadata: 31 | name: crossplane-contrib-function-go-templating 32 | annotations: 33 | render.crossplane.io/runtime: Development 34 | spec: 35 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 36 | ``` 37 | 38 | While the function is running in one terminal, open another terminal window and run `crossplane render`. 39 | The function should output debug-level logs in the terminal. 40 | -------------------------------------------------------------------------------- /example/conditions/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: go-template-context.example.crossplane.io 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: go-templating-update-conditions 12 | functionRef: 13 | name: crossplane-contrib-function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | --- 21 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 22 | kind: ClaimConditions 23 | conditions: 24 | # Guide to ClaimConditions fields: 25 | # Type of the condition, e.g. DatabaseReady. 26 | # 'Healthy', 'Ready' and 'Synced' are reserved for use by Crossplane and this function will raise an error if used 27 | # - type: 28 | # Status of the condition. String of "True"/"False"/"Unknown" 29 | # status: 30 | # Machine-readable PascalCase reason, for example "ErrorProvisioning" 31 | # reason: 32 | # Optional Target. Publish Condition only to the Composite, or the Composite and the Claim (CompositeAndClaim). 33 | # Defaults to Composite 34 | # target: 35 | # Optional message: 36 | # message: 37 | - type: TestCondition 38 | status: "False" 39 | reason: InstallFail 40 | message: "failed to install" 41 | target: CompositeAndClaim 42 | - type: ConditionTrue 43 | status: "True" 44 | reason: TrueCondition 45 | message: we are true 46 | target: Composite 47 | - type: DatabaseReady 48 | status: "True" 49 | reason: Ready 50 | message: Database is ready 51 | target: CompositeAndClaim 52 | - step: automatically-detect-ready-composed-resources 53 | functionRef: 54 | name: crossplane-contrib-function-auto-ready 55 | -------------------------------------------------------------------------------- /example/conditions/functions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: pkg.crossplane.io/v1beta1 3 | kind: Function 4 | metadata: 5 | name: crossplane-contrib-function-environment-configs 6 | spec: 7 | # This is ignored when using the Development runtime. 8 | package: xpkg.upbound.io/crossplane-contrib/function-environment-configs:v0.2.0 9 | --- 10 | apiVersion: pkg.crossplane.io/v1beta1 11 | kind: Function 12 | metadata: 13 | name: crossplane-contrib-function-go-templating 14 | annotations: 15 | # This tells crossplane beta render to connect to the function locally. 16 | render.crossplane.io/runtime: Development 17 | spec: 18 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 19 | --- 20 | apiVersion: pkg.crossplane.io/v1beta1 21 | kind: Function 22 | metadata: 23 | name: crossplane-contrib-function-auto-ready 24 | spec: 25 | package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.4.0 26 | -------------------------------------------------------------------------------- /example/conditions/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1 2 | kind: XR 3 | metadata: 4 | name: example-xr 5 | spec: {} -------------------------------------------------------------------------------- /example/conditions/xrd.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.crossplane.io/v1 3 | kind: CompositeResourceDefinition 4 | metadata: 5 | name: xrs.example.crossplane.io 6 | spec: 7 | group: example.crossplane.io 8 | names: 9 | kind: XR 10 | plural: xrs 11 | connectionSecretKeys: 12 | - test 13 | versions: 14 | - name: v1 15 | served: true 16 | referenceable: true 17 | schema: 18 | openAPIV3Schema: 19 | type: object 20 | properties: 21 | status: 22 | type: object 23 | properties: 24 | fromEnv: 25 | type: string -------------------------------------------------------------------------------- /example/context/README.md: -------------------------------------------------------------------------------- 1 | # Writing to the Function Context 2 | 3 | function-go-templating can write to the Function Context 4 | 5 | ## Testing This Function Locally 6 | 7 | You can run your function locally and test it using [`crossplane render`](https://docs.crossplane.io/latest/cli/command-reference/#render) 8 | with these example manifests. 9 | 10 | ```shell 11 | crossplane render \ 12 | --extra-resources environmentConfigs.yaml \ 13 | --include-context \ 14 | xr.yaml composition.yaml functions.yaml 15 | ``` 16 | 17 | Will produce an output like: 18 | 19 | ```shell 20 | --- 21 | apiVersion: example.crossplane.io/v1 22 | kind: XR 23 | metadata: 24 | name: example-xr 25 | status: 26 | conditions: 27 | - lastTransitionTime: "2024-01-01T00:00:00Z" 28 | reason: Available 29 | status: "True" 30 | type: Ready 31 | fromEnv: e 32 | --- 33 | apiVersion: render.crossplane.io/v1beta1 34 | fields: 35 | apiextensions.crossplane.io/environment: 36 | apiVersion: internal.crossplane.io/v1alpha1 37 | array: 38 | - "1" 39 | - "2" 40 | complex: 41 | a: b 42 | c: 43 | d: e 44 | f: "1" 45 | kind: Environment 46 | nestedEnvUpdate: 47 | hello: world 48 | update: environment 49 | newkey: 50 | hello: world 51 | other-context-key: 52 | complex: 53 | a: b 54 | c: 55 | d: e 56 | f: "1" 57 | kind: Context 58 | ``` 59 | 60 | ## Debugging This Function 61 | 62 | First we need to run the command in debug mode. In a terminal Window Run: 63 | 64 | ```shell 65 | # Run the function locally 66 | $ go run . --insecure --debug 67 | ``` 68 | 69 | Next, set the go-templating function `render.crossplane.io/runtime: Development` annotation so that 70 | `crossplane render` communicates with the local process instead of downloading an image: 71 | 72 | ```yaml 73 | apiVersion: pkg.crossplane.io/v1beta1 74 | kind: Function 75 | metadata: 76 | name: crossplane-contrib-function-go-templating 77 | annotations: 78 | render.crossplane.io/runtime: Development 79 | spec: 80 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.6.0 81 | ``` 82 | 83 | While the function is running in one terminal, open another terminal window and run `crossplane render`. 84 | The function should output debug-level logs in the terminal. 85 | -------------------------------------------------------------------------------- /example/context/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: go-template-context.example.crossplane.io 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: environmentConfigs 12 | functionRef: 13 | name: crossplane-contrib-function-environment-configs 14 | input: 15 | apiVersion: environmentconfigs.fn.crossplane.io/v1beta1 16 | kind: Input 17 | spec: 18 | environmentConfigs: 19 | - type: Reference 20 | ref: 21 | name: example-config 22 | - step: go-templating-update-context 23 | functionRef: 24 | name: crossplane-contrib-function-go-templating 25 | input: 26 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 27 | kind: GoTemplate 28 | source: Inline 29 | inline: 30 | template: | 31 | --- 32 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 33 | kind: Context 34 | data: 35 | # update existing EnvironmentConfig by using the "apiextensions.crossplane.io/environment" key 36 | "apiextensions.crossplane.io/environment": 37 | kind: Environment 38 | apiVersion: internal.crossplane.io/v1alpha1 39 | update: environment 40 | nestedEnvUpdate: 41 | hello: world 42 | array: 43 | - "1" 44 | - "2" 45 | # read existing context and move it to another key 46 | "other-context-key": 47 | complex: {{ index .context "apiextensions.crossplane.io/environment" "complex" | toYaml | nindent 6 }} 48 | # Create a new Context key and populate it with data 49 | newkey: 50 | hello: world 51 | --- 52 | apiVersion: example.crossplane.io/v1 53 | kind: XR 54 | status: 55 | fromEnv: {{ index .context "apiextensions.crossplane.io/environment" "complex" "c" "d" }} 56 | - step: automatically-detect-ready-composed-resources 57 | functionRef: 58 | name: crossplane-contrib-function-auto-ready 59 | -------------------------------------------------------------------------------- /example/context/environmentConfigs.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1alpha1 2 | kind: EnvironmentConfig 3 | metadata: 4 | name: example-config 5 | data: 6 | complex: 7 | a: b 8 | c: 9 | d: e 10 | f: "1" -------------------------------------------------------------------------------- /example/context/functions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: pkg.crossplane.io/v1beta1 3 | kind: Function 4 | metadata: 5 | name: crossplane-contrib-function-environment-configs 6 | spec: 7 | # This is ignored when using the Development runtime. 8 | package: xpkg.upbound.io/crossplane-contrib/function-environment-configs:v0.2.0 9 | --- 10 | apiVersion: pkg.crossplane.io/v1beta1 11 | kind: Function 12 | metadata: 13 | name: crossplane-contrib-function-go-templating 14 | annotations: 15 | # This tells crossplane beta render to connect to the function locally. 16 | render.crossplane.io/runtime: Development 17 | spec: 18 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 19 | --- 20 | apiVersion: pkg.crossplane.io/v1beta1 21 | kind: Function 22 | metadata: 23 | name: crossplane-contrib-function-auto-ready 24 | spec: 25 | package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.4.0 26 | -------------------------------------------------------------------------------- /example/context/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1 2 | kind: XR 3 | metadata: 4 | name: example-xr 5 | spec: {} -------------------------------------------------------------------------------- /example/context/xrd.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.crossplane.io/v1 3 | kind: CompositeResourceDefinition 4 | metadata: 5 | name: xrs.example.crossplane.io 6 | spec: 7 | group: example.crossplane.io 8 | names: 9 | kind: XR 10 | plural: xrs 11 | connectionSecretKeys: 12 | - test 13 | versions: 14 | - name: v1 15 | served: true 16 | referenceable: true 17 | schema: 18 | openAPIV3Schema: 19 | type: object 20 | properties: 21 | status: 22 | type: object 23 | properties: 24 | fromEnv: 25 | type: string -------------------------------------------------------------------------------- /example/custom-delims/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-inline 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | delims: 18 | left: '[[' 19 | right: ']]' 20 | source: Inline 21 | inline: 22 | template: | 23 | [[- range $i := until ( .observed.composite.resource.spec.count | int ) ]] 24 | --- 25 | apiVersion: iam.aws.upbound.io/v1beta1 26 | kind: User 27 | metadata: 28 | annotations: 29 | gotemplating.fn.crossplane.io/composition-resource-name: test-user-[[ $i ]] 30 | labels: 31 | testing.upbound.io/example-name: test-user-[[ $i ]] 32 | [[ if eq $.observed.resources nil ]] 33 | dummy: [[ randomChoice "foo" "bar" "baz" ]] 34 | [[ else ]] 35 | dummy: [[ ( index $.observed.resources ( print "test-user-" $i ) ).resource.metadata.labels.dummy ]] 36 | [[ end ]] 37 | spec: 38 | forProvider: {} 39 | --- 40 | apiVersion: iam.aws.upbound.io/v1beta1 41 | kind: AccessKey 42 | metadata: 43 | annotations: 44 | gotemplating.fn.crossplane.io/composition-resource-name: sample-access-key-[[ $i ]] 45 | spec: 46 | forProvider: 47 | userSelector: 48 | matchLabels: 49 | testing.upbound.io/example-name: test-user-[[ $i ]] 50 | writeConnectionSecretToRef: 51 | name: sample-access-key-secret-[[ $i ]] 52 | namespace: crossplane-system 53 | [[- end ]] 54 | --- 55 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 56 | kind: CompositeConnectionDetails 57 | [[ if eq $.observed.resources nil ]] 58 | data: {} 59 | [[ else ]] 60 | data: 61 | username: [[ ( index $.observed.resources "sample-access-key-0" ).connectionDetails.username ]] 62 | password: [[ ( index $.observed.resources "sample-access-key-0" ).connectionDetails.password ]] 63 | url: [[ "http://www.example.com" | b64enc ]] 64 | [[ end ]] 65 | --- 66 | apiVersion: example.crossplane.io/v1beta1 67 | kind: XR 68 | status: 69 | dummy: cool-status 70 | -------------------------------------------------------------------------------- /example/custom-delims/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.0.0-20231101231317-cdb49945da4e -------------------------------------------------------------------------------- /example/custom-delims/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | count: 2 7 | -------------------------------------------------------------------------------- /example/extra-resources/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-extra-resources 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | --- 21 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 22 | kind: ExtraResources 23 | requirements: 24 | bucket: 25 | apiVersion: s3.aws.upbound.io/v1beta1 26 | kind: Bucket 27 | matchName: my-awesome-{{ .observed.composite.resource.spec.environment }}-bucket 28 | {{- with .extraResources }} 29 | {{ $someExtraResources := index . "bucket" }} 30 | {{- range $i, $extraResource := $someExtraResources.items }} 31 | --- 32 | apiVersion: kubernetes.crossplane.io/v1alpha1 33 | kind: Object 34 | metadata: 35 | annotations: 36 | gotemplating.fn.crossplane.io/composition-resource-name: bucket-configmap-{{ $i }} 37 | spec: 38 | forProvider: 39 | manifest: 40 | apiVersion: v1 41 | kind: Configmap 42 | metadata: 43 | name: {{ $extraResource.resource.metadata.name }}-bucket 44 | data: 45 | bucket: {{ $extraResource.resource.status.atProvider.id }} 46 | providerConfigRef: 47 | name: "kubernetes" 48 | {{- end }} 49 | {{- end }} 50 | --- 51 | apiVersion: example.crossplane.io/v1beta1 52 | kind: XR 53 | status: 54 | dummy: cool-status 55 | -------------------------------------------------------------------------------- /example/extra-resources/extraResources.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: s3.aws.upbound.io/v1beta1 3 | kind: Bucket 4 | metadata: 5 | labels: 6 | testing.upbound.io/example-name: bucket-notification 7 | name: my-awesome-dev-bucket 8 | spec: 9 | forProvider: 10 | region: us-west-1 11 | status: 12 | atProvider: 13 | id: random-bucket-id -------------------------------------------------------------------------------- /example/extra-resources/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/extra-resources/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | environment: dev 7 | -------------------------------------------------------------------------------- /example/filesystem/README.md: -------------------------------------------------------------------------------- 1 | # The `FileSystem` source 2 | 3 | You can't run the example in this directory using `crossplane beta render` 4 | because it loads templates from a ConfigMap. 5 | 6 | You can create a ConfigMap with the templates using the following command: 7 | 8 | ```shell 9 | kubectl create configmap templates --from-file=templates.tmpl -n crossplane-system 10 | ``` 11 | 12 | This ConfigMap will be mounted to the function pod and the templates will be 13 | available in the `/templates` directory. Please see `functions.yaml` for details. 14 | -------------------------------------------------------------------------------- /example/filesystem/claim.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: aws.platformref.upbound.io/v1alpha1 2 | kind: MyUser 3 | metadata: 4 | name: test-user 5 | namespace: crossplane-system 6 | spec: 7 | id: test-user 8 | count: 2 9 | -------------------------------------------------------------------------------- /example/filesystem/composition-fs.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: xusers.aws.platformref.upbound.io 5 | spec: 6 | writeConnectionSecretsToNamespace: crossplane-system 7 | compositeTypeRef: 8 | apiVersion: aws.platformref.upbound.io/v1alpha1 9 | kind: XUser 10 | mode: Pipeline 11 | pipeline: 12 | - step: render-templates 13 | functionRef: 14 | name: function-go-templating 15 | input: 16 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 17 | kind: GoTemplate 18 | source: FileSystem 19 | fileSystem: 20 | dirPath: /templates 21 | - step: ready 22 | functionRef: 23 | name: function-auto-ready 24 | -------------------------------------------------------------------------------- /example/filesystem/functions.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: pkg.crossplane.io/v1beta1 3 | kind: Function 4 | metadata: 5 | name: function-auto-ready 6 | spec: 7 | package: xpkg.upbound.io/crossplane-contrib/function-auto-ready:v0.4.0 8 | --- 9 | apiVersion: pkg.crossplane.io/v1beta1 10 | kind: Function 11 | metadata: 12 | name: function-go-templating 13 | spec: 14 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 15 | runtimeConfigRef: 16 | name: mount-templates 17 | --- 18 | apiVersion: pkg.crossplane.io/v1beta1 19 | kind: DeploymentRuntimeConfig 20 | metadata: 21 | name: mount-templates 22 | spec: 23 | deploymentTemplate: 24 | spec: 25 | selector: {} 26 | template: 27 | spec: 28 | containers: 29 | - name: package-runtime 30 | volumeMounts: 31 | - mountPath: /templates 32 | name: templates 33 | readOnly: true 34 | volumes: 35 | - name: templates 36 | configMap: 37 | name: templates 38 | -------------------------------------------------------------------------------- /example/filesystem/templates.tmpl: -------------------------------------------------------------------------------- 1 | {{- range $i := until ( .observed.composite.resource.spec.count | int ) }} 2 | --- 3 | apiVersion: iam.aws.upbound.io/v1beta1 4 | kind: User 5 | metadata: 6 | annotations: 7 | gotemplating.fn.crossplane.io/composition-resource-name: test-user-{{ $i }} 8 | labels: 9 | testing.upbound.io/example-name: test-user-{{ $i }} 10 | dummy: {{ dig "resources" (print "test-user-" $i) "resource" "metadata" "labels" "dummy" ( randomChoice "foo" "bar" "baz" ) $.observed }} 11 | spec: 12 | forProvider: {} 13 | --- 14 | apiVersion: iam.aws.upbound.io/v1beta1 15 | kind: AccessKey 16 | metadata: 17 | annotations: 18 | gotemplating.fn.crossplane.io/composition-resource-name: sample-access-key-{{ $i }} 19 | spec: 20 | forProvider: 21 | userSelector: 22 | matchLabels: 23 | testing.upbound.io/example-name: test-user-{{ $i }} 24 | writeConnectionSecretToRef: 25 | name: sample-access-key-secret-{{ $i }} 26 | namespace: crossplane-system 27 | {{- end }} 28 | --- 29 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 30 | kind: CompositeConnectionDetails 31 | {{ if eq $.observed.resources nil }} 32 | data: {} 33 | {{ else }} 34 | data: 35 | username: {{ ( index $.observed.resources "sample-access-key-0" ).connectionDetails.username }} 36 | password: {{ ( index $.observed.resources "sample-access-key-0" ).connectionDetails.password }} 37 | url: {{ "http://www.example.com" | b64enc }} 38 | {{ end }} 39 | --- 40 | apiVersion: aws.platformref.upbound.io/v1alpha1 41 | kind: XUser 42 | status: 43 | dummy: cool-status 44 | -------------------------------------------------------------------------------- /example/filesystem/xrd.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: CompositeResourceDefinition 3 | metadata: 4 | name: xusers.aws.platformref.upbound.io 5 | spec: 6 | group: aws.platformref.upbound.io 7 | names: 8 | kind: XUser 9 | plural: xusers 10 | claimNames: 11 | kind: MyUser 12 | plural: myusers 13 | versions: 14 | - name: v1alpha1 15 | served: true 16 | referenceable: true 17 | schema: 18 | openAPIV3Schema: 19 | type: object 20 | properties: 21 | spec: 22 | type: object 23 | properties: 24 | id: 25 | type: string 26 | description: ID of this user that other objects will use to refer to it. 27 | count: 28 | type: integer 29 | required: 30 | - id 31 | - count 32 | status: 33 | type: object 34 | properties: 35 | dummy: 36 | type: string 37 | description: Dummy status field. 38 | -------------------------------------------------------------------------------- /example/functions/fromYaml/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-function-from-yaml 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | --- 21 | apiVersion: {{ .observed.composite.resource.apiVersion }} 22 | kind: {{ .observed.composite.resource.kind }} 23 | status: 24 | # Extract single value from encoded yaml string 25 | dummy: {{ (.observed.composite.resource.spec.yamlBlob | fromYaml).key2 }} 26 | -------------------------------------------------------------------------------- /example/functions/fromYaml/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/functions/fromYaml/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | yamlBlob: | 7 | key1: value1 8 | key2: value2 9 | key3: value3 10 | -------------------------------------------------------------------------------- /example/functions/getComposedResource/README.md: -------------------------------------------------------------------------------- 1 | # getComposedResource 2 | The getComposedResource function is a utility function used to facilitate the retrieval of composed resources within templated configurations, specifically targeting observed resources. By accepting a function request map and a resource name, it navigates the complex structure of a request to fetch the specified composed resource, making it easier and more user-friendly to access nested data. If the resource is found, it returns a map containing the resource's manifest; if not, it returns nil, indicating the resource does not exist or is inaccessible through the given path. 3 | ## Usage 4 | 5 | 6 | Examples: 7 | 8 | ```golang 9 | // Retrieve the observed resource named "flexServer" from the function request 10 | {{ $flexServer := getComposedResource . "flexServer" }} 11 | 12 | // Extract values from the observed resource 13 | {{ $flexServerID := get $flexServer.status.atProvider "id" }} 14 | 15 | 16 | ``` 17 | -------------------------------------------------------------------------------- /example/functions/getComposedResource/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-function-get-composed-resource 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | --- 21 | {{ $flexServerResourceName := "flexServer" }} 22 | # Create an initial composed resource for which we will retrieve a value 23 | apiVersion: dbforpostgresql.azure.upbound.io/v1beta1 24 | kind: FlexibleServer 25 | metadata: 26 | annotations: 27 | {{ setResourceNameAnnotation $flexServerResourceName }} 28 | gotemplating.fn.crossplane.io/ready: "False" 29 | spec: 30 | forProvider: 31 | storageMb: 32768 32 | providerConfigRef: 33 | name: my-provider-cfg 34 | --- 35 | # Use getComposedResource to retrieve the observed resource named "flexServer" 36 | {{ $flexServer := getComposedResource . $flexServerResourceName }} 37 | 38 | apiVersion: dbforpostgresql.azure.upbound.io/v1beta1 39 | kind: FlexibleServerConfiguration 40 | metadata: 41 | annotations: 42 | {{ setResourceNameAnnotation "flexServerConfig" }} 43 | gotemplating.fn.crossplane.io/ready: "False" 44 | spec: 45 | forProvider: 46 | # Populate the field using the observed status of the retrieved resource 47 | serverId: {{ get $flexServer.status "id" }} 48 | 49 | providerConfigRef: 50 | name: my-provider-cfg -------------------------------------------------------------------------------- /example/functions/getComposedResource/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/functions/getComposedResource/observed.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: dbforpostgresql.azure.upbound.io/v1beta1 2 | kind: FlexibleServer 3 | metadata: 4 | annotations: 5 | crossplane.io/composition-resource-name: flexServer 6 | labels: 7 | crossplane.io/composite: example 8 | spec: 9 | forProvider: 10 | storageMb: 32768 11 | providerConfigRef: 12 | name: my-provider-cfg 13 | status: 14 | atProvider: 15 | id: abcdef 16 | conditions: 17 | - type: Ready 18 | status: "True" 19 | reason: "foo" 20 | lastTransitionTime: "2023-11-03T09:07:31Z" 21 | -------------------------------------------------------------------------------- /example/functions/getComposedResource/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: {} 6 | 7 | -------------------------------------------------------------------------------- /example/functions/getCompositeResource/README.md: -------------------------------------------------------------------------------- 1 | # getCompositeResource 2 | The getCompositeResource function is a utility function used to facilitate the retrieval of a composite resources (XR) within templated configurations. Upon successful retrieval, the function returns a map containing the observed composite resource's manifest. If the resource cannot be located or is unreachable, it returns nil, indicating the absence or inaccessibility of the composite resource. 3 | ## Usage 4 | 5 | 6 | Examples: 7 | Given the following XR spec 8 | ```yaml 9 | apiVersion: example.crossplane.io/v1beta1 10 | kind: XR 11 | metadata: 12 | name: example 13 | spec: 14 | name: "example" 15 | location: "eastus" 16 | 17 | ``` 18 | ```golang 19 | // Retrieve the observed composite resource (XR) from the function request 20 | {{ $xr := getCompositeResource . }} 21 | 22 | apiVersion: example.crossplane.io/v1beta1 23 | kind: ExampleResource 24 | // 'Patch' values from the composite resource into the composed resource 25 | spec: 26 | forProvider: 27 | name: {{ get $xr.spec "name" }} 28 | location: {{ get $xr.spec "location" }} 29 | 30 | ``` 31 | -------------------------------------------------------------------------------- /example/functions/getCompositeResource/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-function-get-composite-resource 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | --- 21 | # Use a getCompositeResource to retrieve the XR 22 | {{ $xr := getCompositeResource . }} 23 | 24 | apiVersion: dbforpostgresql.azure.upbound.io/v1beta1 25 | kind: FlexibleServer 26 | metadata: 27 | annotations: 28 | {{ setResourceNameAnnotation "flexserver" }} 29 | gotemplating.fn.crossplane.io/ready: "False" 30 | spec: 31 | forProvider: 32 | 33 | # Use the XR object to set values 34 | adminLogin: {{ get $xr.spec "adminLogin" }} 35 | location: {{ get $xr.spec "location" }} -------------------------------------------------------------------------------- /example/functions/getCompositeResource/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/functions/getCompositeResource/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | adminLogin: "admin" 7 | location: "eastus" 8 | -------------------------------------------------------------------------------- /example/functions/getResourceCondition/README.md: -------------------------------------------------------------------------------- 1 | # getResourceCondition 2 | 3 | ## Usage 4 | 5 | ```golang 6 | {{ getResourceCondition $conditionType $resource }} 7 | {{ $resource | getResourceCondition $conditionType }} 8 | ``` 9 | 10 | Examples: 11 | 12 | ```golang 13 | // Print whole condition 14 | {{ .observed.resources.project | getResourceCondition "Ready" | toYaml }} 15 | 16 | // Check status 17 | {{ if eq (.observed.resources.project | getResourceCondition "Ready").Status "True" }} 18 | // do something 19 | {{ end }} 20 | ``` 21 | 22 | See example composition for more usage examples 23 | 24 | ## Example Outputs 25 | 26 | Requested resource does not exist or does not have the requested condition 27 | 28 | ```yaml 29 | lasttransitiontime: "0001-01-01T00:00:00Z" 30 | message: "" 31 | reason: "" 32 | status: Unknown 33 | type: Ready 34 | ``` 35 | 36 | Requested resource does have the requested condition 37 | 38 | ```yaml 39 | lasttransitiontime: "2023-11-03T10:07:31+01:00" 40 | message: "custom message" 41 | reason: foo 42 | status: "True" 43 | type: Ready 44 | ``` 45 | -------------------------------------------------------------------------------- /example/functions/getResourceCondition/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-function-get-resource-condition 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | --- 21 | apiVersion: {{ .observed.composite.resource.apiVersion }} 22 | kind: {{ .observed.composite.resource.kind }} 23 | status: 24 | # Retreive condition of XR 25 | compositeCondition: 26 | {{ getResourceCondition "Ready" .observed.composite | toYaml | nindent 4 }} 27 | 28 | # Retreive non existing condition of a resource just returns an Unknown condition 29 | compositeConditionNotFound: 30 | {{ getResourceCondition "Other" .observed.composite | toYaml | nindent 4 }} 31 | 32 | # You can also retreive conditions of composed resources the same way 33 | composedCondition: 34 | {{ getResourceCondition "Ready" .observed.resources.project | toYaml | nindent 4 }} 35 | 36 | # Use a getResourceCondition to render conditional blocks in templates 37 | {{ if eq (getResourceCondition "Ready" .observed.resources.project).Status "True" }} 38 | projectId: {{ .observed.resources.project.resource.status.atProvider.id | quote }} 39 | {{ end }} 40 | 41 | # Alternative syntax using pipelines 42 | pipeline: 43 | {{ .observed.resources.project | getResourceCondition "Ready" | toYaml | nindent 4 }} 44 | 45 | # Non object 46 | nonResource: 47 | {{ .observed.whatever | getResourceCondition "Ready" | toYaml | nindent 4 }} 48 | -------------------------------------------------------------------------------- /example/functions/getResourceCondition/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/functions/getResourceCondition/observed.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: identity.openstack.upbound.io/v1alpha1 2 | kind: ProjectV3 3 | metadata: 4 | annotations: 5 | crossplane.io/composition-resource-name: project 6 | labels: 7 | crossplane.io/composite: myproject 8 | name: myproject 9 | spec: 10 | forProvider: 11 | name: myproject 12 | providerConfigRef: 13 | name: myprovider 14 | status: 15 | atProvider: 16 | id: abcdef 17 | conditions: 18 | - type: Ready 19 | status: "True" 20 | reason: "foo" 21 | lastTransitionTime: "2023-11-03T09:07:31Z" 22 | -------------------------------------------------------------------------------- /example/functions/getResourceCondition/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | key: value 7 | status: 8 | conditions: 9 | - type: Ready 10 | status: "True" 11 | reason: "xr foo" 12 | lastTransitionTime: "2023-11-03T09:07:31Z" 13 | -------------------------------------------------------------------------------- /example/functions/include/README.md: -------------------------------------------------------------------------------- 1 | # getResourceCondition 2 | 3 | ## Usage 4 | 5 | ```golang 6 | {{ include $name $context }} 7 | ``` 8 | 9 | Examples: 10 | 11 | ```golang 12 | // Print whole condition 13 | {{ include "template-name" . | nindent 4 }} 14 | {{ $output:= include "template-name" . }} 15 | 16 | ``` 17 | 18 | See example composition for more usage examples -------------------------------------------------------------------------------- /example/functions/include/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-function-to-yaml 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | {{- define "labels" -}} 21 | some-text: {{.val1}} 22 | other-text: {{.val2}} 23 | {{- end }} 24 | 25 | 26 | ## weird indentation to make it work 27 | {{- define "labels-metadata" -}} 28 | some-text: {{.val1}} 29 | other-text: {{.val2}} 30 | {{- end }} 31 | {{- define "labels-matchLabels" -}} 32 | some-text: {{.val1}} 33 | other-text: {{.val2}} 34 | {{- end }} 35 | --- 36 | #include example 37 | {{$vals:= .observed.composite.resource.spec}} 38 | apiVersion: apps/v1 39 | kind: Deployment 40 | metadata: 41 | name: nginx-deployment 42 | annotations: 43 | gotemplating.fn.crossplane.io/composition-resource-name: test1 44 | labels: 45 | {{- include "labels" $vals | nindent 4}} 46 | spec: 47 | replicas: 3 48 | selector: 49 | matchLabels: 50 | {{- include "labels" $vals | nindent 6}} 51 | template: 52 | metadata: 53 | labels: 54 | app: nginx 55 | spec: 56 | containers: 57 | - name: nginx 58 | image: nginx:1.14.2 59 | ports: 60 | - containerPort: 80 61 | --- 62 | # without include, you must define a template per indentation setting 63 | apiVersion: apps/v1 64 | kind: Deployment 65 | metadata: 66 | name: nginx-deployment 67 | annotations: 68 | gotemplating.fn.crossplane.io/composition-resource-name: test2 69 | labels: 70 | {{ template "labels-metadata" $vals}} 71 | spec: 72 | replicas: 3 73 | selector: 74 | matchLabels: 75 | {{ template "labels-matchLabels" $vals }} 76 | template: 77 | metadata: 78 | labels: 79 | app: nginx 80 | spec: 81 | containers: 82 | - name: nginx 83 | image: nginx:1.14.2 84 | ports: 85 | - containerPort: 80 86 | -------------------------------------------------------------------------------- /example/functions/include/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:latest -------------------------------------------------------------------------------- /example/functions/include/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | val1: value1 7 | val2: value2 8 | -------------------------------------------------------------------------------- /example/functions/toYaml/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-function-to-yaml 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | --- 21 | apiVersion: {{ .observed.composite.resource.apiVersion }} 22 | kind: {{ .observed.composite.resource.kind }} 23 | status: 24 | # Copy the whole 'complexDictionary' as is by fomatting it as yaml 25 | dummy: {{ .observed.composite.resource.spec.complexDictionary | toYaml | nindent 7 }} 26 | -------------------------------------------------------------------------------- /example/functions/toYaml/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/functions/toYaml/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | complexDictionary: 7 | scalar1: true 8 | scalar2: text 9 | scalar3: 123 10 | list: 11 | - abc 12 | - def 13 | -------------------------------------------------------------------------------- /example/inline/composition.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-inline 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | {{- range $i := until ( .observed.composite.resource.spec.count | int ) }} 21 | --- 22 | apiVersion: iam.aws.upbound.io/v1beta1 23 | kind: User 24 | metadata: 25 | annotations: 26 | {{ setResourceNameAnnotation (print "test-user-" $i) }} 27 | labels: 28 | testing.upbound.io/example-name: test-user-{{ $i }} 29 | dummy: {{ dig "resources" (print "test-user-" $i) "resource" "metadata" "labels" "dummy" ( randomChoice "foo" "bar" "baz" ) $.observed }} 30 | spec: 31 | forProvider: {} 32 | --- 33 | apiVersion: iam.aws.upbound.io/v1beta1 34 | kind: AccessKey 35 | metadata: 36 | annotations: 37 | {{ setResourceNameAnnotation (print "sample-access-key-" $i) }} 38 | spec: 39 | forProvider: 40 | userSelector: 41 | matchLabels: 42 | testing.upbound.io/example-name: test-user-{{ $i }} 43 | writeConnectionSecretToRef: 44 | name: sample-access-key-secret-{{ $i }} 45 | namespace: crossplane-system 46 | {{- end }} 47 | --- 48 | apiVersion: meta.gotemplating.fn.crossplane.io/v1alpha1 49 | kind: CompositeConnectionDetails 50 | {{ if eq $.observed.resources nil }} 51 | data: {} 52 | {{ else }} 53 | data: 54 | username: {{ ( index $.observed.resources "sample-access-key-0" ).connectionDetails.username }} 55 | password: {{ ( index $.observed.resources "sample-access-key-0" ).connectionDetails.password }} 56 | url: {{ "http://www.example.com" | b64enc }} 57 | {{ end }} 58 | --- 59 | apiVersion: example.crossplane.io/v1beta1 60 | kind: XR 61 | status: 62 | dummy: cool-status 63 | -------------------------------------------------------------------------------- /example/inline/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/inline/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | count: 2 7 | -------------------------------------------------------------------------------- /example/recursive/composition-real.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-recursive-real # defining the real composition 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | apiVersion: s3.aws.upbound.io/v1beta1 21 | kind: Bucket 22 | metadata: 23 | annotations: 24 | {{ setResourceNameAnnotation "bucket" }} 25 | spec: 26 | forProvider: 27 | region: {{ .observed.composite.resource.spec.region }} 28 | -------------------------------------------------------------------------------- /example/recursive/composition-wrapper.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apiextensions.crossplane.io/v1 2 | kind: Composition 3 | metadata: 4 | name: example-recursive-wrapper # defining the wrapper composition 5 | spec: 6 | compositeTypeRef: 7 | apiVersion: example.crossplane.io/v1beta1 8 | kind: XR 9 | mode: Pipeline 10 | pipeline: 11 | - step: render-templates 12 | functionRef: 13 | name: function-go-templating 14 | input: 15 | apiVersion: gotemplating.fn.crossplane.io/v1beta1 16 | kind: GoTemplate 17 | source: Inline 18 | inline: 19 | template: | 20 | {{- range $i := until ( .observed.composite.resource.spec.count | int ) }} 21 | --- 22 | apiVersion: example.crossplane.io/v1beta1 23 | kind: XR 24 | metadata: 25 | annotations: 26 | {{ setResourceNameAnnotation (print "test-xr-" $i) }} 27 | spec: 28 | compositionRef: 29 | name: example-recursive-real # instantiating the real composition 30 | region: {{ print "us-west-" (add $i 1) }} 31 | {{ end }} 32 | -------------------------------------------------------------------------------- /example/recursive/functions.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: pkg.crossplane.io/v1beta1 2 | kind: Function 3 | metadata: 4 | name: function-go-templating 5 | spec: 6 | package: xpkg.upbound.io/crossplane-contrib/function-go-templating:v0.9.0 7 | -------------------------------------------------------------------------------- /example/recursive/xr.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.crossplane.io/v1beta1 2 | kind: XR 3 | metadata: 4 | name: example 5 | spec: 6 | compositionRef: 7 | name: example-recursive-wrapper # instantiating the wrapper composition 8 | count: 2 9 | -------------------------------------------------------------------------------- /extraresources.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | fnv1 "github.com/crossplane/function-sdk-go/proto/v1" 5 | ) 6 | 7 | // ExtraResourcesRequirements defines the requirements for extra resources. 8 | type ExtraResourcesRequirements map[string]ExtraResourcesRequirement 9 | 10 | // ExtraResourcesRequirement defines a single requirement for extra resources. 11 | // Needed to have camelCase keys instead of the snake_case keys as defined 12 | // through json tags by fnv1.ResourceSelector. 13 | type ExtraResourcesRequirement struct { 14 | // APIVersion of the resource. 15 | APIVersion string `json:"apiVersion"` 16 | // Kind of the resource. 17 | Kind string `json:"kind"` 18 | // MatchLabels defines the labels to match the resource, if defined, 19 | // matchName is ignored. 20 | MatchLabels map[string]string `json:"matchLabels,omitempty"` 21 | // MatchName defines the name to match the resource, if MatchLabels is 22 | // empty. 23 | MatchName string `json:"matchName,omitempty"` 24 | } 25 | 26 | // ToResourceSelector converts the ExtraResourcesRequirement to a fnv1.ResourceSelector. 27 | func (e *ExtraResourcesRequirement) ToResourceSelector() *fnv1.ResourceSelector { 28 | out := &fnv1.ResourceSelector{ 29 | ApiVersion: e.APIVersion, 30 | Kind: e.Kind, 31 | } 32 | if e.MatchName == "" { 33 | out.Match = &fnv1.ResourceSelector_MatchLabels{ 34 | MatchLabels: &fnv1.MatchLabels{Labels: e.MatchLabels}, 35 | } 36 | return out 37 | } 38 | 39 | out.Match = &fnv1.ResourceSelector_MatchName{ 40 | MatchName: e.MatchName, 41 | } 42 | return out 43 | } 44 | -------------------------------------------------------------------------------- /fn.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/base64" 7 | "io" 8 | "io/fs" 9 | "os" 10 | 11 | "dario.cat/mergo" 12 | "google.golang.org/protobuf/encoding/protojson" 13 | "google.golang.org/protobuf/types/known/structpb" 14 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 | "k8s.io/apimachinery/pkg/util/json" 16 | "k8s.io/apimachinery/pkg/util/yaml" 17 | 18 | "github.com/crossplane/crossplane-runtime/pkg/fieldpath" 19 | "github.com/crossplane/crossplane-runtime/pkg/meta" 20 | 21 | "github.com/crossplane/function-sdk-go/errors" 22 | "github.com/crossplane/function-sdk-go/logging" 23 | fnv1 "github.com/crossplane/function-sdk-go/proto/v1" 24 | "github.com/crossplane/function-sdk-go/request" 25 | "github.com/crossplane/function-sdk-go/resource" 26 | "github.com/crossplane/function-sdk-go/response" 27 | 28 | "github.com/crossplane-contrib/function-go-templating/input/v1beta1" 29 | ) 30 | 31 | // osFS is a dead-simple implementation of [io/fs.FS] that just wraps around 32 | // [os.Open]. 33 | type osFS struct{} 34 | 35 | func (*osFS) Open(name string) (fs.File, error) { 36 | return os.Open(name) 37 | } 38 | 39 | // Function uses Go templates to compose resources. 40 | type Function struct { 41 | fnv1.UnimplementedFunctionRunnerServiceServer 42 | 43 | log logging.Logger 44 | fsys fs.FS 45 | } 46 | 47 | const ( 48 | annotationKeyCompositionResourceName = "gotemplating.fn.crossplane.io/composition-resource-name" 49 | annotationKeyReady = "gotemplating.fn.crossplane.io/ready" 50 | 51 | metaApiVersion = "meta.gotemplating.fn.crossplane.io/v1alpha1" 52 | ) 53 | 54 | // RunFunction runs the Function. 55 | func (f *Function) RunFunction(_ context.Context, req *fnv1.RunFunctionRequest) (*fnv1.RunFunctionResponse, error) { 56 | f.log.Info("Running Function", "tag", req.GetMeta().GetTag()) 57 | 58 | rsp := response.To(req, response.DefaultTTL) 59 | 60 | in := &v1beta1.GoTemplate{} 61 | if err := request.GetInput(req, in); err != nil { 62 | response.Fatal(rsp, errors.Wrapf(err, "cannot get Function input from %T", req)) 63 | return rsp, nil 64 | } 65 | 66 | tg, err := NewTemplateSourceGetter(f.fsys, in) 67 | if err != nil { 68 | response.Fatal(rsp, errors.Wrap(err, "invalid function input")) 69 | return rsp, nil 70 | } 71 | 72 | f.log.Debug("template", "template", tg.GetTemplates()) 73 | 74 | tmpl, err := GetNewTemplateWithFunctionMaps(in.Delims).Parse(tg.GetTemplates()) 75 | if err != nil { 76 | response.Fatal(rsp, errors.Wrap(err, "invalid function input: cannot parse the provided templates")) 77 | return rsp, nil 78 | } 79 | 80 | reqMap, err := convertToMap(req) 81 | if err != nil { 82 | response.Fatal(rsp, errors.Wrap(err, "cannot convert request to map")) 83 | return rsp, nil 84 | } 85 | 86 | f.log.Debug("constructed request map", "request", reqMap) 87 | 88 | buf := &bytes.Buffer{} 89 | 90 | if err := tmpl.Execute(buf, reqMap); err != nil { 91 | response.Fatal(rsp, errors.Wrap(err, "cannot execute template")) 92 | return rsp, nil 93 | } 94 | 95 | f.log.Debug("rendered manifests", "manifests", buf.String()) 96 | 97 | // Parse the rendered manifests. 98 | var objs []*unstructured.Unstructured 99 | decoder := yaml.NewYAMLOrJSONDecoder(bytes.NewBufferString(buf.String()), 1024) 100 | for { 101 | u := &unstructured.Unstructured{} 102 | if err := decoder.Decode(&u); err != nil { 103 | if err == io.EOF { 104 | break 105 | } 106 | response.Fatal(rsp, errors.Wrap(err, "cannot decode manifest")) 107 | return rsp, nil 108 | } 109 | 110 | if u == nil { 111 | continue 112 | } 113 | 114 | // When decoding YAML into an Unstructured object, unquoted values like booleans or integers 115 | // can inadvertently be set as annotations, leading to unexpected behavior in later processing 116 | // steps that assume string-only values, such as GetAnnotations. 117 | if _, _, err := unstructured.NestedStringMap(u.Object, "metadata", "annotations"); err != nil { 118 | m, _, _ := unstructured.NestedMap(u.Object, "metadata", "annotations") 119 | response.Fatal(rsp, errors.Wrapf(err, "invalid annotations in resource '%s resource-name=%v'", u.GroupVersionKind(), m[annotationKeyCompositionResourceName])) 120 | return rsp, nil 121 | } 122 | 123 | objs = append(objs, u) 124 | } 125 | 126 | // Get the desired composite resource from the request. 127 | desiredComposite, err := request.GetDesiredCompositeResource(req) 128 | if err != nil { 129 | response.Fatal(rsp, errors.Wrap(err, "cannot get desired composite resource")) 130 | return rsp, nil 131 | } 132 | 133 | // Get the observed composite resource from the request. 134 | observedComposite, err := request.GetObservedCompositeResource(req) 135 | if err != nil { 136 | response.Fatal(rsp, errors.Wrap(err, "cannot get observed composite resource")) 137 | return rsp, nil 138 | } 139 | 140 | // Get the desired composed resources from the request. 141 | desiredComposed, err := request.GetDesiredComposedResources(req) 142 | if err != nil { 143 | response.Fatal(rsp, errors.Wrap(err, "cannot get desired composed resources")) 144 | return rsp, nil 145 | } 146 | 147 | // Initialize the requirements. 148 | requirements := &fnv1.Requirements{ExtraResources: make(map[string]*fnv1.ResourceSelector)} 149 | 150 | // Convert the rendered manifests to a list of desired composed resources. 151 | for _, obj := range objs { 152 | cd := resource.NewDesiredComposed() 153 | cd.Resource.Unstructured = *obj.DeepCopy() 154 | 155 | // TODO(ezgidemirel): Refactor to reduce cyclomatic complexity. 156 | // Handle if the composite resource appears in the rendered template. 157 | // Unless resource name annotation is present, update only the status of the desired composite resource. 158 | name, nameFound := obj.GetAnnotations()[annotationKeyCompositionResourceName] 159 | if cd.Resource.GetAPIVersion() == observedComposite.Resource.GetAPIVersion() && cd.Resource.GetKind() == observedComposite.Resource.GetKind() && !nameFound { 160 | dst := make(map[string]any) 161 | if err := desiredComposite.Resource.GetValueInto("status", &dst); err != nil && !fieldpath.IsNotFound(err) { 162 | response.Fatal(rsp, errors.Wrap(err, "cannot get desired composite status")) 163 | return rsp, nil 164 | } 165 | 166 | src := make(map[string]any) 167 | if err := cd.Resource.GetValueInto("status", &src); err != nil && !fieldpath.IsNotFound(err) { 168 | response.Fatal(rsp, errors.Wrap(err, "cannot get templated composite status")) 169 | return rsp, nil 170 | } 171 | 172 | if err := mergo.Merge(&dst, src, mergo.WithOverride); err != nil { 173 | response.Fatal(rsp, errors.Wrap(err, "cannot merge desired composite status")) 174 | return rsp, nil 175 | } 176 | 177 | if err := fieldpath.Pave(desiredComposite.Resource.Object).SetValue("status", dst); err != nil { 178 | response.Fatal(rsp, errors.Wrap(err, "cannot set desired composite status")) 179 | return rsp, nil 180 | } 181 | 182 | continue 183 | } 184 | 185 | // TODO(ezgidemirel): Refactor to reduce cyclomatic complexity. 186 | if cd.Resource.GetAPIVersion() == metaApiVersion { 187 | switch obj.GetKind() { 188 | case "CompositeConnectionDetails": 189 | // Set composite resource's connection details. 190 | con, _ := cd.Resource.GetStringObject("data") 191 | for k, v := range con { 192 | d, _ := base64.StdEncoding.DecodeString(v) //nolint:errcheck // k8s returns secret values encoded 193 | desiredComposite.ConnectionDetails[k] = d 194 | } 195 | case "ClaimConditions": 196 | var conditions []TargetedCondition 197 | if err = cd.Resource.GetValueInto("conditions", &conditions); err != nil { 198 | response.Fatal(rsp, errors.Wrap(err, "cannot get Conditions from input")) 199 | return rsp, nil 200 | } 201 | err := UpdateClaimConditions(rsp, conditions...) 202 | if err != nil { 203 | return rsp, nil 204 | } 205 | f.log.Debug("updating ClaimConditions", "conditions", rsp.Conditions) 206 | case "Context": 207 | contextData := make(map[string]interface{}) 208 | if err = cd.Resource.GetValueInto("data", &contextData); err != nil { 209 | response.Fatal(rsp, errors.Wrap(err, "cannot get Contexts from input")) 210 | return rsp, nil 211 | } 212 | mergedCtx, err := f.MergeContext(req, contextData) 213 | if err != nil { 214 | response.Fatal(rsp, errors.Wrapf(err, "cannot merge Context")) 215 | return rsp, nil 216 | } 217 | 218 | for key, v := range mergedCtx { 219 | vv, err := structpb.NewValue(v) 220 | if err != nil { 221 | response.Fatal(rsp, errors.Wrap(err, "cannot convert value to structpb.Value")) 222 | return rsp, nil 223 | } 224 | f.log.Debug("Updating Composition environment", "key", key, "data", v) 225 | response.SetContextKey(rsp, key, vv) 226 | } 227 | case "ExtraResources": 228 | // Set extra resources requirements. 229 | ers := make(ExtraResourcesRequirements) 230 | if err = cd.Resource.GetValueInto("requirements", &ers); err != nil { 231 | response.Fatal(rsp, errors.Wrap(err, "cannot get extra resources requirements")) 232 | return rsp, nil 233 | } 234 | for k, v := range ers { 235 | if _, found := requirements.ExtraResources[k]; found { 236 | response.Fatal(rsp, errors.Errorf("duplicate extra resource key %q", k)) 237 | return rsp, nil 238 | } 239 | requirements.ExtraResources[k] = v.ToResourceSelector() 240 | } 241 | default: 242 | response.Fatal(rsp, errors.Errorf("invalid kind %q for apiVersion %q - must be one of CompositeConnectionDetails, Context or ExtraResources", obj.GetKind(), metaApiVersion)) 243 | return rsp, nil 244 | } 245 | 246 | continue 247 | } 248 | 249 | // TODO(ezgidemirel): Refactor to reduce cyclomatic complexity. 250 | // Set ready state. 251 | if v, found := cd.Resource.GetAnnotations()[annotationKeyReady]; found { 252 | if v != string(resource.ReadyTrue) && v != string(resource.ReadyUnspecified) && v != string(resource.ReadyFalse) { 253 | response.Fatal(rsp, errors.Errorf("invalid function input: invalid %q annotation value %q: must be True, False, or Unspecified", annotationKeyReady, v)) 254 | return rsp, nil 255 | } 256 | 257 | cd.Ready = resource.Ready(v) 258 | 259 | // Remove meta annotation. 260 | meta.RemoveAnnotations(cd.Resource, annotationKeyReady) 261 | } 262 | 263 | // Remove resource name annotation. 264 | meta.RemoveAnnotations(cd.Resource, annotationKeyCompositionResourceName) 265 | 266 | // Add resource to the desired composed resources map. 267 | if !nameFound { 268 | response.Fatal(rsp, errors.Errorf("%q template is missing required %q annotation", obj.GetKind(), annotationKeyCompositionResourceName)) 269 | return rsp, nil 270 | } 271 | 272 | desiredComposed[resource.Name(name)] = cd 273 | } 274 | 275 | f.log.Debug("desired composite resource", "desiredComposite:", desiredComposite) 276 | f.log.Debug("constructed desired composed resources", "desiredComposed:", desiredComposed) 277 | 278 | if err := response.SetDesiredComposedResources(rsp, desiredComposed); err != nil { 279 | response.Fatal(rsp, errors.Wrap(err, "cannot desired composed resources")) 280 | return rsp, nil 281 | } 282 | 283 | if err := response.SetDesiredCompositeResource(rsp, desiredComposite); err != nil { 284 | response.Fatal(rsp, errors.Wrap(err, "cannot set desired composite resource")) 285 | return rsp, nil 286 | } 287 | 288 | if len(requirements.ExtraResources) > 0 { 289 | rsp.Requirements = requirements 290 | } 291 | 292 | f.log.Info("Successfully composed desired resources", "source", in.Source, "count", len(objs)) 293 | 294 | return rsp, nil 295 | } 296 | 297 | func convertToMap(req *fnv1.RunFunctionRequest) (map[string]any, error) { 298 | jReq, err := protojson.Marshal(req) 299 | if err != nil { 300 | return nil, errors.Wrap(err, "cannot marshal request from proto to json") 301 | } 302 | 303 | var mReq map[string]any 304 | if err := json.Unmarshal(jReq, &mReq); err != nil { 305 | return nil, errors.Wrap(err, "cannot unmarshal json to map[string]any") 306 | } 307 | 308 | return mReq, nil 309 | } 310 | -------------------------------------------------------------------------------- /fn_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "embed" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/google/go-cmp/cmp" 10 | "github.com/google/go-cmp/cmp/cmpopts" 11 | "google.golang.org/protobuf/testing/protocmp" 12 | "google.golang.org/protobuf/types/known/durationpb" 13 | "k8s.io/utils/ptr" 14 | 15 | "github.com/crossplane/crossplane-runtime/pkg/logging" 16 | 17 | fnv1 "github.com/crossplane/function-sdk-go/proto/v1" 18 | "github.com/crossplane/function-sdk-go/resource" 19 | "github.com/crossplane/function-sdk-go/response" 20 | 21 | "github.com/crossplane-contrib/function-go-templating/input/v1beta1" 22 | ) 23 | 24 | var ( 25 | cd = `{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{"gotemplating.fn.crossplane.io/composition-resource-name":"cool-cd"},"name":"cool-cd"}}` 26 | cdTmpl = `{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{"gotemplating.fn.crossplane.io/composition-resource-name":"cool-cd"},"name":"cool-cd","labels":{"belongsTo":{{.observed.composite.resource.metadata.name|quote}}}}}` 27 | cdWrongTmpl = `{"apiVersion":"example.org/v1","kind":"CD","metadata":{"name":"cool-cd","labels":{"belongsTo":{{.invalid-key}}}}}` 28 | cdMissingKind = `{"apiVersion":"example.org/v1"}` 29 | cdMissingResourceName = `{"apiVersion":"example.org/v1","kind":"CD","metadata":{"name":"cool-cd"}}` 30 | cdWithReadyWrong = `{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{"gotemplating.fn.crossplane.io/composition-resource-name":"cool-cd","gotemplating.fn.crossplane.io/ready":"wrongValue"},"name":"cool-cd"}}` 31 | cdWithReadyTrue = `{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{"gotemplating.fn.crossplane.io/composition-resource-name":"cool-cd","gotemplating.fn.crossplane.io/ready":"True"},"name":"cool-cd"}}` 32 | 33 | metaResourceInvalid = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"InvalidMeta"}` 34 | metaResourceConDet = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"CompositeConnectionDetails","data":{"key":"dmFsdWU="}}` // encoded string "value" 35 | metaResourceContextInvalid = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"Context","data": 1 }` 36 | metaResourceContext = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"Context","data":{"apiextensions.crossplane.io/environment":{ "new":"value"}}}` 37 | 38 | xr = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2}}` 39 | xrWithStatus = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2},"status":{"ready":"true"}}` 40 | xrWithNestedStatusFoo = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2},"status":{"state":{"foo":"bar"}}}` 41 | xrWithNestedStatusBaz = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2},"status":{"state":{"baz":"qux"}}}` 42 | xrRecursiveTmpl = `{"apiVersion":"example.org/v1","kind":"XR","metadata":{"annotations":{"gotemplating.fn.crossplane.io/composition-resource-name":"recursive-xr"},"name":"recursive-xr","labels":{"belongsTo":{{.observed.composite.resource.metadata.name|quote}}}},"spec":{"count":2}}` 43 | 44 | claimConditions = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ClaimConditions","conditions":[{"type":"TestCondition","status":"False","reason":"InstallFail","message":"failed to install","target":"ClaimAndComposite"},{"type":"ConditionTrue","status":"True","reason":"this condition is true","message":"we are true","target":"Composite"},{"type":"DatabaseReady","status":"True","reason":"Ready","message":"Database is ready"}]}` 45 | claimConditionsReservedKey = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ClaimConditions","conditions":[{"type":"Ready","status":"False","reason":"InstallFail","message":"I am using a reserved Condition","target":"ClaimAndComposite"}]}` 46 | 47 | extraResources = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ExtraResources","requirements":{"cool-extra-resource":{"apiVersion":"example.org/v1","kind":"CoolExtraResource","matchName":"cool-extra-resource"}}} 48 | {"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ExtraResources","requirements":{"another-cool-extra-resource":{"apiVersion":"example.org/v1","kind":"CoolExtraResource","matchLabels":{"key": "value"}},"yet-another-cool-extra-resource":{"apiVersion":"example.org/v1","kind":"CoolExtraResource","matchName":"foo"}}} 49 | {"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ExtraResources","requirements":{"all-cool-resources":{"apiVersion":"example.org/v1","kind":"CoolExtraResource","matchLabels":{}}}}` 50 | extraResourcesDuplicatedKey = `{"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ExtraResources","requirements":{"cool-extra-resource":{"apiVersion":"example.org/v1","kind":"CoolExtraResource","matchName":"cool-extra-resource"}}} 51 | {"apiVersion":"meta.gotemplating.fn.crossplane.io/v1alpha1","kind":"ExtraResources","requirements":{"cool-extra-resource":{"apiVersion":"example.org/v1","kind":"CoolExtraResource","matchName":"another-cool-extra-resource"}}}` 52 | 53 | path = "testdata/templates" 54 | wrongPath = "testdata/wrong" 55 | 56 | //go:embed testdata 57 | testdataFS embed.FS 58 | ) 59 | 60 | func TestRunFunction(t *testing.T) { 61 | type args struct { 62 | ctx context.Context 63 | req *fnv1.RunFunctionRequest 64 | } 65 | type want struct { 66 | rsp *fnv1.RunFunctionResponse 67 | err error 68 | } 69 | 70 | cases := map[string]struct { 71 | reason string 72 | args args 73 | want want 74 | }{ 75 | "WrongInputSourceType": { 76 | reason: "The Function should return a fatal result if the cd source type is wrong", 77 | args: args{ 78 | req: &fnv1.RunFunctionRequest{ 79 | Input: resource.MustStructObject( 80 | &v1beta1.GoTemplate{ 81 | Source: "wrong", 82 | }), 83 | }, 84 | }, 85 | want: want{ 86 | rsp: &fnv1.RunFunctionResponse{ 87 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 88 | Results: []*fnv1.Result{ 89 | { 90 | Severity: fnv1.Severity_SEVERITY_FATAL, 91 | Message: "invalid function input: invalid source: wrong", 92 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 93 | }, 94 | }, 95 | }, 96 | }, 97 | }, 98 | "NoInput": { 99 | reason: "The Function should return a fatal result if no cd was specified", 100 | args: args{ 101 | req: &fnv1.RunFunctionRequest{}, 102 | }, 103 | want: want{ 104 | rsp: &fnv1.RunFunctionResponse{ 105 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 106 | Results: []*fnv1.Result{ 107 | { 108 | Severity: fnv1.Severity_SEVERITY_FATAL, 109 | Message: "invalid function input: source is required", 110 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 111 | }, 112 | }, 113 | }, 114 | }, 115 | }, 116 | "WrongInlineInput": { 117 | reason: "The Function should return a fatal result if there is no inline template provided", 118 | args: args{ 119 | req: &fnv1.RunFunctionRequest{ 120 | Input: resource.MustStructObject( 121 | &v1beta1.GoTemplate{ 122 | Source: v1beta1.InlineSource, 123 | }), 124 | }, 125 | }, 126 | want: want{ 127 | rsp: &fnv1.RunFunctionResponse{ 128 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 129 | Results: []*fnv1.Result{ 130 | { 131 | Severity: fnv1.Severity_SEVERITY_FATAL, 132 | Message: "invalid function input: inline.template should be provided", 133 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 134 | }, 135 | }, 136 | }, 137 | }, 138 | }, 139 | "WrongFileSystemInput": { 140 | args: args{ 141 | req: &fnv1.RunFunctionRequest{ 142 | Input: resource.MustStructObject( 143 | &v1beta1.GoTemplate{ 144 | Source: v1beta1.FileSystemSource, 145 | }), 146 | }, 147 | }, 148 | want: want{ 149 | rsp: &fnv1.RunFunctionResponse{ 150 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 151 | Results: []*fnv1.Result{ 152 | { 153 | Severity: fnv1.Severity_SEVERITY_FATAL, 154 | Message: "invalid function input: fileSystem.dirPath should be provided", 155 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 156 | }, 157 | }, 158 | }, 159 | }, 160 | }, 161 | "NoResourceNameAnnotation": { 162 | reason: "The Function should return a fatal result if the cd does not have a composition-resource-name annotation", 163 | args: args{ 164 | req: &fnv1.RunFunctionRequest{ 165 | Input: resource.MustStructObject( 166 | &v1beta1.GoTemplate{ 167 | Source: v1beta1.InlineSource, 168 | Inline: &v1beta1.TemplateSourceInline{Template: cdMissingResourceName}, 169 | }), 170 | }, 171 | }, 172 | want: want{ 173 | rsp: &fnv1.RunFunctionResponse{ 174 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 175 | Results: []*fnv1.Result{ 176 | { 177 | Severity: fnv1.Severity_SEVERITY_FATAL, 178 | Message: "\"CD\" template is missing required \"" + annotationKeyCompositionResourceName + "\" annotation", 179 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 180 | }, 181 | }, 182 | }, 183 | }, 184 | }, 185 | "CannotDecodeManifest": { 186 | reason: "The Function should return a fatal result if the manifest cannot be decoded", 187 | args: args{ 188 | req: &fnv1.RunFunctionRequest{ 189 | Input: resource.MustStructObject( 190 | &v1beta1.GoTemplate{ 191 | Source: v1beta1.InlineSource, 192 | Inline: &v1beta1.TemplateSourceInline{Template: cdMissingKind}, 193 | }), 194 | }, 195 | }, 196 | want: want{ 197 | rsp: &fnv1.RunFunctionResponse{ 198 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 199 | Results: []*fnv1.Result{ 200 | { 201 | Severity: fnv1.Severity_SEVERITY_FATAL, 202 | Message: fmt.Sprintf("cannot decode manifest: Object 'Kind' is missing in '%s'", cdMissingKind), 203 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 204 | }, 205 | }, 206 | }, 207 | }, 208 | }, 209 | "CannotParseTemplate": { 210 | reason: "The Function should return a fatal result if the template cannot be parsed", 211 | args: args{ 212 | req: &fnv1.RunFunctionRequest{ 213 | Input: resource.MustStructObject( 214 | &v1beta1.GoTemplate{ 215 | Source: v1beta1.InlineSource, 216 | Inline: &v1beta1.TemplateSourceInline{Template: cdWrongTmpl}, 217 | }, 218 | ), 219 | Observed: &fnv1.State{ 220 | Composite: &fnv1.Resource{ 221 | Resource: resource.MustStructJSON(xr), 222 | }, 223 | }, 224 | Desired: &fnv1.State{ 225 | Composite: &fnv1.Resource{ 226 | Resource: resource.MustStructJSON(xr), 227 | }, 228 | }, 229 | }, 230 | }, 231 | want: want{ 232 | rsp: &fnv1.RunFunctionResponse{ 233 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 234 | Results: []*fnv1.Result{ 235 | { 236 | Severity: fnv1.Severity_SEVERITY_FATAL, 237 | Message: "invalid function input: cannot parse the provided templates: template: manifests:1: bad character U+002D '-'", 238 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 239 | }, 240 | }, 241 | Desired: &fnv1.State{ 242 | Composite: &fnv1.Resource{ 243 | Resource: resource.MustStructJSON(xr), 244 | }, 245 | }, 246 | }, 247 | }, 248 | }, 249 | "ResponseIsReturnedWithNoChange": { 250 | reason: "The Function should return the desired composite resource and cd composed resource without any changes.", 251 | args: args{ 252 | req: &fnv1.RunFunctionRequest{ 253 | Meta: &fnv1.RequestMeta{Tag: "nochange"}, 254 | Input: resource.MustStructObject( 255 | &v1beta1.GoTemplate{ 256 | Source: v1beta1.InlineSource, 257 | Inline: &v1beta1.TemplateSourceInline{Template: cd}, 258 | }), 259 | Observed: &fnv1.State{ 260 | Composite: &fnv1.Resource{ 261 | Resource: resource.MustStructJSON(xr), 262 | }, 263 | }, 264 | Desired: &fnv1.State{ 265 | Composite: &fnv1.Resource{ 266 | Resource: resource.MustStructJSON(xr), 267 | }, 268 | Resources: map[string]*fnv1.Resource{ 269 | "cool-cd": { 270 | Resource: resource.MustStructJSON(cd), 271 | }, 272 | }, 273 | }, 274 | }, 275 | }, 276 | want: want{ 277 | rsp: &fnv1.RunFunctionResponse{ 278 | Meta: &fnv1.ResponseMeta{Tag: "nochange", Ttl: durationpb.New(response.DefaultTTL)}, 279 | Desired: &fnv1.State{ 280 | Composite: &fnv1.Resource{ 281 | Resource: resource.MustStructJSON(xr), 282 | }, 283 | Resources: map[string]*fnv1.Resource{ 284 | "cool-cd": { 285 | Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{},"name":"cool-cd"}}`), 286 | }, 287 | }, 288 | }, 289 | }, 290 | }, 291 | }, 292 | "ResponseIsReturnedWithTemplating": { 293 | reason: "The Function should return the desired composite resource and the templated composed resources.", 294 | args: args{ 295 | req: &fnv1.RunFunctionRequest{ 296 | Meta: &fnv1.RequestMeta{Tag: "templates"}, 297 | Input: resource.MustStructObject( 298 | &v1beta1.GoTemplate{ 299 | Source: v1beta1.InlineSource, 300 | Inline: &v1beta1.TemplateSourceInline{Template: cdTmpl}, 301 | }), 302 | Observed: &fnv1.State{ 303 | Composite: &fnv1.Resource{ 304 | Resource: resource.MustStructJSON(xr), 305 | }, 306 | }, 307 | Desired: &fnv1.State{ 308 | Composite: &fnv1.Resource{ 309 | Resource: resource.MustStructJSON(xr), 310 | }, 311 | }, 312 | }, 313 | }, 314 | want: want{ 315 | rsp: &fnv1.RunFunctionResponse{ 316 | Meta: &fnv1.ResponseMeta{Tag: "templates", Ttl: durationpb.New(response.DefaultTTL)}, 317 | Desired: &fnv1.State{ 318 | Composite: &fnv1.Resource{ 319 | Resource: resource.MustStructJSON(xr), 320 | }, 321 | Resources: map[string]*fnv1.Resource{ 322 | "cool-cd": { 323 | Resource: resource.MustStructJSON(`{"apiVersion": "example.org/v1","kind":"CD","metadata":{"annotations":{},"name":"cool-cd","labels":{"belongsTo":"cool-xr"}}}`), 324 | }, 325 | }, 326 | }, 327 | }, 328 | }, 329 | }, 330 | "UpdateDesiredCompositeStatus": { 331 | reason: "The Function should update the desired composite resource status.", 332 | args: args{ 333 | req: &fnv1.RunFunctionRequest{ 334 | Meta: &fnv1.RequestMeta{Tag: "status"}, 335 | Input: resource.MustStructObject( 336 | &v1beta1.GoTemplate{ 337 | Source: v1beta1.InlineSource, 338 | Inline: &v1beta1.TemplateSourceInline{Template: xrWithStatus}, 339 | }), 340 | Observed: &fnv1.State{ 341 | Composite: &fnv1.Resource{ 342 | Resource: resource.MustStructJSON(xr), 343 | }, 344 | }, 345 | Desired: &fnv1.State{ 346 | Composite: &fnv1.Resource{ 347 | Resource: resource.MustStructJSON(xr), 348 | }, 349 | }, 350 | }, 351 | }, 352 | want: want{ 353 | rsp: &fnv1.RunFunctionResponse{ 354 | Meta: &fnv1.ResponseMeta{Tag: "status", Ttl: durationpb.New(response.DefaultTTL)}, 355 | Desired: &fnv1.State{ 356 | Composite: &fnv1.Resource{ 357 | Resource: resource.MustStructJSON(xrWithStatus), 358 | }, 359 | }, 360 | }, 361 | }, 362 | }, 363 | "UpdateDesiredCompositeNestedStatus": { 364 | reason: "The Function should update the desired composite resource nested status.", 365 | args: args{ 366 | req: &fnv1.RunFunctionRequest{ 367 | Meta: &fnv1.RequestMeta{Tag: "status"}, 368 | Input: resource.MustStructObject( 369 | &v1beta1.GoTemplate{ 370 | Source: v1beta1.InlineSource, 371 | Inline: &v1beta1.TemplateSourceInline{Template: xrWithNestedStatusBaz}, 372 | }), 373 | Observed: &fnv1.State{ 374 | Composite: &fnv1.Resource{ 375 | Resource: resource.MustStructJSON(xrWithNestedStatusFoo), 376 | }, 377 | }, 378 | Desired: &fnv1.State{ 379 | Composite: &fnv1.Resource{ 380 | Resource: resource.MustStructJSON(xrWithNestedStatusFoo), 381 | }, 382 | }, 383 | }, 384 | }, 385 | want: want{ 386 | rsp: &fnv1.RunFunctionResponse{ 387 | Meta: &fnv1.ResponseMeta{Tag: "status", Ttl: durationpb.New(response.DefaultTTL)}, 388 | Desired: &fnv1.State{ 389 | Composite: &fnv1.Resource{ 390 | Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"XR","metadata":{"name":"cool-xr"},"spec":{"count":2},"status":{"state":{"foo":"bar","baz":"qux"}}}`), 391 | }, 392 | }, 393 | }, 394 | }, 395 | }, 396 | "ResponseIsReturnedWithTemplatedXR": { 397 | reason: "The Function should return the desired composite resource and the composed templated XR resource.", 398 | args: args{ 399 | req: &fnv1.RunFunctionRequest{ 400 | Meta: &fnv1.RequestMeta{Tag: "status"}, 401 | Input: resource.MustStructObject( 402 | &v1beta1.GoTemplate{ 403 | Source: v1beta1.InlineSource, 404 | Inline: &v1beta1.TemplateSourceInline{Template: xrRecursiveTmpl}, 405 | }), 406 | Observed: &fnv1.State{ 407 | Composite: &fnv1.Resource{ 408 | Resource: resource.MustStructJSON(xr), 409 | }, 410 | }, 411 | Desired: &fnv1.State{ 412 | Composite: &fnv1.Resource{ 413 | Resource: resource.MustStructJSON(xr), 414 | }, 415 | }, 416 | }, 417 | }, 418 | want: want{ 419 | rsp: &fnv1.RunFunctionResponse{ 420 | Meta: &fnv1.ResponseMeta{Tag: "status", Ttl: durationpb.New(response.DefaultTTL)}, 421 | Desired: &fnv1.State{ 422 | Composite: &fnv1.Resource{ 423 | Resource: resource.MustStructJSON(xr), 424 | }, 425 | Resources: map[string]*fnv1.Resource{ 426 | "recursive-xr": { 427 | Resource: resource.MustStructJSON(`{"apiVersion": "example.org/v1","kind":"XR","metadata":{"annotations":{},"name":"recursive-xr","labels":{"belongsTo":"cool-xr"}},"spec":{"count":2}}`), 428 | }, 429 | }, 430 | }, 431 | }, 432 | }, 433 | }, 434 | "ResponseIsReturnedWithTemplatingFS": { 435 | reason: "The Function should return the desired composite resource and the templated composed resources with FileSystem cd.", 436 | args: args{ 437 | req: &fnv1.RunFunctionRequest{ 438 | Meta: &fnv1.RequestMeta{Tag: "templates"}, 439 | Input: resource.MustStructObject( 440 | &v1beta1.GoTemplate{ 441 | Source: v1beta1.FileSystemSource, 442 | FileSystem: &v1beta1.TemplateSourceFileSystem{DirPath: path}, 443 | }), 444 | Observed: &fnv1.State{ 445 | Composite: &fnv1.Resource{ 446 | Resource: resource.MustStructJSON(xr), 447 | }, 448 | }, 449 | Desired: &fnv1.State{ 450 | Composite: &fnv1.Resource{ 451 | Resource: resource.MustStructJSON(xr), 452 | }, 453 | }, 454 | }, 455 | }, 456 | want: want{ 457 | rsp: &fnv1.RunFunctionResponse{ 458 | Meta: &fnv1.ResponseMeta{Tag: "templates", Ttl: durationpb.New(response.DefaultTTL)}, 459 | Desired: &fnv1.State{ 460 | Composite: &fnv1.Resource{ 461 | Resource: resource.MustStructJSON(xr), 462 | }, 463 | Resources: map[string]*fnv1.Resource{ 464 | "cool-cd": { 465 | Resource: resource.MustStructJSON(`{"apiVersion": "example.org/v1","kind":"CD","metadata":{"annotations":{},"name":"cool-cd","labels":{"belongsTo":"cool-xr"}}}`), 466 | }, 467 | }, 468 | }, 469 | }, 470 | }, 471 | }, 472 | "CannotReadTemplatesFromFS": { 473 | reason: "The Function should return a fatal result if the templates cannot be read from the filesystem.", 474 | args: args{ 475 | req: &fnv1.RunFunctionRequest{ 476 | Input: resource.MustStructObject( 477 | &v1beta1.GoTemplate{ 478 | Source: v1beta1.FileSystemSource, 479 | FileSystem: &v1beta1.TemplateSourceFileSystem{DirPath: wrongPath}, 480 | }, 481 | ), 482 | }, 483 | }, 484 | want: want{ 485 | rsp: &fnv1.RunFunctionResponse{ 486 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 487 | Results: []*fnv1.Result{ 488 | { 489 | Severity: fnv1.Severity_SEVERITY_FATAL, 490 | Message: "invalid function input: cannot read tmpl from the folder {testdata/wrong}: open testdata/wrong: file does not exist", 491 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 492 | }, 493 | }, 494 | }, 495 | }, 496 | }, 497 | "ReadyStatusAnnotationNotValid": { 498 | reason: "The Function should return a fatal result if the ready annotation is not valid.", 499 | args: args{ 500 | req: &fnv1.RunFunctionRequest{ 501 | Input: resource.MustStructObject( 502 | &v1beta1.GoTemplate{ 503 | Source: v1beta1.InlineSource, 504 | Inline: &v1beta1.TemplateSourceInline{Template: cdWithReadyWrong}, 505 | }), 506 | Observed: &fnv1.State{ 507 | Composite: &fnv1.Resource{ 508 | Resource: resource.MustStructJSON(xr), 509 | }, 510 | }, 511 | Desired: &fnv1.State{ 512 | Composite: &fnv1.Resource{ 513 | Resource: resource.MustStructJSON(xr), 514 | }, 515 | }, 516 | }, 517 | }, 518 | want: want{ 519 | rsp: &fnv1.RunFunctionResponse{ 520 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 521 | Results: []*fnv1.Result{ 522 | { 523 | Severity: fnv1.Severity_SEVERITY_FATAL, 524 | Message: "invalid function input: invalid \"" + annotationKeyReady + "\" annotation value \"wrongValue\": must be True, False, or Unspecified", 525 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 526 | }, 527 | }, 528 | Desired: &fnv1.State{ 529 | Composite: &fnv1.Resource{ 530 | Resource: resource.MustStructJSON(xr), 531 | }, 532 | }, 533 | }, 534 | }, 535 | }, 536 | "ReadyStatusAnnotation": { 537 | reason: "The Function should return desired composed resource with True ready state.", 538 | args: args{ 539 | req: &fnv1.RunFunctionRequest{ 540 | Input: resource.MustStructObject( 541 | &v1beta1.GoTemplate{ 542 | Source: v1beta1.InlineSource, 543 | Inline: &v1beta1.TemplateSourceInline{Template: cdWithReadyTrue}, 544 | }), 545 | Observed: &fnv1.State{ 546 | Composite: &fnv1.Resource{ 547 | Resource: resource.MustStructJSON(xr), 548 | }, 549 | Resources: map[string]*fnv1.Resource{ 550 | "cool-cd": { 551 | Resource: resource.MustStructJSON(cd), 552 | }, 553 | }, 554 | }, 555 | Desired: &fnv1.State{ 556 | Composite: &fnv1.Resource{ 557 | Resource: resource.MustStructJSON(xr), 558 | }, 559 | }, 560 | }, 561 | }, 562 | want: want{ 563 | rsp: &fnv1.RunFunctionResponse{ 564 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 565 | Desired: &fnv1.State{ 566 | Composite: &fnv1.Resource{ 567 | Resource: resource.MustStructJSON(xr), 568 | }, 569 | Resources: map[string]*fnv1.Resource{ 570 | "cool-cd": { 571 | Resource: resource.MustStructJSON(`{"apiVersion":"example.org/v1","kind":"CD","metadata":{"annotations":{},"name":"cool-cd"}}`), 572 | Ready: 1, 573 | }, 574 | }, 575 | }, 576 | }, 577 | }, 578 | }, 579 | "InvalidMetaKind": { 580 | reason: "The Function should return a fatal result if the meta kind is invalid.", 581 | args: args{ 582 | req: &fnv1.RunFunctionRequest{ 583 | Input: resource.MustStructObject( 584 | &v1beta1.GoTemplate{ 585 | Source: v1beta1.InlineSource, 586 | Inline: &v1beta1.TemplateSourceInline{Template: metaResourceInvalid}, 587 | }), 588 | Observed: &fnv1.State{ 589 | Composite: &fnv1.Resource{ 590 | Resource: resource.MustStructJSON(xr), 591 | }, 592 | }, 593 | Desired: &fnv1.State{ 594 | Composite: &fnv1.Resource{ 595 | Resource: resource.MustStructJSON(xr), 596 | }, 597 | }, 598 | }, 599 | }, 600 | want: want{ 601 | rsp: &fnv1.RunFunctionResponse{ 602 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 603 | Results: []*fnv1.Result{ 604 | { 605 | Severity: fnv1.Severity_SEVERITY_FATAL, 606 | Message: "invalid kind \"InvalidMeta\" for apiVersion \"" + metaApiVersion + "\" - must be one of CompositeConnectionDetails, Context or ExtraResources", 607 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 608 | }, 609 | }, 610 | Desired: &fnv1.State{ 611 | Composite: &fnv1.Resource{ 612 | Resource: resource.MustStructJSON(xr), 613 | }, 614 | }, 615 | }, 616 | }, 617 | }, 618 | "ClaimConditionsError": { 619 | reason: "The Function should return a fatal result if a reserved Condition is set.", 620 | args: args{ 621 | req: &fnv1.RunFunctionRequest{ 622 | Input: resource.MustStructObject( 623 | &v1beta1.GoTemplate{ 624 | Source: v1beta1.InlineSource, 625 | Inline: &v1beta1.TemplateSourceInline{Template: claimConditionsReservedKey}, 626 | }), 627 | Observed: &fnv1.State{ 628 | Composite: &fnv1.Resource{ 629 | Resource: resource.MustStructJSON(xr), 630 | }, 631 | }, 632 | Desired: &fnv1.State{ 633 | Composite: &fnv1.Resource{ 634 | Resource: resource.MustStructJSON(xr), 635 | }, 636 | }, 637 | }, 638 | }, 639 | want: want{ 640 | rsp: &fnv1.RunFunctionResponse{ 641 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 642 | Results: []*fnv1.Result{ 643 | { 644 | Severity: fnv1.Severity_SEVERITY_FATAL, 645 | Message: "cannot set ClaimCondition type: Ready is a reserved Crossplane Condition", 646 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 647 | }, 648 | }, 649 | Desired: &fnv1.State{ 650 | Composite: &fnv1.Resource{ 651 | Resource: resource.MustStructJSON(xr), 652 | }, 653 | }, 654 | }, 655 | }, 656 | }, 657 | "ClaimConditions": { 658 | reason: "The Function should correctly set ClaimConditions.", 659 | args: args{ 660 | req: &fnv1.RunFunctionRequest{ 661 | Input: resource.MustStructObject( 662 | &v1beta1.GoTemplate{ 663 | Source: v1beta1.InlineSource, 664 | Inline: &v1beta1.TemplateSourceInline{Template: claimConditions}, 665 | }), 666 | Observed: &fnv1.State{ 667 | Composite: &fnv1.Resource{ 668 | Resource: resource.MustStructJSON(xr), 669 | }, 670 | }, 671 | Desired: &fnv1.State{ 672 | Composite: &fnv1.Resource{ 673 | Resource: resource.MustStructJSON(xr), 674 | }, 675 | }, 676 | }, 677 | }, 678 | want: want{ 679 | rsp: &fnv1.RunFunctionResponse{ 680 | Conditions: []*fnv1.Condition{ 681 | { 682 | Type: "TestCondition", 683 | Status: fnv1.Status_STATUS_CONDITION_FALSE, 684 | Reason: "InstallFail", 685 | Message: ptr.To("failed to install"), 686 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 687 | }, 688 | { 689 | Type: "ConditionTrue", 690 | Status: fnv1.Status_STATUS_CONDITION_TRUE, 691 | Reason: "this condition is true", 692 | Message: ptr.To("we are true"), 693 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 694 | }, 695 | { 696 | Type: "DatabaseReady", 697 | Status: fnv1.Status_STATUS_CONDITION_TRUE, 698 | Reason: "Ready", 699 | Message: ptr.To("Database is ready"), 700 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 701 | }, 702 | }, 703 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 704 | Desired: &fnv1.State{ 705 | Composite: &fnv1.Resource{ 706 | Resource: resource.MustStructJSON(xr), 707 | }, 708 | }, 709 | }, 710 | }, 711 | }, 712 | "CompositeConnectionDetails": { 713 | reason: "The Function should return the desired composite with CompositeConnectionDetails.", 714 | args: args{ 715 | req: &fnv1.RunFunctionRequest{ 716 | Input: resource.MustStructObject( 717 | &v1beta1.GoTemplate{ 718 | Source: v1beta1.InlineSource, 719 | Inline: &v1beta1.TemplateSourceInline{Template: metaResourceConDet}, 720 | }), 721 | Observed: &fnv1.State{ 722 | Composite: &fnv1.Resource{ 723 | Resource: resource.MustStructJSON(xr), 724 | }, 725 | }, 726 | Desired: &fnv1.State{ 727 | Composite: &fnv1.Resource{ 728 | Resource: resource.MustStructJSON(xr), 729 | }, 730 | }, 731 | }, 732 | }, 733 | want: want{ 734 | rsp: &fnv1.RunFunctionResponse{ 735 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 736 | Desired: &fnv1.State{ 737 | Composite: &fnv1.Resource{ 738 | Resource: resource.MustStructJSON(xr), 739 | ConnectionDetails: map[string][]byte{"key": []byte("value")}, 740 | }, 741 | }, 742 | }, 743 | }, 744 | }, 745 | "ContextInvalidData": { 746 | reason: "The Function should return an error if he context data is invalid.", 747 | args: args{ 748 | req: &fnv1.RunFunctionRequest{ 749 | Input: resource.MustStructObject( 750 | &v1beta1.GoTemplate{ 751 | Source: v1beta1.InlineSource, 752 | Inline: &v1beta1.TemplateSourceInline{Template: metaResourceContextInvalid}, 753 | }), 754 | Observed: &fnv1.State{ 755 | Composite: &fnv1.Resource{ 756 | Resource: resource.MustStructJSON(xr), 757 | }, 758 | }, 759 | Desired: &fnv1.State{ 760 | Composite: &fnv1.Resource{ 761 | Resource: resource.MustStructJSON(xr), 762 | }, 763 | }, 764 | }, 765 | }, 766 | want: want{ 767 | rsp: &fnv1.RunFunctionResponse{ 768 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 769 | Desired: &fnv1.State{ 770 | Composite: &fnv1.Resource{ 771 | Resource: resource.MustStructJSON(xr), 772 | }, 773 | }, 774 | Results: []*fnv1.Result{ 775 | { 776 | Severity: fnv1.Severity_SEVERITY_FATAL, 777 | Message: "cannot get Contexts from input: cannot unmarshal value from JSON: json: cannot unmarshal number into Go value of type map[string]interface {}", 778 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 779 | }, 780 | }, 781 | }, 782 | }, 783 | }, 784 | "Context": { 785 | reason: "The Function should return the desired composite with updated context.", 786 | args: args{ 787 | req: &fnv1.RunFunctionRequest{ 788 | Input: resource.MustStructObject( 789 | &v1beta1.GoTemplate{ 790 | Source: v1beta1.InlineSource, 791 | Inline: &v1beta1.TemplateSourceInline{Template: metaResourceContext}, 792 | }), 793 | Observed: &fnv1.State{ 794 | Composite: &fnv1.Resource{ 795 | Resource: resource.MustStructJSON(xr), 796 | }, 797 | }, 798 | Desired: &fnv1.State{ 799 | Composite: &fnv1.Resource{ 800 | Resource: resource.MustStructJSON(xr), 801 | }, 802 | }, 803 | }, 804 | }, 805 | want: want{ 806 | rsp: &fnv1.RunFunctionResponse{ 807 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 808 | Desired: &fnv1.State{ 809 | Composite: &fnv1.Resource{ 810 | Resource: resource.MustStructJSON(xr), 811 | }, 812 | }, 813 | Context: resource.MustStructJSON( 814 | `{ 815 | "apiextensions.crossplane.io/environment": { 816 | "new": "value" 817 | } 818 | }`, 819 | ), 820 | }, 821 | }, 822 | }, 823 | "ExtraResources": { 824 | reason: "The Function should return the desired composite with extra resources.", 825 | args: args{ 826 | req: &fnv1.RunFunctionRequest{ 827 | Input: resource.MustStructObject( 828 | &v1beta1.GoTemplate{ 829 | Source: v1beta1.InlineSource, 830 | Inline: &v1beta1.TemplateSourceInline{Template: extraResources}, 831 | }), 832 | Observed: &fnv1.State{ 833 | Composite: &fnv1.Resource{ 834 | Resource: resource.MustStructJSON(xr), 835 | }, 836 | }, 837 | Desired: &fnv1.State{ 838 | Composite: &fnv1.Resource{ 839 | Resource: resource.MustStructJSON(xr), 840 | }, 841 | Resources: map[string]*fnv1.Resource{ 842 | "cool-cd": { 843 | Resource: resource.MustStructJSON(cd), 844 | }, 845 | }, 846 | }, 847 | }, 848 | }, 849 | want: want{ 850 | rsp: &fnv1.RunFunctionResponse{ 851 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 852 | Results: []*fnv1.Result{}, 853 | Requirements: &fnv1.Requirements{ 854 | ExtraResources: map[string]*fnv1.ResourceSelector{ 855 | "cool-extra-resource": { 856 | ApiVersion: "example.org/v1", 857 | Kind: "CoolExtraResource", 858 | Match: &fnv1.ResourceSelector_MatchName{ 859 | MatchName: "cool-extra-resource", 860 | }, 861 | }, 862 | "another-cool-extra-resource": { 863 | ApiVersion: "example.org/v1", 864 | Kind: "CoolExtraResource", 865 | Match: &fnv1.ResourceSelector_MatchLabels{ 866 | MatchLabels: &fnv1.MatchLabels{ 867 | Labels: map[string]string{"key": "value"}, 868 | }, 869 | }, 870 | }, 871 | "yet-another-cool-extra-resource": { 872 | ApiVersion: "example.org/v1", 873 | Kind: "CoolExtraResource", 874 | Match: &fnv1.ResourceSelector_MatchName{ 875 | MatchName: "foo", 876 | }, 877 | }, 878 | "all-cool-resources": { 879 | ApiVersion: "example.org/v1", 880 | Kind: "CoolExtraResource", 881 | Match: &fnv1.ResourceSelector_MatchLabels{ 882 | MatchLabels: &fnv1.MatchLabels{ 883 | Labels: map[string]string{}, 884 | }, 885 | }, 886 | }, 887 | }, 888 | }, 889 | Desired: &fnv1.State{ 890 | Composite: &fnv1.Resource{ 891 | Resource: resource.MustStructJSON(xr), 892 | }, 893 | Resources: map[string]*fnv1.Resource{ 894 | "cool-cd": { 895 | Resource: resource.MustStructJSON(cd), 896 | }, 897 | }, 898 | }, 899 | }, 900 | }, 901 | }, 902 | "DuplicateExtraResourceKey": { 903 | reason: "The Function should return a fatal result if the extra resource key is duplicated.", 904 | args: args{ 905 | req: &fnv1.RunFunctionRequest{ 906 | Input: resource.MustStructObject( 907 | &v1beta1.GoTemplate{ 908 | Source: v1beta1.InlineSource, 909 | Inline: &v1beta1.TemplateSourceInline{Template: extraResourcesDuplicatedKey}, 910 | }), 911 | Observed: &fnv1.State{ 912 | Composite: &fnv1.Resource{ 913 | Resource: resource.MustStructJSON(xr), 914 | }, 915 | }, 916 | Desired: &fnv1.State{ 917 | Composite: &fnv1.Resource{ 918 | Resource: resource.MustStructJSON(xr), 919 | }, 920 | Resources: map[string]*fnv1.Resource{ 921 | "cool-cd": { 922 | Resource: resource.MustStructJSON(cd), 923 | }, 924 | }, 925 | }, 926 | }, 927 | }, 928 | want: want{ 929 | rsp: &fnv1.RunFunctionResponse{ 930 | Meta: &fnv1.ResponseMeta{Ttl: durationpb.New(response.DefaultTTL)}, 931 | Results: []*fnv1.Result{ 932 | { 933 | Severity: fnv1.Severity_SEVERITY_FATAL, 934 | Message: "duplicate extra resource key \"cool-extra-resource\"", 935 | Target: fnv1.Target_TARGET_COMPOSITE.Enum(), 936 | }, 937 | }, 938 | Desired: &fnv1.State{ 939 | Composite: &fnv1.Resource{ 940 | Resource: resource.MustStructJSON(xr), 941 | }, 942 | Resources: map[string]*fnv1.Resource{ 943 | "cool-cd": { 944 | Resource: resource.MustStructJSON(cd), 945 | }, 946 | }, 947 | }, 948 | }, 949 | }, 950 | }, 951 | } 952 | 953 | for name, tc := range cases { 954 | t.Run(name, func(t *testing.T) { 955 | f := &Function{ 956 | log: logging.NewNopLogger(), 957 | fsys: testdataFS, 958 | } 959 | rsp, err := f.RunFunction(tc.args.ctx, tc.args.req) 960 | 961 | if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { 962 | t.Errorf("%s\nf.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 963 | } 964 | 965 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 966 | t.Errorf("%s\nf.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) 967 | } 968 | }) 969 | } 970 | } 971 | -------------------------------------------------------------------------------- /function_maps.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "math/rand" 6 | "strings" 7 | "text/template" 8 | "time" 9 | 10 | sprig "github.com/Masterminds/sprig/v3" 11 | "github.com/crossplane-contrib/function-go-templating/input/v1beta1" 12 | xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 13 | "github.com/crossplane/crossplane-runtime/pkg/fieldpath" 14 | "github.com/crossplane/function-sdk-go/errors" 15 | "gopkg.in/yaml.v3" 16 | ) 17 | 18 | const recursionMaxNums = 1000 19 | 20 | var funcMaps = []template.FuncMap{ 21 | { 22 | "randomChoice": randomChoice, 23 | "toYaml": toYaml, 24 | "fromYaml": fromYaml, 25 | "getResourceCondition": getResourceCondition, 26 | "setResourceNameAnnotation": setResourceNameAnnotation, 27 | "getComposedResource": getComposedResource, 28 | "getCompositeResource": getCompositeResource, 29 | }, 30 | } 31 | 32 | func GetNewTemplateWithFunctionMaps(delims *v1beta1.Delims) *template.Template { 33 | tpl := template.New("manifests") 34 | 35 | if delims != nil { 36 | if delims.Left != nil && delims.Right != nil { 37 | tpl = tpl.Delims(*delims.Left, *delims.Right) 38 | } 39 | } 40 | 41 | for _, f := range funcMaps { 42 | tpl.Funcs(f) 43 | } 44 | tpl.Funcs(template.FuncMap{ 45 | "include": initInclude(tpl), 46 | }) 47 | // Sprig's env and expandenv can lead to information leakage (injected tokens/passwords). 48 | // Both Helm and ArgoCD remove these due to security implications. 49 | // see: https://masterminds.github.io/sprig/os.html 50 | sprigFuncs := sprig.FuncMap() 51 | delete(sprigFuncs, "env") 52 | delete(sprigFuncs, "expandenv") 53 | tpl.Funcs(sprigFuncs) 54 | 55 | return tpl 56 | } 57 | 58 | func randomChoice(choices ...string) string { 59 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 60 | 61 | return choices[r.Intn(len(choices))] 62 | } 63 | 64 | func toYaml(val any) (string, error) { 65 | res, err := yaml.Marshal(val) 66 | if err != nil { 67 | return "", err 68 | } 69 | 70 | return string(res), nil 71 | } 72 | 73 | func fromYaml(val string) (any, error) { 74 | var res any 75 | err := yaml.Unmarshal([]byte(val), &res) 76 | 77 | return res, err 78 | } 79 | 80 | func getResourceCondition(ct string, res map[string]any) xpv1.Condition { 81 | var conditioned xpv1.ConditionedStatus 82 | if err := fieldpath.Pave(res).GetValueInto("resource.status", &conditioned); err != nil { 83 | conditioned = xpv1.ConditionedStatus{} 84 | } 85 | 86 | // Return either found condition or empty one with "Unknown" status 87 | return conditioned.GetCondition(xpv1.ConditionType(ct)) 88 | } 89 | 90 | func setResourceNameAnnotation(name string) string { 91 | return fmt.Sprintf("gotemplating.fn.crossplane.io/composition-resource-name: %s", name) 92 | } 93 | 94 | func initInclude(t *template.Template) func(string, interface{}) (string, error) { 95 | 96 | includedNames := make(map[string]int) 97 | 98 | return func(name string, data interface{}) (string, error) { 99 | var buf strings.Builder 100 | if v, ok := includedNames[name]; ok { 101 | if v > recursionMaxNums { 102 | return "", errors.Wrapf(fmt.Errorf("unable to execute template"), "rendering template has a nested reference name: %s", name) 103 | } 104 | includedNames[name]++ 105 | } else { 106 | includedNames[name] = 1 107 | } 108 | err := t.ExecuteTemplate(&buf, name, data) 109 | includedNames[name]-- 110 | return buf.String(), err 111 | } 112 | 113 | } 114 | 115 | func getComposedResource(req map[string]any, name string) map[string]any { 116 | var cr map[string]any 117 | path := fmt.Sprintf("observed.resources[%s]resource", name) 118 | if err := fieldpath.Pave(req).GetValueInto(path, &cr); err != nil { 119 | return nil 120 | } 121 | 122 | return cr 123 | } 124 | 125 | func getCompositeResource(req map[string]any) map[string]any { 126 | var cr map[string]any 127 | if err := fieldpath.Pave(req).GetValueInto("observed.composite.resource", &cr); err != nil { 128 | return nil 129 | } 130 | 131 | return cr 132 | } 133 | -------------------------------------------------------------------------------- /function_maps_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | "text/template" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/google/go-cmp/cmp/cmpopts" 10 | "google.golang.org/protobuf/testing/protocmp" 11 | 12 | v1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 13 | ) 14 | 15 | func Test_fromYaml(t *testing.T) { 16 | type args struct { 17 | val string 18 | } 19 | type want struct { 20 | rsp any 21 | err error 22 | } 23 | cases := map[string]struct { 24 | reason string 25 | args args 26 | want want 27 | }{ 28 | "UnmarshalYaml": { 29 | reason: "Should return unmarshalled yaml", 30 | args: args{ 31 | val: ` 32 | complexDictionary: 33 | scalar1: true 34 | list: 35 | - abc 36 | - def`, 37 | }, 38 | want: want{ 39 | rsp: map[string]interface{}{ 40 | "complexDictionary": map[string]interface{}{ 41 | "scalar1": true, 42 | "list": []interface{}{ 43 | "abc", 44 | "def", 45 | }, 46 | }, 47 | }, 48 | }, 49 | }, 50 | "UnmarshalYamlError": { 51 | reason: "Should return error when unmarshalling yaml", 52 | args: args{ 53 | val: ` 54 | complexDictionary: 55 | scalar1: true 56 | `, 57 | }, 58 | want: want{ 59 | err: cmpopts.AnyError, 60 | }, 61 | }, 62 | } 63 | for name, tc := range cases { 64 | t.Run(name, func(t *testing.T) { 65 | rsp, err := fromYaml(tc.args.val) 66 | 67 | if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { 68 | t.Errorf("%s\nfromYaml(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 69 | } 70 | 71 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 72 | t.Errorf("%s\nfromYaml(...): -want err, +got err:\n%s", tc.reason, diff) 73 | } 74 | }) 75 | } 76 | } 77 | 78 | func Test_toYaml(t *testing.T) { 79 | type args struct { 80 | val any 81 | } 82 | type want struct { 83 | rsp any 84 | err error 85 | } 86 | cases := map[string]struct { 87 | reason string 88 | args args 89 | want want 90 | }{ 91 | "MarshalYaml": { 92 | reason: "Should return marshalled yaml", 93 | args: args{ 94 | val: map[string]interface{}{ 95 | "complexDictionary": map[string]interface{}{ 96 | "scalar1": true, 97 | "list": []interface{}{ 98 | "abc", 99 | "def", 100 | }, 101 | }, 102 | }, 103 | }, 104 | want: want{ 105 | rsp: `complexDictionary: 106 | list: 107 | - abc 108 | - def 109 | scalar1: true 110 | `, 111 | }, 112 | }, 113 | } 114 | for name, tc := range cases { 115 | t.Run(name, func(t *testing.T) { 116 | rsp, err := toYaml(tc.args.val) 117 | 118 | if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { 119 | t.Errorf("%s\ntoYaml(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 120 | } 121 | 122 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 123 | t.Errorf("%s\ntoYaml(...): -want err, +got err:\n%s", tc.reason, diff) 124 | } 125 | }) 126 | } 127 | } 128 | 129 | func Test_getResourceCondition(t *testing.T) { 130 | type args struct { 131 | ct string 132 | res map[string]any 133 | } 134 | 135 | type want struct { 136 | rsp v1.Condition 137 | } 138 | cases := map[string]struct { 139 | reason string 140 | args args 141 | want want 142 | }{ 143 | "GetCondition": { 144 | reason: "Should return condition", 145 | args: args{ 146 | ct: "Ready", 147 | res: map[string]any{ 148 | "resource": map[string]any{ 149 | "status": map[string]any{ 150 | "conditions": []any{ 151 | map[string]any{ 152 | "type": "Ready", 153 | "status": "True", 154 | }, 155 | }, 156 | }, 157 | }, 158 | }, 159 | }, 160 | want: want{ 161 | rsp: v1.Condition{ 162 | Type: "Ready", 163 | Status: "True", 164 | }, 165 | }, 166 | }, 167 | "GetConditionUnknown": { 168 | reason: "Should return an Unknown status", 169 | args: args{ 170 | ct: "Ready", 171 | res: map[string]any{ 172 | "resource": map[string]any{}, 173 | }, 174 | }, 175 | want: want{ 176 | rsp: v1.Condition{ 177 | Type: "Ready", 178 | Status: "Unknown", 179 | }, 180 | }, 181 | }, 182 | "GetConditionNotFound": { 183 | reason: "Should return an Unknown condition when not found", 184 | args: args{ 185 | ct: "Ready", 186 | res: map[string]any{ 187 | "resource": map[string]any{ 188 | "status": map[string]any{ 189 | "conditions": []any{ 190 | map[string]any{ 191 | "type": "NotReady", 192 | "status": "True", 193 | }, 194 | }, 195 | }, 196 | }, 197 | }, 198 | }, 199 | want: want{ 200 | rsp: v1.Condition{ 201 | Type: "Ready", 202 | Status: "Unknown", 203 | }, 204 | }, 205 | }, 206 | } 207 | for name, tc := range cases { 208 | t.Run(name, func(t *testing.T) { 209 | rsp := getResourceCondition(tc.args.ct, tc.args.res) 210 | 211 | if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { 212 | t.Errorf("%s\ngetResourceCondition(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 213 | } 214 | }) 215 | } 216 | } 217 | 218 | func Test_setResourceNameAnnotation(t *testing.T) { 219 | type args struct { 220 | name string 221 | } 222 | type want struct { 223 | rsp string 224 | } 225 | 226 | cases := map[string]struct { 227 | reason string 228 | args args 229 | want want 230 | }{ 231 | "SetAnnotationWithGivenName": { 232 | reason: "Should return composition resource name annotation with given name", 233 | args: args{ 234 | name: "test", 235 | }, 236 | want: want{ 237 | rsp: "gotemplating.fn.crossplane.io/composition-resource-name: test", 238 | }, 239 | }, 240 | } 241 | for name, tc := range cases { 242 | t.Run(name, func(t *testing.T) { 243 | rsp := setResourceNameAnnotation(tc.args.name) 244 | 245 | if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { 246 | t.Errorf("%s\nsetResourceNameAnnotation(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 247 | } 248 | }) 249 | } 250 | } 251 | 252 | func Test_include(t *testing.T) { 253 | type args struct { 254 | val string 255 | } 256 | type want struct { 257 | rsp string 258 | err error 259 | } 260 | cases := map[string]struct { 261 | reason string 262 | args args 263 | want want 264 | }{ 265 | "ExecTemplate": { 266 | reason: "Should return the executed template", 267 | args: args{ 268 | val: ` 269 | {{- define "test-template" -}} 270 | value: {{.}} 271 | {{- end }} 272 | {{- $var:= include "test-template" "val" -}} 273 | Must capture output: {{$var}}`, 274 | }, 275 | want: want{ 276 | rsp: `Must capture output: value: val`, 277 | }, 278 | }, 279 | "TemplateErrorCtxNotSet": { 280 | reason: "Should return error if ctx not set", 281 | args: args{ 282 | val: ` 283 | {{- define "test-template" -}} 284 | value: {{.}} 285 | {{- end }} 286 | {{- $var:= include "test-template" -}} 287 | Must capture output: {{$var}} 288 | `, 289 | }, 290 | want: want{ 291 | err: cmpopts.AnyError, 292 | }, 293 | }, 294 | "TemplateErrorTemplateNameNotSet": { 295 | reason: "Should return error if template name not set", 296 | args: args{ 297 | val: ` 298 | {{- define "test-template" -}} 299 | value: {{.}} 300 | {{- end }} 301 | {{- $var:= include -}} 302 | Must capture output: {{$var}} 303 | `, 304 | }, 305 | want: want{ 306 | err: cmpopts.AnyError, 307 | }, 308 | }, 309 | } 310 | 311 | tpl := template.New("") 312 | tpl.Funcs(template.FuncMap{ 313 | "include": initInclude(tpl), 314 | }) 315 | 316 | for name, tc := range cases { 317 | _tpl := template.Must(tpl.Parse(tc.args.val)) 318 | t.Run(name, func(t *testing.T) { 319 | rsp := &bytes.Buffer{} 320 | err := _tpl.Execute(rsp, nil) 321 | if diff := cmp.Diff(tc.want.rsp, rsp.String(), protocmp.Transform()); diff != "" { 322 | t.Errorf("%s\nfromYaml(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 323 | } 324 | 325 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 326 | t.Errorf("%s\nfromYaml(...): -want err, +got err:\n%s", tc.reason, diff) 327 | } 328 | }) 329 | } 330 | } 331 | 332 | func Test_getComposedResource(t *testing.T) { 333 | type args struct { 334 | req map[string]any 335 | name string 336 | } 337 | 338 | type want struct { 339 | rsp map[string]any 340 | } 341 | 342 | completeResource := map[string]any{ 343 | "apiVersion": "dbforpostgresql.azure.upbound.io/v1beta1", 344 | "kind": "FlexibleServer", 345 | "spec": map[string]any{ 346 | "forProvider": map[string]any{ 347 | "storageMb": "32768", 348 | }, 349 | }, 350 | "status": map[string]any{ 351 | "atProvider": map[string]any{ 352 | "id": "abcdef", 353 | }, 354 | }, 355 | } 356 | 357 | cases := map[string]struct { 358 | reason string 359 | args args 360 | want want 361 | }{ 362 | "RetrieveCompleteResource": { 363 | reason: "Should successfully retrieve the complete resource", 364 | args: args{ 365 | req: map[string]any{ 366 | "observed": map[string]any{ 367 | "resources": map[string]any{ 368 | "flexserver": map[string]any{ 369 | "resource": completeResource, 370 | }, 371 | }, 372 | }, 373 | }, 374 | name: "flexserver", 375 | }, 376 | want: want{rsp: completeResource}, 377 | }, 378 | "RetrieveCompleteResourceWithDots": { 379 | reason: "Should successfully retrieve the complete resource when identifier contains dots", 380 | args: args{ 381 | req: map[string]any{ 382 | "observed": map[string]any{ 383 | "resources": map[string]any{ 384 | "flex.server": map[string]any{ 385 | "resource": completeResource, 386 | }, 387 | }, 388 | }, 389 | }, 390 | name: "flex.server", 391 | }, 392 | want: want{rsp: completeResource}, 393 | }, 394 | "ResourceNotFound": { 395 | reason: "Should return nil if the resource is not found", 396 | args: args{ 397 | req: map[string]any{ 398 | "observed": map[string]any{ 399 | "resources": map[string]any{}, 400 | }, 401 | }, 402 | name: "missingResource", 403 | }, 404 | want: want{rsp: nil}, 405 | }, 406 | } 407 | 408 | for name, tc := range cases { 409 | t.Run(name, func(t *testing.T) { 410 | got := getComposedResource(tc.args.req, tc.args.name) 411 | if diff := cmp.Diff(tc.want.rsp, got); diff != "" { 412 | t.Errorf("%s\ngetComposedResource(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 413 | } 414 | }) 415 | } 416 | } 417 | 418 | func Test_getCompositeResource(t *testing.T) { 419 | type args struct { 420 | req map[string]any 421 | } 422 | 423 | type want struct { 424 | rsp map[string]any 425 | } 426 | 427 | compositeResource := map[string]any{ 428 | "apiVersion": "example.crossplane.io/v1beta1", 429 | "kind": "XR", 430 | "metadata": map[string]any{ 431 | "name": "example", 432 | }, 433 | "spec": map[string]any{ 434 | "key": "value", 435 | }, 436 | } 437 | 438 | cases := map[string]struct { 439 | reason string 440 | args args 441 | want want 442 | }{ 443 | "RetrieveCompositeResource": { 444 | reason: "Should successfully retrieve the composite resource", 445 | args: args{ 446 | req: map[string]any{ 447 | "observed": map[string]any{ 448 | "composite": map[string]any{ 449 | "resource": compositeResource, 450 | }, 451 | }, 452 | }, 453 | }, 454 | want: want{rsp: compositeResource}, 455 | }, 456 | "ResourceNotFound": { 457 | reason: "Should return nil if the composite resource is not found", 458 | args: args{ 459 | req: map[string]any{ 460 | "observed": map[string]any{ 461 | "composite": map[string]any{}, 462 | }, 463 | }, 464 | }, 465 | want: want{rsp: nil}, 466 | }, 467 | } 468 | 469 | for name, tc := range cases { 470 | t.Run(name, func(t *testing.T) { 471 | got := getCompositeResource(tc.args.req) 472 | if diff := cmp.Diff(tc.want.rsp, got); diff != "" { 473 | t.Errorf("%s\ngetCompositeResource(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 474 | } 475 | }) 476 | } 477 | } 478 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/crossplane-contrib/function-go-templating 2 | 3 | go 1.23.8 4 | 5 | require ( 6 | dario.cat/mergo v1.0.1 7 | github.com/Masterminds/sprig/v3 v3.3.0 8 | github.com/alecthomas/kong v0.9.0 9 | github.com/crossplane/crossplane-runtime v1.18.0 10 | github.com/crossplane/function-sdk-go v0.4.0 11 | github.com/google/go-cmp v0.6.0 12 | google.golang.org/protobuf v1.34.3-0.20240816073751-94ecbc261689 13 | gopkg.in/yaml.v3 v3.0.1 14 | k8s.io/api v0.31.0 15 | k8s.io/apimachinery v0.31.0 16 | k8s.io/utils v0.0.0-20241210054802-24370beab758 17 | sigs.k8s.io/controller-tools v0.16.0 18 | ) 19 | 20 | require ( 21 | github.com/Masterminds/goutils v1.1.1 // indirect 22 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 23 | github.com/alecthomas/assert/v2 v2.11.0 // indirect 24 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 25 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 26 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 27 | github.com/fatih/color v1.17.0 // indirect 28 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 29 | github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 // indirect 30 | github.com/go-logr/logr v1.4.2 // indirect 31 | github.com/go-logr/zapr v1.3.0 // indirect 32 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 33 | github.com/go-openapi/jsonreference v0.20.2 // indirect 34 | github.com/go-openapi/swag v0.22.4 // indirect 35 | github.com/gobuffalo/flect v1.0.2 // indirect 36 | github.com/gogo/protobuf v1.3.2 // indirect 37 | github.com/golang/protobuf v1.5.4 // indirect 38 | github.com/google/gnostic-models v0.6.8 // indirect 39 | github.com/google/gofuzz v1.2.0 // indirect 40 | github.com/google/uuid v1.6.0 // indirect 41 | github.com/huandu/xstrings v1.5.0 // indirect 42 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 43 | github.com/josharian/intern v1.0.0 // indirect 44 | github.com/json-iterator/go v1.1.12 // indirect 45 | github.com/mailru/easyjson v0.7.7 // indirect 46 | github.com/mattn/go-colorable v0.1.13 // indirect 47 | github.com/mattn/go-isatty v0.0.20 // indirect 48 | github.com/mitchellh/copystructure v1.2.0 // indirect 49 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 50 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 51 | github.com/modern-go/reflect2 v1.0.2 // indirect 52 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 53 | github.com/pkg/errors v0.9.1 // indirect 54 | github.com/rogpeppe/go-internal v1.13.1 // indirect 55 | github.com/shopspring/decimal v1.4.0 // indirect 56 | github.com/spf13/afero v1.11.0 // indirect 57 | github.com/spf13/cast v1.7.0 // indirect 58 | github.com/spf13/cobra v1.8.1 // indirect 59 | github.com/spf13/pflag v1.0.5 // indirect 60 | github.com/x448/float16 v0.8.4 // indirect 61 | go.uber.org/multierr v1.11.0 // indirect 62 | go.uber.org/zap v1.27.0 // indirect 63 | golang.org/x/crypto v0.36.0 // indirect 64 | golang.org/x/mod v0.21.0 // indirect 65 | golang.org/x/net v0.38.0 // indirect 66 | golang.org/x/oauth2 v0.22.0 // indirect 67 | golang.org/x/sync v0.12.0 // indirect 68 | golang.org/x/sys v0.31.0 // indirect 69 | golang.org/x/term v0.30.0 // indirect 70 | golang.org/x/text v0.23.0 // indirect 71 | golang.org/x/time v0.5.0 // indirect 72 | golang.org/x/tools v0.25.0 // indirect 73 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect 74 | google.golang.org/grpc v1.67.0 // indirect 75 | gopkg.in/inf.v0 v0.9.1 // indirect 76 | gopkg.in/yaml.v2 v2.4.0 // indirect 77 | k8s.io/apiextensions-apiserver v0.31.0 // indirect 78 | k8s.io/client-go v0.31.0 // indirect 79 | k8s.io/klog/v2 v2.130.1 // indirect 80 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 81 | sigs.k8s.io/controller-runtime v0.19.0 // indirect 82 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 83 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 84 | sigs.k8s.io/yaml v1.4.0 // indirect 85 | ) 86 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 4 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 5 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 6 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 7 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 8 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 9 | github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7lmo= 10 | github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558= 11 | github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0= 12 | github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k= 13 | github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= 14 | github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= 15 | github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc= 16 | github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4= 17 | github.com/antchfx/htmlquery v1.2.4 h1:qLteofCMe/KGovBI6SQgmou2QNyedFUW+pE+BpeZ494= 18 | github.com/antchfx/htmlquery v1.2.4/go.mod h1:2xO6iu3EVWs7R2JYqBbp8YzG50gj/ofqs5/0VZoDZLc= 19 | github.com/antchfx/xpath v1.2.0 h1:mbwv7co+x0RwgeGAOHdrKy89GvHaGvxxBtPK0uF9Zr8= 20 | github.com/antchfx/xpath v1.2.0/go.mod h1:i54GszH55fYfBmoZXapTHN8T8tkcHfRgLyVwwqzXNcs= 21 | github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= 22 | github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= 23 | github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= 24 | github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 25 | github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= 26 | github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= 27 | github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 28 | github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 29 | github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 30 | github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 31 | github.com/crossplane/crossplane-runtime v1.18.0 h1:aAQIMNOgPbbXaqj9CUSv+gPl3QnVbn33YlzSe145//0= 32 | github.com/crossplane/crossplane-runtime v1.18.0/go.mod h1:p7nVVsLn0CWjsLvLCtr7T40ErbTgNWKRxmYnwFdfXb4= 33 | github.com/crossplane/function-sdk-go v0.4.0 h1:1jd+UIaZlVNQCUO4hLAgUqWBRnUKw2ObF9ZuMw5CpKk= 34 | github.com/crossplane/function-sdk-go v0.4.0/go.mod h1:jLnzUG8pt8tn/U6/uvtNStAhDjhIq4wCR31yECT54NM= 35 | github.com/crossplane/upjet v1.4.1-0.20240911184956-3afbb7796d46 h1:2IH1YPTBrNmBj0Z1OCjEBTrQCuRaLutZbWLaswFeCFQ= 36 | github.com/crossplane/upjet v1.4.1-0.20240911184956-3afbb7796d46/go.mod h1:wkdZf/Cvhr6PI30VdHIOjg4dX39Z5uijqnLWFk5PbGM= 37 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 38 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 39 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 40 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 41 | github.com/emicklei/go-restful/v3 v3.11.0 h1:rAQeMHw1c7zTmncogyy8VvRZwtkmkZ4FxERmMY4rD+g= 42 | github.com/emicklei/go-restful/v3 v3.11.0/go.mod h1:6n3XBCmQQb25CM2LCACGz8ukIrRry+4bhvbpWn3mrbc= 43 | github.com/evanphx/json-patch v5.9.0+incompatible h1:fBXyNpNMuTTDdquAq/uisOr2lShz4oaXpDTX2bLe7ls= 44 | github.com/evanphx/json-patch v5.9.0+incompatible/go.mod h1:50XU6AFN0ol/bzJsmQLiYLvXMP4fmwYFNcr97nuDLSk= 45 | github.com/evanphx/json-patch/v5 v5.9.0 h1:kcBlZQbplgElYIlo/n1hJbls2z/1awpXxpRi0/FOJfg= 46 | github.com/evanphx/json-patch/v5 v5.9.0/go.mod h1:VNkHZ/282BpEyt/tObQO8s5CMPmYYq14uClGH4abBuQ= 47 | github.com/fatih/camelcase v1.0.0 h1:hxNvNX/xYBp0ovncs8WyWZrOrpBNub/JfaMvbURyft8= 48 | github.com/fatih/camelcase v1.0.0/go.mod h1:yN2Sb0lFhZJUdVvtELVWefmrXpuZESvPmqwoZc+/fpc= 49 | github.com/fatih/color v1.17.0 h1:GlRw1BRJxkpqUCBKzKOw098ed57fEsKeNjpTe3cSjK4= 50 | github.com/fatih/color v1.17.0/go.mod h1:YZ7TlrGPkiz6ku9fK3TLD/pl3CpsiFyu8N92HLgmosI= 51 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 52 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 53 | github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA= 54 | github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM= 55 | github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E= 56 | github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ= 57 | github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 h1:xcuWappghOVI8iNWoF2OKahVejd1LSVi/v4JED44Amo= 58 | github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1/go.mod h1:BWmvoE1Xia34f3l/ibJweyhrT+aROb/FQ6d+37F0e2s= 59 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 60 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 61 | github.com/go-logr/zapr v1.3.0 h1:XGdV8XW8zdwFiwOA2Dryh1gj2KRQyOOoNmBy4EplIcQ= 62 | github.com/go-logr/zapr v1.3.0/go.mod h1:YKepepNBd1u/oyhd/yQmtjVXmm9uML4IXUgMOwR8/Gg= 63 | github.com/go-openapi/jsonpointer v0.19.6 h1:eCs3fxoIi3Wh6vtgmLTOjdhSpiqphQ+DaPn38N2ZdrE= 64 | github.com/go-openapi/jsonpointer v0.19.6/go.mod h1:osyAmYz/mB/C3I+WsTTSgw1ONzaLJoLCyoi6/zppojs= 65 | github.com/go-openapi/jsonreference v0.20.2 h1:3sVjiK66+uXK/6oQ8xgcRKcFgQ5KXa2KvnJRumpMGbE= 66 | github.com/go-openapi/jsonreference v0.20.2/go.mod h1:Bl1zwGIM8/wsvqjsOQLJ/SH+En5Ap4rVB5KVcIDZG2k= 67 | github.com/go-openapi/swag v0.22.3/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 68 | github.com/go-openapi/swag v0.22.4 h1:QLMzNJnMGPRNDCbySlcj1x01tzU8/9LTTL9hZZZogBU= 69 | github.com/go-openapi/swag v0.22.4/go.mod h1:UzaqsxGiab7freDnrUUra0MwWfN/q7tE4j+VcZ0yl14= 70 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 71 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 72 | github.com/gobuffalo/flect v1.0.2 h1:eqjPGSo2WmjgY2XlpGwo2NXgL3RucAKo4k4qQMNA5sA= 73 | github.com/gobuffalo/flect v1.0.2/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs= 74 | github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= 75 | github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= 76 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= 77 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= 78 | github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= 79 | github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= 80 | github.com/google/gnostic-models v0.6.8 h1:yo/ABAfM5IMRsS1VnXjTBvUb61tFIHozhlYvRgGre9I= 81 | github.com/google/gnostic-models v0.6.8/go.mod h1:5n7qKqH0f5wFt+aWF8CW6pZLLNOfYuF5OpfBSENuI8U= 82 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 83 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 84 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 85 | github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 86 | github.com/google/gofuzz v1.2.0 h1:xRy4A+RhZaiKjJ1bPfwQ8sedCA+YS2YcCHW6ec7JMi0= 87 | github.com/google/gofuzz v1.2.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= 88 | github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 h1:c5FlPPgxOn7kJz3VoPLkQYQXGBS3EklQ4Zfi57uOuqQ= 89 | github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 90 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 91 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 92 | github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 h1:Ud/6/AdmJ1R7ibdS0Wo5MWPj0T1R0fkpaD087bBaW8I= 93 | github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637/go.mod h1:EiZBMaudVLy8fmjf9Npq1dq9RalhveqZG5w/yz3mHWs= 94 | github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 95 | github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= 96 | github.com/hashicorp/go-uuid v1.0.3 h1:2gKiV6YVmrJ1i2CKKa9obLvRieoRGviZFL26PcT/Co8= 97 | github.com/hashicorp/go-uuid v1.0.3/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro= 98 | github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= 99 | github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= 100 | github.com/hashicorp/hcl/v2 v2.21.0 h1:lve4q/o/2rqwYOgUg3y3V2YPyD1/zkCLGjIV74Jit14= 101 | github.com/hashicorp/hcl/v2 v2.21.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA= 102 | github.com/hashicorp/logutils v1.0.0 h1:dLEQVugN8vlakKOUE3ihGLTZJRB4j+M2cdTm/ORI65Y= 103 | github.com/hashicorp/logutils v1.0.0/go.mod h1:QIAnNjmIWmVIIkWDTG1z5v++HQmx9WQRO+LraFDTW64= 104 | github.com/hashicorp/terraform-json v0.22.1 h1:xft84GZR0QzjPVWs4lRUwvTcPnegqlyS7orfb5Ltvec= 105 | github.com/hashicorp/terraform-json v0.22.1/go.mod h1:JbWSQCLFSXFFhg42T7l9iJwdGXBYV8fmmD6o/ML4p3A= 106 | github.com/hashicorp/terraform-plugin-framework v1.10.0 h1:xXhICE2Fns1RYZxEQebwkB2+kXouLC932Li9qelozrc= 107 | github.com/hashicorp/terraform-plugin-framework v1.10.0/go.mod h1:qBXLDn69kM97NNVi/MQ9qgd1uWWsVftGSnygYG1tImM= 108 | github.com/hashicorp/terraform-plugin-go v0.23.0 h1:AALVuU1gD1kPb48aPQUjug9Ir/125t+AAurhqphJ2Co= 109 | github.com/hashicorp/terraform-plugin-go v0.23.0/go.mod h1:1E3Cr9h2vMlahWMbsSEcNrOCxovCZhOOIXjFHbjc/lQ= 110 | github.com/hashicorp/terraform-plugin-log v0.9.0 h1:i7hOA+vdAItN1/7UrfBqBwvYPQ9TFvymaRGZED3FCV0= 111 | github.com/hashicorp/terraform-plugin-log v0.9.0/go.mod h1:rKL8egZQ/eXSyDqzLUuwUYLVdlYeamldAHSxjUFADow= 112 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 h1:kJiWGx2kiQVo97Y5IOGR4EMcZ8DtMswHhUuFibsCQQE= 113 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0/go.mod h1:sl/UoabMc37HA6ICVMmGO+/0wofkVIRxf+BMb/dnoIg= 114 | github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM= 115 | github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg= 116 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 117 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 118 | github.com/iancoleman/strcase v0.2.0 h1:05I4QRnGpI0m37iZQRuskXh+w77mr6Z41lwQzuHLwW0= 119 | github.com/iancoleman/strcase v0.2.0/go.mod h1:iwCmte+B7n89clKwxIoIXy/HfoL7AsD47ZCWhYzw7ho= 120 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 121 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 122 | github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= 123 | github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 124 | github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= 125 | github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= 126 | github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= 127 | github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= 128 | github.com/kisielk/errcheck v1.5.0/go.mod h1:pFxgyoBC7bSaBwPgfKdkLd5X25qrDl4LWUI2bnpBCr8= 129 | github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck= 130 | github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= 131 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 132 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 133 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 134 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 135 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 136 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 137 | github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= 138 | github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= 139 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 140 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 141 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 142 | github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= 143 | github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 144 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 145 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 146 | github.com/mitchellh/go-testing-interface v1.14.1 h1:jrgshOhYAUVNMAJiKbEu7EqAwgJJ2JqpQmpLJOu07cU= 147 | github.com/mitchellh/go-testing-interface v1.14.1/go.mod h1:gfgS7OtZj6MA4U1UrDRp04twqAjfvlZyCfX3sDjEym8= 148 | github.com/mitchellh/go-wordwrap v1.0.1 h1:TLuKupo69TCn6TQSyGxwI1EblZZEsQ0vMlAFQflz0v0= 149 | github.com/mitchellh/go-wordwrap v1.0.1/go.mod h1:R62XHJLzvMFRBbcrT7m7WgmE1eOyTSsCt+hzestvNj0= 150 | github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= 151 | github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= 152 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 153 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 154 | github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 155 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg= 156 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= 157 | github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= 158 | github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= 159 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= 160 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 161 | github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= 162 | github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= 163 | github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE= 164 | github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU= 165 | github.com/onsi/ginkgo/v2 v2.20.2 h1:7NVCeyIWROIAheY21RLS+3j2bb52W0W82tkberYytp4= 166 | github.com/onsi/ginkgo/v2 v2.20.2/go.mod h1:K9gyxPIlb+aIvnZ8bd9Ak+YP18w3APlR+5coaZoE2ag= 167 | github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= 168 | github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= 169 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 170 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 171 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 172 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= 173 | github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 174 | github.com/prometheus/client_golang v1.19.1 h1:wZWJDwK+NameRJuPGDhlnFgx8e8HN3XHQeLaYJFJBOE= 175 | github.com/prometheus/client_golang v1.19.1/go.mod h1:mP78NwGzrVks5S2H6ab8+ZZGJLZUq1hoULYBAYBw1Ho= 176 | github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= 177 | github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= 178 | github.com/prometheus/common v0.55.0 h1:KEi6DK7lXW/m7Ig5i47x0vRzuBsHuvJdi5ee6Y3G1dc= 179 | github.com/prometheus/common v0.55.0/go.mod h1:2SECS4xJG1kd8XF9IcM1gMX6510RAEL65zxzNImwdc8= 180 | github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= 181 | github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= 182 | github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII= 183 | github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o= 184 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 185 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 186 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 187 | github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8= 188 | github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= 189 | github.com/spf13/cast v1.7.0 h1:ntdiHjuueXFgm5nzDRdOS4yfT43P5Fnud6DH50rz/7w= 190 | github.com/spf13/cast v1.7.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 191 | github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= 192 | github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= 193 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 194 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 195 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 196 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 197 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 198 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 199 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 200 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 201 | github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= 202 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 203 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 204 | github.com/tmccombs/hcl2json v0.3.3 h1:+DLNYqpWE0CsOQiEZu+OZm5ZBImake3wtITYxQ8uLFQ= 205 | github.com/tmccombs/hcl2json v0.3.3/go.mod h1:Y2chtz2x9bAeRTvSibVRVgbLJhLJXKlUeIvjeVdnm4w= 206 | github.com/upbound/provider-aws v1.14.0 h1:DDUdlMp+dNlFXXlhsGdCvQD7qFdT1AsEcaqlRU3BO14= 207 | github.com/upbound/provider-aws v1.14.0/go.mod h1:IvyvgGlhRVr737E4P75tyD/i53hxnyO7KPM8bbXH+SU= 208 | github.com/vmihailenco/msgpack v4.0.4+incompatible h1:dSLoQfGFAo3F6OoNhwUmLwVgaUXK79GlxNBwueZn0xI= 209 | github.com/vmihailenco/msgpack v4.0.4+incompatible/go.mod h1:fy3FlTQTDXWkZ7Bh6AcGMlsjHatGryHQYUTf1ShIgkk= 210 | github.com/vmihailenco/msgpack/v5 v5.4.1 h1:cQriyiUvjTwOHg8QZaPihLWeRAAVoCpE00IUPn0Bjt8= 211 | github.com/vmihailenco/msgpack/v5 v5.4.1/go.mod h1:GaZTsDaehaPpQVyxrf5mtQlH+pc21PIudVV/E3rRQok= 212 | github.com/vmihailenco/tagparser/v2 v2.0.0 h1:y09buUbR+b5aycVFQs/g70pqKVZNBmxwAhO7/IwNM9g= 213 | github.com/vmihailenco/tagparser/v2 v2.0.0/go.mod h1:Wri+At7QHww0WTrCBeu4J6bNtoV6mEfg5OIWRZA9qds= 214 | github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= 215 | github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= 216 | github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 217 | github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= 218 | github.com/yuin/goldmark v1.4.13 h1:fVcFKWvrslecOb/tg+Cc05dkeYx540o0FuFt3nUVDoE= 219 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 220 | github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8= 221 | github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE= 222 | go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= 223 | go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 224 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 225 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 226 | go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= 227 | go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= 228 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 229 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= 230 | golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 231 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 232 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 233 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 h1:e66Fs6Z+fZTbFBAxKfP3PALWBtpfqks2bwGcexMxgtk= 234 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0/go.mod h1:2TbTHSBQa924w8M6Xs1QcRcFwyucIwBGpK1p2f1YFFY= 235 | golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 236 | golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= 237 | golang.org/x/mod v0.21.0 h1:vvrHzRwRfVKSiLrG+d4FMl/Qi4ukBCE6kZlTUkDYRT0= 238 | golang.org/x/mod v0.21.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY= 239 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 240 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 241 | golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 242 | golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= 243 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 244 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 245 | golang.org/x/oauth2 v0.22.0 h1:BzDx2FehcG7jJwgWLELCdmLuxk2i+x9UDpSiss2u0ZA= 246 | golang.org/x/oauth2 v0.22.0/go.mod h1:XYTD2NtWslqkgxebSiOHnXEap4TF09sJSc7H1sXbhtI= 247 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 248 | golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 249 | golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 250 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 251 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 252 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 253 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 254 | golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 255 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 256 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 257 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 258 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 259 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 260 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 261 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 262 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 263 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 264 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 265 | golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= 266 | golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= 267 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 268 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 269 | golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= 270 | golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= 271 | golang.org/x/tools v0.25.0 h1:oFU9pkj/iJgs+0DT+VMHrx+oBKs/LJMV+Uvg78sl+fE= 272 | golang.org/x/tools v0.25.0/go.mod h1:/vtpO8WL1N9cQC3FN5zPqb//fRXskFHbLKk4OW1Q7rg= 273 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 274 | golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 275 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 276 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 277 | gomodules.xyz/jsonpatch/v2 v2.4.0 h1:Ci3iUJyx9UeRx7CeFN8ARgGbkESwJK+KB9lLcWxY/Zw= 278 | gomodules.xyz/jsonpatch/v2 v2.4.0/go.mod h1:AH3dM2RI6uoBZxn3LVrfvJ3E0/9dG4cSrbuBJT4moAY= 279 | google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM= 280 | google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds= 281 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ= 282 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU= 283 | google.golang.org/grpc v1.67.0 h1:IdH9y6PF5MPSdAntIcpjQ+tXO41pcQsfZV2RxtQgVcw= 284 | google.golang.org/grpc v1.67.0/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA= 285 | google.golang.org/protobuf v1.34.3-0.20240816073751-94ecbc261689 h1:hNwajDgT0MlsxZzlUajZVmUYFpts8/CYe4BSNx503ZE= 286 | google.golang.org/protobuf v1.34.3-0.20240816073751-94ecbc261689/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw= 287 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 288 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= 289 | gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= 290 | gopkg.in/evanphx/json-patch.v4 v4.12.0 h1:n6jtcsulIzXPJaxegRbvFNNrZDjbij7ny3gmSPG+6V4= 291 | gopkg.in/evanphx/json-patch.v4 v4.12.0/go.mod h1:p8EYWUEYMpynmqDbY58zCKCFZw8pRWMG4EsWvDvM72M= 292 | gopkg.in/inf.v0 v0.9.1 h1:73M5CoZyi3ZLMOyDlQh031Cx6N9NDJ2Vvfl76EDAgDc= 293 | gopkg.in/inf.v0 v0.9.1/go.mod h1:cWUDdTG/fYaXco+Dcufb5Vnc6Gp2YChqWtbxRZE0mXw= 294 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= 295 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= 296 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 297 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 298 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 299 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 300 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 301 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 302 | k8s.io/api v0.31.0 h1:b9LiSjR2ym/SzTOlfMHm1tr7/21aD7fSkqgD/CVJBCo= 303 | k8s.io/api v0.31.0/go.mod h1:0YiFF+JfFxMM6+1hQei8FY8M7s1Mth+z/q7eF1aJkTE= 304 | k8s.io/apiextensions-apiserver v0.31.0 h1:fZgCVhGwsclj3qCw1buVXCV6khjRzKC5eCFt24kyLSk= 305 | k8s.io/apiextensions-apiserver v0.31.0/go.mod h1:b9aMDEYaEe5sdK+1T0KU78ApR/5ZVp4i56VacZYEHxk= 306 | k8s.io/apimachinery v0.31.0 h1:m9jOiSr3FoSSL5WO9bjm1n6B9KROYYgNZOb4tyZ1lBc= 307 | k8s.io/apimachinery v0.31.0/go.mod h1:rsPdaZJfTfLsNJSQzNHQvYoTmxhoOEofxtOsF3rtsMo= 308 | k8s.io/client-go v0.31.0 h1:QqEJzNjbN2Yv1H79SsS+SWnXkBgVu4Pj3CJQgbx0gI8= 309 | k8s.io/client-go v0.31.0/go.mod h1:Y9wvC76g4fLjmU0BA+rV+h2cncoadjvjjkkIGoTLcGU= 310 | k8s.io/component-base v0.31.0 h1:/KIzGM5EvPNQcYgwq5NwoQBaOlVFrghoVGr8lG6vNRs= 311 | k8s.io/component-base v0.31.0/go.mod h1:TYVuzI1QmN4L5ItVdMSXKvH7/DtvIuas5/mm8YT3rTo= 312 | k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= 313 | k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= 314 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 h1:BZqlfIlq5YbRMFko6/PM7FjZpUb45WallggurYhKGag= 315 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340/go.mod h1:yD4MZYeKMBwQKVht279WycxKyM84kkAx2DPrTXaeb98= 316 | k8s.io/utils v0.0.0-20241210054802-24370beab758 h1:sdbE21q2nlQtFh65saZY+rRM6x6aJJI8IUa1AmH/qa0= 317 | k8s.io/utils v0.0.0-20241210054802-24370beab758/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0= 318 | sigs.k8s.io/controller-runtime v0.19.0 h1:nWVM7aq+Il2ABxwiCizrVDSlmDcshi9llbaFbC0ji/Q= 319 | sigs.k8s.io/controller-runtime v0.19.0/go.mod h1:iRmWllt8IlaLjvTTDLhRBXIEtkCK6hwVBJJsYS9Ajf4= 320 | sigs.k8s.io/controller-tools v0.16.0 h1:EJPB+a5Bve861SPBPPWRbP6bbKyNxqK12oYT5zEns9s= 321 | sigs.k8s.io/controller-tools v0.16.0/go.mod h1:0I0xqjR65YTfoO12iR+mZR6s6UAVcUARgXRlsu0ljB0= 322 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd h1:EDPBXCAspyGV4jQlpZSudPeMmr1bNJefnuqLsRAsHZo= 323 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd/go.mod h1:B8JuhiUyNFVKdsE8h686QcCxMaH6HrOAZj4vswFpcB0= 324 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 h1:150L+0vs/8DA78h1u02ooW1/fFq/Lwr+sGiqlzvrtq4= 325 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1/go.mod h1:N8hJocpFajUSSeSJ9bOZ77VzejKZaXsTtZo4/u7Io08= 326 | sigs.k8s.io/yaml v1.4.0 h1:Mk1wCc2gy/F0THH0TAp1QYyJNzRm2KCLy3o5ASXVI5E= 327 | sigs.k8s.io/yaml v1.4.0/go.mod h1:Ejl7/uTz7PSA4eKMyQCUTnhZYNmLIl+5c2lQPGR2BPY= 328 | -------------------------------------------------------------------------------- /input/generate.go: -------------------------------------------------------------------------------- 1 | //go:build generate 2 | // +build generate 3 | 4 | // NOTE(negz): See the below link for details on what is happening here. 5 | // https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 6 | 7 | // Remove existing and generate new input manifests 8 | //go:generate rm -rf ../package/input/ 9 | //go:generate go run -tags generate sigs.k8s.io/controller-tools/cmd/controller-gen paths=./v1beta1 object crd:crdVersions=v1 output:artifacts:config=../package/input 10 | 11 | package input 12 | 13 | import ( 14 | _ "sigs.k8s.io/controller-tools/cmd/controller-gen" //nolint:typecheck 15 | ) 16 | -------------------------------------------------------------------------------- /input/v1beta1/input.go: -------------------------------------------------------------------------------- 1 | // Package v1beta1 contains the input type for this Function 2 | // +kubebuilder:object:generate=true 3 | // +groupName=gotemplating.fn.crossplane.io 4 | // +versionName=v1beta1 5 | package v1beta1 6 | 7 | import ( 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | ) 10 | 11 | // This isn't a custom resource, in the sense that we never install its CRD. 12 | // It is a KRM-like object, so we generate a CRD to describe its schema. 13 | 14 | // A GoTemplate is used to provide templates to this Function. 15 | // +kubebuilder:object:root=true 16 | // +kubebuilder:storageversion 17 | // +kubebuilder:resource:categories=crossplane 18 | type GoTemplate struct { 19 | metav1.TypeMeta `json:",inline"` 20 | metav1.ObjectMeta `json:"metadata,omitempty"` 21 | // Template delimiters 22 | // +optional 23 | Delims *Delims `json:"delims,omitempty"` 24 | // Source specifies the different types of input sources that can be used with this function 25 | Source TemplateSource `json:"source"` 26 | // Inline is the inline form input of the templates 27 | Inline *TemplateSourceInline `json:"inline,omitempty"` 28 | // FileSystem is the folder path where the templates are located 29 | FileSystem *TemplateSourceFileSystem `json:"fileSystem,omitempty"` 30 | } 31 | 32 | type TemplateSource string 33 | 34 | const ( 35 | // InlineSource indicates that function will get its input as inline 36 | InlineSource TemplateSource = "Inline" 37 | 38 | // FileSystemSource indicates that function will get its input from a folder 39 | FileSystemSource TemplateSource = "FileSystem" 40 | ) 41 | 42 | type TemplateSourceInline struct { 43 | Template string `json:"template,omitempty"` 44 | } 45 | 46 | type TemplateSourceFileSystem struct { 47 | DirPath string `json:"dirPath,omitempty"` 48 | } 49 | 50 | type Delims struct { 51 | // Template start characters 52 | // +kubebuilder:default:="{{" 53 | // +optional 54 | Left *string `json:"left,omitempty"` 55 | // Template end characters 56 | // +kubebuilder:default:="}}" 57 | // +optional 58 | Right *string `json:"right,omitempty"` 59 | } 60 | -------------------------------------------------------------------------------- /input/v1beta1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | 3 | // Code generated by controller-gen. DO NOT EDIT. 4 | 5 | package v1beta1 6 | 7 | import ( 8 | runtime "k8s.io/apimachinery/pkg/runtime" 9 | ) 10 | 11 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 12 | func (in *Delims) DeepCopyInto(out *Delims) { 13 | *out = *in 14 | if in.Left != nil { 15 | in, out := &in.Left, &out.Left 16 | *out = new(string) 17 | **out = **in 18 | } 19 | if in.Right != nil { 20 | in, out := &in.Right, &out.Right 21 | *out = new(string) 22 | **out = **in 23 | } 24 | } 25 | 26 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Delims. 27 | func (in *Delims) DeepCopy() *Delims { 28 | if in == nil { 29 | return nil 30 | } 31 | out := new(Delims) 32 | in.DeepCopyInto(out) 33 | return out 34 | } 35 | 36 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 37 | func (in *GoTemplate) DeepCopyInto(out *GoTemplate) { 38 | *out = *in 39 | out.TypeMeta = in.TypeMeta 40 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 41 | if in.Delims != nil { 42 | in, out := &in.Delims, &out.Delims 43 | *out = new(Delims) 44 | (*in).DeepCopyInto(*out) 45 | } 46 | if in.Inline != nil { 47 | in, out := &in.Inline, &out.Inline 48 | *out = new(TemplateSourceInline) 49 | **out = **in 50 | } 51 | if in.FileSystem != nil { 52 | in, out := &in.FileSystem, &out.FileSystem 53 | *out = new(TemplateSourceFileSystem) 54 | **out = **in 55 | } 56 | } 57 | 58 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GoTemplate. 59 | func (in *GoTemplate) DeepCopy() *GoTemplate { 60 | if in == nil { 61 | return nil 62 | } 63 | out := new(GoTemplate) 64 | in.DeepCopyInto(out) 65 | return out 66 | } 67 | 68 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 69 | func (in *GoTemplate) DeepCopyObject() runtime.Object { 70 | if c := in.DeepCopy(); c != nil { 71 | return c 72 | } 73 | return nil 74 | } 75 | 76 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 77 | func (in *TemplateSourceFileSystem) DeepCopyInto(out *TemplateSourceFileSystem) { 78 | *out = *in 79 | } 80 | 81 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateSourceFileSystem. 82 | func (in *TemplateSourceFileSystem) DeepCopy() *TemplateSourceFileSystem { 83 | if in == nil { 84 | return nil 85 | } 86 | out := new(TemplateSourceFileSystem) 87 | in.DeepCopyInto(out) 88 | return out 89 | } 90 | 91 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 92 | func (in *TemplateSourceInline) DeepCopyInto(out *TemplateSourceInline) { 93 | *out = *in 94 | } 95 | 96 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TemplateSourceInline. 97 | func (in *TemplateSourceInline) DeepCopy() *TemplateSourceInline { 98 | if in == nil { 99 | return nil 100 | } 101 | out := new(TemplateSourceInline) 102 | in.DeepCopyInto(out) 103 | return out 104 | } 105 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package main implements a Composition Function. 2 | package main 3 | 4 | import ( 5 | "github.com/alecthomas/kong" 6 | 7 | "github.com/crossplane/function-sdk-go" 8 | ) 9 | 10 | // CLI of this Function. 11 | type CLI struct { 12 | Debug bool `short:"d" help:"Emit debug logs in addition to info logs."` 13 | 14 | Network string `help:"Network on which to listen for gRPC connections." default:"tcp"` 15 | Address string `help:"Address at which to listen for gRPC connections." default:":9443"` 16 | TLSCertsDir string `help:"Directory containing server certs (tls.key, tls.crt) and the CA used to verify client certificates (ca.crt)" env:"TLS_SERVER_CERTS_DIR"` 17 | Insecure bool `help:"Run without mTLS credentials. If you supply this flag --tls-server-certs-dir will be ignored."` 18 | MaxRecvMessageSize int `help:"Maximum size of received messages in MB." default:"4"` 19 | } 20 | 21 | // Run this Function. 22 | func (c *CLI) Run() error { 23 | log, err := function.NewLogger(c.Debug) 24 | if err != nil { 25 | return err 26 | } 27 | 28 | return function.Serve( 29 | &Function{ 30 | log: log, 31 | fsys: &osFS{}, 32 | }, 33 | function.Listen(c.Network, c.Address), 34 | function.MTLSCertificates(c.TLSCertsDir), 35 | function.Insecure(c.Insecure), 36 | function.MaxRecvMessageSize(c.MaxRecvMessageSize*1024*1024)) 37 | } 38 | 39 | func main() { 40 | ctx := kong.Parse(&CLI{}, kong.Description("A Crossplane Composition Function.")) 41 | ctx.FatalIfErrorf(ctx.Run()) 42 | } 43 | -------------------------------------------------------------------------------- /package/crossplane.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: meta.pkg.crossplane.io/v1beta1 3 | kind: Function 4 | metadata: 5 | name: function-go-templating 6 | annotations: 7 | meta.crossplane.io/maintainer: Crossplane Maintainers 8 | meta.crossplane.io/source: github.com/crossplane-contrib/function-go-templating 9 | meta.crossplane.io/license: Apache-2.0 10 | meta.crossplane.io/description: A Go templating composition function 11 | meta.crossplane.io/readme: | 12 | This composition function allows you to compose Crossplane resources using 13 | [Go templates](https://pkg.go.dev/text/template). If you've written a 14 | [Helm chart](https://helm.sh) before, using this function will be a 15 | familiar experience. See the 16 | [README](https://github.com/crossplane-contrib/function-go-templating) 17 | for examples and documentation. 18 | spec: {} 19 | -------------------------------------------------------------------------------- /package/input/gotemplating.fn.crossplane.io_gotemplates.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | controller-gen.kubebuilder.io/version: v0.13.0 7 | name: gotemplates.gotemplating.fn.crossplane.io 8 | spec: 9 | group: gotemplating.fn.crossplane.io 10 | names: 11 | categories: 12 | - crossplane 13 | kind: GoTemplate 14 | listKind: GoTemplateList 15 | plural: gotemplates 16 | singular: gotemplate 17 | scope: Namespaced 18 | versions: 19 | - name: v1beta1 20 | schema: 21 | openAPIV3Schema: 22 | description: A GoTemplate is used to provide templates to this Function. 23 | properties: 24 | apiVersion: 25 | description: 'APIVersion defines the versioned schema of this representation 26 | of an object. Servers should convert recognized schemas to the latest 27 | internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources' 28 | type: string 29 | delims: 30 | description: Template delimiters 31 | properties: 32 | left: 33 | default: '{{' 34 | description: Template start characters 35 | type: string 36 | right: 37 | default: '}}' 38 | description: Template end characters 39 | type: string 40 | type: object 41 | fileSystem: 42 | description: FileSystem is the folder path where the templates are located 43 | properties: 44 | dirPath: 45 | type: string 46 | type: object 47 | inline: 48 | description: Inline is the inline form input of the templates 49 | properties: 50 | template: 51 | type: string 52 | type: object 53 | kind: 54 | description: 'Kind is a string value representing the REST resource this 55 | object represents. Servers may infer this from the endpoint the client 56 | submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds' 57 | type: string 58 | metadata: 59 | type: object 60 | source: 61 | description: Source specifies the different types of input sources that 62 | can be used with this function 63 | type: string 64 | required: 65 | - source 66 | type: object 67 | served: true 68 | storage: true 69 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:recommended" 5 | ], 6 | "crossplane": { 7 | "fileMatch": ["(^|/)example/.*\\.ya?ml$"] 8 | }, 9 | "packageRules": [ 10 | { 11 | "matchManagers": ["crossplane"], 12 | "matchFileNames": ["example/**"], 13 | "groupName": "examples" 14 | } 15 | ], 16 | "postUpdateOptions": [ 17 | "gomodTidy", 18 | "gomodUpdateImportPaths" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /template.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io/fs" 5 | "path/filepath" 6 | 7 | "github.com/crossplane/function-sdk-go/errors" 8 | 9 | "github.com/crossplane-contrib/function-go-templating/input/v1beta1" 10 | ) 11 | 12 | const dotCharacter = 46 13 | 14 | // TemplateGetter interface is used to read templates from different sources 15 | type TemplateGetter interface { 16 | // GetTemplates returns the templates from the datasource 17 | GetTemplates() string 18 | } 19 | 20 | // NewTemplateSourceGetter returns a TemplateGetter based on the cd source 21 | func NewTemplateSourceGetter(fsys fs.FS, in *v1beta1.GoTemplate) (TemplateGetter, error) { 22 | switch in.Source { 23 | case v1beta1.InlineSource: 24 | return newInlineSource(in) 25 | case v1beta1.FileSystemSource: 26 | return newFileSource(fsys, in) 27 | case "": 28 | return nil, errors.Errorf("source is required") 29 | default: 30 | return nil, errors.Errorf("invalid source: %s", in.Source) 31 | } 32 | } 33 | 34 | // InlineSource is a datasource that reads a template from the composition 35 | type InlineSource struct { 36 | Template string 37 | } 38 | 39 | // FileSource is a datasource that reads a template from a folder 40 | type FileSource struct { 41 | FolderPath string 42 | Template string 43 | } 44 | 45 | // GetTemplates returns the inline template 46 | func (is *InlineSource) GetTemplates() string { 47 | return is.Template 48 | } 49 | 50 | func newInlineSource(in *v1beta1.GoTemplate) (*InlineSource, error) { 51 | if in.Inline == nil || in.Inline.Template == "" { 52 | return nil, errors.New("inline.template should be provided") 53 | } 54 | 55 | return &InlineSource{ 56 | Template: in.Inline.Template, 57 | }, nil 58 | } 59 | 60 | // GetTemplates returns the templates in the folder 61 | func (fs *FileSource) GetTemplates() string { 62 | return fs.Template 63 | } 64 | 65 | func newFileSource(fsys fs.FS, in *v1beta1.GoTemplate) (*FileSource, error) { 66 | if in.FileSystem == nil || in.FileSystem.DirPath == "" { 67 | return nil, errors.New("fileSystem.dirPath should be provided") 68 | } 69 | 70 | d := in.FileSystem.DirPath 71 | 72 | tmpl, err := readTemplates(fsys, d) 73 | if err != nil { 74 | return nil, errors.Errorf("cannot read tmpl from the folder %s: %s", *in.FileSystem, err) 75 | } 76 | 77 | return &FileSource{ 78 | FolderPath: in.FileSystem.DirPath, 79 | Template: tmpl, 80 | }, nil 81 | } 82 | 83 | func readTemplates(fsys fs.FS, dir string) (string, error) { 84 | tmpl := "" 85 | 86 | if err := fs.WalkDir(fsys, dir, func(path string, dirEntry fs.DirEntry, e error) error { 87 | if e != nil { 88 | return e 89 | } 90 | 91 | // skip hidden directories 92 | if dirEntry.IsDir() && dirEntry.Name()[0] == dotCharacter { 93 | return filepath.SkipDir 94 | } 95 | 96 | info, err := dirEntry.Info() 97 | if err != nil { 98 | return err 99 | } 100 | 101 | // check for directory and hidden files/folders 102 | if info.IsDir() || info.Name()[0] == dotCharacter { 103 | return nil 104 | } 105 | 106 | data, err := fs.ReadFile(fsys, path) 107 | if err != nil { 108 | return err 109 | } 110 | 111 | tmpl += string(data) 112 | tmpl += "\n---\n" 113 | 114 | return nil 115 | }); err != nil { 116 | return "", err 117 | } 118 | 119 | return tmpl, nil 120 | } 121 | -------------------------------------------------------------------------------- /testdata/templates/..shouldBeSkipped/_helpers.tpl: -------------------------------------------------------------------------------- 1 | {{- define "common-labels" -}} 2 | testLabel: "testValue" 3 | {{- end }} -------------------------------------------------------------------------------- /testdata/templates/..shouldBeSkipped/resource.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: kubernetes.crossplane.io/v1alpha1 2 | kind: Object 3 | metadata: 4 | annotations: 5 | gotemplating.fn.crossplane.io/composition-resource-name: test 6 | labels: 7 | {{- include "common-labels" . | nindent 4}} 8 | spec: 9 | providerConfigRef: 10 | name: default 11 | forProvider: 12 | manifest: 13 | apiVersion: v1 14 | kind: ConfigMap 15 | metadata: 16 | name: test-001 17 | namespace: test 18 | labels: 19 | {{- include "common-labels" . | nindent 10}} 20 | data: 21 | test: | 22 | spec: 23 | resources: [] -------------------------------------------------------------------------------- /testdata/templates/templates.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: example.org/v1 2 | kind: CD 3 | metadata: 4 | name: cool-cd 5 | annotations: 6 | gotemplating.fn.crossplane.io/composition-resource-name: cool-cd 7 | labels: 8 | belongsTo: {{ .observed.composite.resource.metadata.name|quote }} --------------------------------------------------------------------------------