├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── renovate.json5 ├── stale.yml └── workflows │ ├── backport.yml │ ├── ci.yml │ ├── commands.yml │ ├── promote.yml │ └── tag.yml ├── .gitignore ├── .gitmodules ├── .golangci.yml ├── LICENSE ├── Makefile ├── README.md ├── context └── context.go ├── errors ├── errors.go └── errors_test.go ├── go.mod ├── go.sum ├── logging └── logging.go ├── proto ├── buf.gen.yaml ├── generate.go ├── v1 │ ├── run_function.pb.go │ ├── run_function.proto │ └── run_function_grpc.pb.go └── v1beta1 │ ├── run_function.pb.go │ ├── run_function.proto │ └── run_function_grpc.pb.go ├── request ├── request.go └── request_test.go ├── resource ├── composed │ ├── composed.go │ └── composed_test.go ├── composite │ ├── composite.go │ └── composite_test.go └── resource.go ├── response ├── condition.go ├── condition_test.go ├── response.go ├── result.go └── result_test.go ├── sdk.go └── sdk_test.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Help us diagnose and fix bugs in Crossplane 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 | Crossplane version: 30 | 31 | 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Help us make Crossplane more useful 4 | labels: enhancement 5 | --- 6 | 13 | 14 | ### What problem are you facing? 15 | 20 | 21 | ### How could Crossplane help solve your problem? 22 | 25 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 8 | 9 | ### Description of your changes 10 | 11 | 20 | Fixes # 21 | 22 | I have: 23 | 24 | - [ ] Read and followed Crossplane's [contribution process]. 25 | - [ ] Run `make reviewable` to ensure this PR is ready for review. 26 | 27 | ### How has this code been tested 28 | 29 | 34 | 35 | [contribution process]: https://git.io/fj2m9 36 | -------------------------------------------------------------------------------- /.github/renovate.json5: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "config:base", 5 | "helpers:pinGitHubActionDigests" 6 | ], 7 | // We only want renovate to rebase PRs when they have conflicts, 8 | // default "auto" mode is not required. 9 | "rebaseWhen": "conflicted", 10 | // The maximum number of PRs to be created in parallel 11 | "prConcurrentLimit": 5, 12 | "postUpdateOptions": ["gomodTidy"], 13 | // By default renovate will auto detect whether semantic commits have been used 14 | // in the recent history and comply with that, we explicitly disable it 15 | "semanticCommits": "disabled", 16 | // All PRs should have a label 17 | "labels": ["automated"], 18 | "regexManagers": [ 19 | { 20 | "description": "Bump Go version ued in workflows", 21 | "fileMatch": ["^\\.github\\/workflows\\/[^/]+\\.ya?ml$"], 22 | "matchStrings": [ 23 | "GO_VERSION: '(?.*?)'\\n" 24 | ], 25 | "datasourceTemplate": "golang-version", 26 | "depNameTemplate": "golang" 27 | }, { 28 | "description": "Bump golangci-lint version in workflows and the Makefile", 29 | "fileMatch": ["^\\.github\\/workflows\\/[^/]+\\.ya?ml$","^Makefile$"], 30 | "matchStrings": [ 31 | "GOLANGCI_VERSION: 'v(?.*?)'\\n", 32 | "GOLANGCILINT_VERSION = (?.*?)\\n" 33 | ], 34 | "datasourceTemplate": "github-tags", 35 | "depNameTemplate": "golangci/golangci-lint", 36 | "extractVersionTemplate": "^v(?.*)$" 37 | }, { 38 | "description": "Bump Go required version in workflows and the Makefile", 39 | "fileMatch": ["^\\.github\\/workflows\\/[^/]+\\.ya?ml$", "^Makefile$"], 40 | "matchStrings": [ 41 | "GO_REQUIRED_VERSION = (?.*?)\\n", 42 | ], 43 | "datasourceTemplate": "golang-version", 44 | "depNameTemplate": "golang", 45 | "versioningTemplate": "loose", 46 | "extractVersionTemplate": "^(?\\d+\\.\\d+)" 47 | } 48 | ], 49 | // PackageRules disabled below should be enabled in case of vulnerabilities 50 | "vulnerabilityAlerts": { 51 | "enabled": true 52 | }, 53 | "osvVulnerabilityAlerts": true, 54 | "packageRules": [ 55 | { 56 | "description": "Only get docker image updates every 2 weeks to reduce noise", 57 | "matchDatasources": ["docker"], 58 | "schedule": ["every 2 week on monday"], 59 | "enabled": true, 60 | }, { 61 | "description": "Ignore k8s.io/client-go older versions, they switched to semantic version and old tags are still available in the repo", 62 | "matchDatasources": [ 63 | "go" 64 | ], 65 | "matchDepNames": [ 66 | "k8s.io/client-go" 67 | ], 68 | "allowedVersions": "<1.0" 69 | }, { 70 | "description": "Only get dependency digest updates every month to reduce noise", 71 | "matchDatasources": [ 72 | "go" 73 | ], 74 | "matchUpdateTypes": [ 75 | "digest", 76 | ], 77 | "extends": ["schedule:monthly"], 78 | }, { 79 | "description": "Single PR for all kubernetes dependency updates, as they usually are all linked", 80 | "matchDatasources": [ 81 | "go" 82 | ], 83 | "groupName": "kubernetes deps", 84 | "matchUpdateTypes": [ 85 | "major", 86 | "minor", 87 | "patch" 88 | ], 89 | "matchPackagePrefixes": [ 90 | "k8s.io", 91 | "sigs.k8s.io" 92 | ] 93 | }, { 94 | "description": "Ignore oss-fuzz, it's not using tags, we'll stick to master", 95 | "matchDepTypes": [ 96 | "action" 97 | ], 98 | "matchDepNames": [ 99 | "google/oss-fuzz" 100 | ], 101 | "enabled": false 102 | } 103 | ] 104 | } 105 | -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Configuration for probot-stale - https://github.com/probot/stale 2 | 3 | # Number of days of inactivity before an Issue or Pull Request becomes stale 4 | daysUntilStale: 90 5 | 6 | # Number of days of inactivity before a stale Issue or Pull Request is closed. 7 | # Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. 8 | daysUntilClose: 7 9 | 10 | # Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable 11 | exemptLabels: 12 | - security 13 | 14 | # Set to true to ignore issues in a project (defaults to false) 15 | exemptProjects: false 16 | 17 | # Set to true to ignore issues in a milestone (defaults to false) 18 | exemptMilestones: false 19 | 20 | # Label to use when marking as stale 21 | staleLabel: wontfix 22 | 23 | # Comment to post when marking as stale. Set to `false` to disable 24 | markComment: > 25 | This issue has been automatically marked as stale because it has not had 26 | recent activity. It will be closed if no further activity occurs. Thank you 27 | for your contributions. 28 | 29 | # Comment to post when closing a stale Issue or Pull Request. 30 | closeComment: > 31 | This issue has been automatically closed due to inactivity. Please re-open 32 | if this still requires investigation. 33 | 34 | # Limit the number of actions per hour, from 1-30. Default is 30 35 | limitPerRun: 30 36 | 37 | # Limit to only `issues` or `pulls` 38 | only: issues 39 | -------------------------------------------------------------------------------- /.github/workflows/backport.yml: -------------------------------------------------------------------------------- 1 | name: Backport 2 | 3 | on: 4 | # NOTE(negz): This is a risky target, but we run this action only when and if 5 | # a PR is closed, then filter down to specifically merged PRs. We also don't 6 | # invoke any scripts, etc from within the repo. I believe the fact that we'll 7 | # be able to review PRs before this runs makes this fairly safe. 8 | # https://securitylab.github.com/research/github-actions-preventing-pwn-requests/ 9 | pull_request_target: 10 | types: [closed] 11 | # See also commands.yml for the /backport triggered variant of this workflow. 12 | 13 | jobs: 14 | # NOTE(negz): I tested many backport GitHub actions before landing on this 15 | # one. Many do not support merge commits, or do not support pull requests with 16 | # more than one commit. This one does. It also handily links backport PRs with 17 | # new PRs, and provides commentary and instructions when it can't backport. 18 | # The main gotchas with this action are that it _only_ supports merge commits, 19 | # and that PRs _must_ be labelled before they're merged to trigger a backport. 20 | open-pr: 21 | runs-on: ubuntu-24.04 22 | if: github.event.pull_request.merged 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Open Backport PR 30 | uses: zeebe-io/backport-action@be567af183754f6a5d831ae90f648954763f17f5 # v3.1.0 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | github_workspace: ${{ github.workspace }} 34 | -------------------------------------------------------------------------------- /.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 | 11 | env: 12 | # Common versions 13 | GO_VERSION: '1.23.4' 14 | GOLANGCI_VERSION: 'v1.61.0' 15 | 16 | jobs: 17 | check-diff: 18 | runs-on: ubuntu-24.04 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 23 | with: 24 | submodules: true 25 | 26 | - name: Setup Go 27 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | 31 | - name: Find the Go Build Cache 32 | id: go 33 | run: echo "::set-output name=cache::$(make go.cachedir)" 34 | 35 | - name: Cache the Go Build Cache 36 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 37 | with: 38 | path: ${{ steps.go.outputs.cache }} 39 | key: ${{ runner.os }}-build-check-diff-${{ hashFiles('**/go.sum') }} 40 | restore-keys: ${{ runner.os }}-build-check-diff- 41 | 42 | - name: Cache Go Dependencies 43 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 44 | with: 45 | path: .work/pkg 46 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 47 | restore-keys: ${{ runner.os }}-pkg- 48 | 49 | - name: Download Go Modules 50 | run: make modules.download modules.check 51 | 52 | - name: Check Diff 53 | run: make check-diff 54 | 55 | detect-noop: 56 | runs-on: ubuntu-24.04 57 | outputs: 58 | noop: ${{ steps.noop.outputs.should_skip }} 59 | steps: 60 | - name: Detect No-op Changes 61 | id: noop 62 | uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1 63 | with: 64 | github_token: ${{ secrets.GITHUB_TOKEN }} 65 | paths_ignore: '["**.md", "**.png", "**.jpg"]' 66 | do_not_skip: '["workflow_dispatch", "schedule", "push"]' 67 | concurrent_skipping: false 68 | 69 | lint: 70 | runs-on: ubuntu-24.04 71 | needs: detect-noop 72 | if: needs.detect-noop.outputs.noop != 'true' 73 | 74 | steps: 75 | - name: Checkout 76 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 77 | with: 78 | submodules: true 79 | 80 | - name: Setup Go 81 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5 82 | with: 83 | go-version: ${{ env.GO_VERSION }} 84 | 85 | - name: Find the Go Build Cache 86 | id: go 87 | run: echo "::set-output name=cache::$(make go.cachedir)" 88 | 89 | - name: Cache the Go Build Cache 90 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 91 | with: 92 | path: ${{ steps.go.outputs.cache }} 93 | key: ${{ runner.os }}-build-lint-${{ hashFiles('**/go.sum') }} 94 | restore-keys: ${{ runner.os }}-build-lint- 95 | 96 | - name: Cache Go Dependencies 97 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 98 | with: 99 | path: .work/pkg 100 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 101 | restore-keys: ${{ runner.os }}-pkg- 102 | 103 | - name: Download Go Modules 104 | run: make modules.download modules.check 105 | 106 | # We could run 'make lint' to ensure our desired Go version, but we prefer 107 | # this action because it leaves 'annotations' (i.e. it comments on PRs to 108 | # point out linter violations). 109 | - name: Lint 110 | uses: golangci/golangci-lint-action@aaa42aa0628b4ae2578232a66b541047968fac86 # v6 111 | with: 112 | version: ${{ env.GOLANGCI_VERSION }} 113 | skip-cache: true # We do our own caching. 114 | 115 | codeql: 116 | runs-on: ubuntu-24.04 117 | needs: detect-noop 118 | if: needs.detect-noop.outputs.noop != 'true' 119 | 120 | steps: 121 | - name: Checkout 122 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 123 | with: 124 | submodules: true 125 | 126 | - name: Setup Go 127 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5 128 | with: 129 | go-version: ${{ env.GO_VERSION }} 130 | 131 | - name: Find the Go Build Cache 132 | id: go 133 | run: echo "::set-output name=cache::$(make go.cachedir)" 134 | 135 | - name: Cache the Go Build Cache 136 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 137 | with: 138 | path: ${{ steps.go.outputs.cache }} 139 | key: ${{ runner.os }}-build-check-diff-${{ hashFiles('**/go.sum') }} 140 | restore-keys: ${{ runner.os }}-build-check-diff- 141 | 142 | - name: Cache Go Dependencies 143 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 144 | with: 145 | path: .work/pkg 146 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 147 | restore-keys: ${{ runner.os }}-pkg- 148 | 149 | - name: Download Go Modules 150 | run: make modules.download modules.check 151 | 152 | - name: Initialize CodeQL 153 | uses: github/codeql-action/init@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3 154 | with: 155 | languages: go 156 | 157 | - name: Perform CodeQL Analysis 158 | uses: github/codeql-action/analyze@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3 159 | 160 | trivy-scan-fs: 161 | runs-on: ubuntu-24.04 162 | needs: detect-noop 163 | if: needs.detect-noop.outputs.noop != 'true' 164 | steps: 165 | - name: Checkout 166 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 167 | with: 168 | submodules: true 169 | 170 | - name: Run Trivy vulnerability scanner in fs mode 171 | uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # 0.24.0 172 | with: 173 | scan-type: 'fs' 174 | ignore-unfixed: true 175 | skip-dirs: design 176 | scan-ref: '.' 177 | exit-code: '1' 178 | severity: 'CRITICAL,HIGH' 179 | 180 | unit-tests: 181 | runs-on: ubuntu-24.04 182 | needs: detect-noop 183 | if: needs.detect-noop.outputs.noop != 'true' 184 | 185 | steps: 186 | - name: Checkout 187 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 188 | with: 189 | submodules: true 190 | 191 | - name: Fetch History 192 | run: git fetch --prune --unshallow 193 | 194 | - name: Setup Go 195 | uses: actions/setup-go@0a12ed9d6a96ab950c8f026ed9f722fe0da7ef32 # v5 196 | with: 197 | go-version: ${{ env.GO_VERSION }} 198 | 199 | - name: Find the Go Build Cache 200 | id: go 201 | run: echo "::set-output name=cache::$(make go.cachedir)" 202 | 203 | - name: Cache the Go Build Cache 204 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 205 | with: 206 | path: ${{ steps.go.outputs.cache }} 207 | key: ${{ runner.os }}-build-unit-tests-${{ hashFiles('**/go.sum') }} 208 | restore-keys: ${{ runner.os }}-build-unit-tests- 209 | 210 | - name: Cache Go Dependencies 211 | uses: actions/cache@0c45773b623bea8c8e75f6c82b208c3cf94ea4f9 # v4 212 | with: 213 | path: .work/pkg 214 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 215 | restore-keys: ${{ runner.os }}-pkg- 216 | 217 | - name: Download Go Modules 218 | run: make modules.download modules.check 219 | 220 | - name: Run Unit Tests 221 | run: make -j2 test 222 | 223 | - name: Publish Unit Test Coverage 224 | uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 225 | with: 226 | flags: unittests 227 | file: _output/tests/linux_amd64/coverage.txt 228 | -------------------------------------------------------------------------------- /.github/workflows/commands.yml: -------------------------------------------------------------------------------- 1 | name: Comment Commands 2 | 3 | on: issue_comment 4 | 5 | jobs: 6 | points: 7 | runs-on: ubuntu-24.04 8 | if: startsWith(github.event.comment.body, '/points') 9 | 10 | steps: 11 | - name: Extract Command 12 | id: command 13 | uses: xt0rted/slash-command-action@bf51f8f5f4ea3d58abc7eca58f77104182b23e88 # v2 14 | with: 15 | repo-token: ${{ secrets.GITHUB_TOKEN }} 16 | command: points 17 | reaction: "true" 18 | reaction-type: "eyes" 19 | allow-edits: "false" 20 | permission-level: write 21 | - name: Handle Command 22 | uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7 23 | env: 24 | POINTS: ${{ steps.command.outputs.command-arguments }} 25 | with: 26 | github-token: ${{ secrets.GITHUB_TOKEN }} 27 | script: | 28 | const points = process.env.POINTS 29 | 30 | if (isNaN(parseInt(points))) { 31 | console.log("Malformed command - expected '/points '") 32 | github.reactions.createForIssueComment({ 33 | owner: context.repo.owner, 34 | repo: context.repo.repo, 35 | comment_id: context.payload.comment.id, 36 | content: "confused" 37 | }) 38 | return 39 | } 40 | const label = "points/" + points 41 | 42 | // Delete our needs-points-label label. 43 | try { 44 | await github.issues.deleteLabel({ 45 | issue_number: context.issue.number, 46 | owner: context.repo.owner, 47 | repo: context.repo.repo, 48 | name: ['needs-points-label'] 49 | }) 50 | console.log("Deleted 'needs-points-label' label.") 51 | } 52 | catch(e) { 53 | console.log("Label 'needs-points-label' probably didn't exist.") 54 | } 55 | 56 | // Add our points label. 57 | github.issues.addLabels({ 58 | issue_number: context.issue.number, 59 | owner: context.repo.owner, 60 | repo: context.repo.repo, 61 | labels: [label] 62 | }) 63 | console.log("Added '" + label + "' label.") 64 | 65 | # NOTE(negz): See also backport.yml, which is the variant that triggers on PR 66 | # merge rather than on comment. 67 | backport: 68 | runs-on: ubuntu-24.04 69 | if: github.event.issue.pull_request && startsWith(github.event.comment.body, '/backport') 70 | steps: 71 | - name: Extract Command 72 | id: command 73 | uses: xt0rted/slash-command-action@bf51f8f5f4ea3d58abc7eca58f77104182b23e88 # v2 74 | with: 75 | repo-token: ${{ secrets.GITHUB_TOKEN }} 76 | command: backport 77 | reaction: "true" 78 | reaction-type: "eyes" 79 | allow-edits: "false" 80 | permission-level: write 81 | 82 | - name: Checkout 83 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 84 | with: 85 | fetch-depth: 0 86 | 87 | - name: Open Backport PR 88 | uses: zeebe-io/backport-action@be567af183754f6a5d831ae90f648954763f17f5 # v3.1.0 89 | with: 90 | github_token: ${{ secrets.GITHUB_TOKEN }} 91 | github_workspace: ${{ github.workspace }} 92 | -------------------------------------------------------------------------------- /.github/workflows/promote.yml: -------------------------------------------------------------------------------- 1 | name: Promote 2 | 3 | on: 4 | workflow_dispatch: 5 | inputs: 6 | version: 7 | description: 'Release version (e.g. v0.1.0)' 8 | required: true 9 | channel: 10 | description: 'Release channel' 11 | required: true 12 | default: 'alpha' 13 | 14 | env: 15 | # Common users. We can't run a step 'if secrets.AWS_USR != ""' but we can run 16 | # a step 'if env.AWS_USR' != ""', so we copy these to succinctly test whether 17 | # credentials have been provided before trying to run steps that need them. 18 | DOCKER_USR: ${{ secrets.DOCKER_USR }} 19 | AWS_USR: ${{ secrets.AWS_USR }} 20 | 21 | jobs: 22 | promote-artifacts: 23 | runs-on: ubuntu-24.04 24 | 25 | steps: 26 | - name: Checkout 27 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 28 | with: 29 | submodules: true 30 | 31 | - name: Fetch History 32 | run: git fetch --prune --unshallow 33 | 34 | - name: Login to Docker 35 | uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3 36 | if: env.DOCKER_USR != '' 37 | with: 38 | username: ${{ secrets.DOCKER_USR }} 39 | password: ${{ secrets.DOCKER_PSW }} 40 | 41 | - name: Promote Artifacts in S3 and Docker Hub 42 | if: env.AWS_USR != '' && env.DOCKER_USR != '' 43 | run: make -j2 promote BRANCH_NAME=${GITHUB_REF##*/} 44 | env: 45 | VERSION: ${{ github.event.inputs.version }} 46 | CHANNEL: ${{ github.event.inputs.channel }} 47 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_USR }} 48 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_PSW }} 49 | -------------------------------------------------------------------------------- /.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-24.04 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@692973e3d937129bcbf40652eb9f2f61becf3332 # v4 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 | /.cache 2 | /.work 3 | /_output 4 | /config/ 5 | /config 6 | cover.out 7 | /vendor 8 | /.vendor-new 9 | 10 | # gitlab example 11 | # exclude files generate by running the example 12 | external-dns-*.tgz 13 | gitlab-*.tgz 14 | gitlab-gcp.yaml 15 | gitlab/ 16 | 17 | # ignore IDE folders 18 | .vscode/ 19 | .idea/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "build"] 2 | path = build 3 | url = https://github.com/crossplane/build 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 10m 3 | 4 | skip-files: 5 | - "zz_generated\\..+\\.go$" 6 | 7 | output: 8 | # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" 9 | format: colored-line-number 10 | 11 | linters-settings: 12 | errcheck: 13 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 14 | # default is false: such cases aren't reported by default. 15 | check-type-assertions: false 16 | 17 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 18 | # default is false: such cases aren't reported by default. 19 | check-blank: false 20 | 21 | # [deprecated] comma-separated list of pairs of the form pkg:regex 22 | # the regex is used to ignore names within pkg. (default "fmt:.*"). 23 | # see https://github.com/kisielk/errcheck#the-deprecated-method for details 24 | ignore: fmt:.*,io/ioutil:^Read.* 25 | 26 | govet: 27 | # report about shadowed variables 28 | check-shadowing: false 29 | 30 | gofmt: 31 | # simplify code: gofmt with `-s` option, true by default 32 | simplify: true 33 | 34 | gci: 35 | custom-order: true 36 | sections: 37 | - standard 38 | - default 39 | - prefix(github.com/crossplane/crossplane-runtime) 40 | - prefix(github.com/crossplane/function-sdk-go) 41 | - blank 42 | - dot 43 | 44 | gocyclo: 45 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 46 | min-complexity: 10 47 | 48 | maligned: 49 | # print struct with more effective memory layout or not, false by default 50 | suggest-new: true 51 | 52 | dupl: 53 | # tokens count to trigger issue, 150 by default 54 | threshold: 100 55 | 56 | goconst: 57 | # minimal length of string constant, 3 by default 58 | min-len: 3 59 | # minimal occurrences count to trigger, 3 by default 60 | min-occurrences: 5 61 | 62 | lll: 63 | # tab width in spaces. Default to 1. 64 | tab-width: 1 65 | 66 | unused: 67 | # treat code as a program (not a library) and report unused exported identifiers; default is false. 68 | # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: 69 | # if it's called for subdir of a project it can't find funcs usages. All text editor integrations 70 | # with golangci-lint call it on a directory with the changed file. 71 | check-exported: false 72 | 73 | unparam: 74 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 75 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 76 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 77 | # with golangci-lint call it on a directory with the changed file. 78 | check-exported: false 79 | 80 | nakedret: 81 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 82 | max-func-lines: 30 83 | 84 | prealloc: 85 | # XXX: we don't recommend using this linter before doing performance profiling. 86 | # For most programs usage of prealloc will be a premature optimization. 87 | 88 | # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. 89 | # True by default. 90 | simple: true 91 | range-loops: true # Report preallocation suggestions on range loops, true by default 92 | for-loops: false # Report preallocation suggestions on for loops, false by default 93 | 94 | gocritic: 95 | # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint` run to see all tags and checks. 96 | # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". 97 | enabled-tags: 98 | - performance 99 | 100 | settings: # settings passed to gocritic 101 | captLocal: # must be valid enabled check name 102 | paramsOnly: true 103 | rangeValCopy: 104 | sizeThreshold: 32 105 | 106 | nolintlint: 107 | require-explanation: true 108 | require-specific: true 109 | 110 | 111 | linters: 112 | enable: 113 | - megacheck 114 | - govet 115 | - gocyclo 116 | - gocritic 117 | - goconst 118 | - gci 119 | - gofmt # We enable this as well as goimports for its simplify mode. 120 | - prealloc 121 | - revive 122 | - unconvert 123 | - misspell 124 | - nakedret 125 | - nolintlint 126 | 127 | disable: 128 | # These linters are all deprecated as of golangci-lint v1.49.0. We disable 129 | # them explicitly to avoid the linter logging deprecation warnings. 130 | - deadcode 131 | - varcheck 132 | - scopelint 133 | - structcheck 134 | - interfacer 135 | 136 | presets: 137 | - bugs 138 | - unused 139 | fast: false 140 | 141 | 142 | issues: 143 | # Excluding configuration per-path and per-linter 144 | exclude-rules: 145 | # Exclude some linters from running on tests files. 146 | - path: _test(ing)?\.go 147 | linters: 148 | - gocyclo 149 | - errcheck 150 | - dupl 151 | - gosec 152 | - scopelint 153 | - unparam 154 | - contextcheck 155 | - errchkjson 156 | 157 | # Ease some gocritic warnings on test files. 158 | - path: _test\.go 159 | text: "(unnamedResult|exitAfterDefer)" 160 | linters: 161 | - gocritic 162 | 163 | # These are performance optimisations rather than style issues per se. 164 | # They warn when function arguments or range values copy a lot of memory 165 | # rather than using a pointer. 166 | - text: "(hugeParam|rangeValCopy):" 167 | linters: 168 | - gocritic 169 | 170 | # This "TestMain should call os.Exit to set exit code" warning is not clever 171 | # enough to notice that we call a helper method that calls os.Exit. 172 | - text: "SA3000:" 173 | linters: 174 | - staticcheck 175 | 176 | - text: "k8s.io/api/core/v1" 177 | linters: 178 | - goimports 179 | 180 | # This is a "potential hardcoded credentials" warning. It's triggered by 181 | # any variable with 'secret' in the same, and thus hits a lot of false 182 | # positives in Kubernetes land where a Secret is an object type. 183 | - text: "G101:" 184 | linters: 185 | - gosec 186 | - gas 187 | 188 | # This is an 'errors unhandled' warning that duplicates errcheck. 189 | - text: "G104:" 190 | linters: 191 | - gosec 192 | - gas 193 | 194 | # Independently from option `exclude` we use default exclude patterns, 195 | # it can be disabled by this option. To list all 196 | # excluded by default patterns execute `golangci-lint run --help`. 197 | # Default value for this option is true. 198 | exclude-use-default: false 199 | 200 | # Show only new issues: if there are unstaged changes or untracked files, 201 | # only those changes are analyzed, else only changes in HEAD~ are analyzed. 202 | # It's a super-useful option for integration of golangci-lint into existing 203 | # large codebase. It's not practical to fix all existing issues at the moment 204 | # of integration: much better don't allow issues in new code. 205 | # Default is false. 206 | new: false 207 | 208 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 209 | max-per-linter: 0 210 | 211 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 212 | max-same-issues: 0 213 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ==================================================================================== 2 | # Setup Project 3 | 4 | PROJECT_NAME := function-sdk-go 5 | PROJECT_REPO := github.com/crossplane/$(PROJECT_NAME) 6 | 7 | PLATFORMS ?= linux_amd64 linux_arm64 8 | # -include will silently skip missing files, which allows us 9 | # to load those files with a target in the Makefile. If only 10 | # "include" was used, the make command would fail and refuse 11 | # to run a target until the include commands succeeded. 12 | -include build/makelib/common.mk 13 | 14 | # ==================================================================================== 15 | # Setup Go 16 | 17 | # Set a sane default so that the nprocs calculation below is less noisy on the initial 18 | # loading of this file 19 | NPROCS ?= 1 20 | 21 | # each of our test suites starts a kube-apiserver and running many test suites in 22 | # parallel can lead to high CPU utilization. by default we reduce the parallelism 23 | # to half the number of CPU cores. 24 | GO_TEST_PARALLEL := $(shell echo $$(( $(NPROCS) / 2 ))) 25 | 26 | GO_LDFLAGS += -X $(GO_PROJECT)/pkg/version.Version=$(VERSION) 27 | GO_SUBDIRS += errors proto resource response request 28 | GO111MODULE = on 29 | GOLANGCILINT_VERSION = 1.61.0 30 | GO_LINT_ARGS ?= "--fix" 31 | -include build/makelib/golang.mk 32 | 33 | # ==================================================================================== 34 | # Targets 35 | 36 | # run `make help` to see the targets and options 37 | 38 | # We want submodules to be set up the first time `make` is run. 39 | # We manage the build/ folder and its Makefiles as a submodule. 40 | # The first time `make` is run, the includes of build/*.mk files will 41 | # all fail, and this target will be run. The next time, the default as defined 42 | # by the includes will be run instead. 43 | fallthrough: submodules 44 | @echo Initial setup complete. Running make again . . . 45 | @make 46 | 47 | 48 | # NOTE(hasheddan): the build submodule currently overrides XDG_CACHE_HOME in 49 | # order to force the Helm 3 to use the .work/helm directory. This causes Go on 50 | # Linux machines to use that directory as the build cache as well. We should 51 | # adjust this behavior in the build submodule because it is also causing Linux 52 | # users to duplicate their build cache, but for now we just make it easier to 53 | # identify its location in CI so that we cache between builds. 54 | go.cachedir: 55 | @go env GOCACHE 56 | 57 | # Update the submodules, such as the common build scripts. 58 | submodules: 59 | @git submodule sync 60 | @git submodule update --init --recursive 61 | 62 | .PHONY: reviewable submodules fallthrough 63 | 64 | # ==================================================================================== 65 | # Special Targets 66 | 67 | define SDK_HELP 68 | Targets: 69 | reviewable Ensure a PR is ready for review. 70 | submodules Update the submodules, such as the common build scripts. 71 | 72 | endef 73 | export SDK_HELP 74 | 75 | function-sdk-go.help: 76 | @echo "$$SDK_HELP" 77 | 78 | help-special: function-sdk-go.help 79 | 80 | .PHONY: function-sdk-go.help help-special 81 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # function-sdk-go 2 | [![CI](https://github.com/crossplane/function-sdk-go/actions/workflows/ci.yml/badge.svg)](https://github.com/crossplane/function-sdk-go/actions/workflows/ci.yml) ![GitHub release (latest SemVer)](https://img.shields.io/github/release/crossplane/function-sdk-go) [![Go Reference](https://pkg.go.dev/badge/github.com/crossplane/function-sdk-go.svg)](https://pkg.go.dev/github.com/crossplane/function-sdk-go) 3 | 4 | The [Go][go] SDK for writing [composition functions][functions]. 5 | 6 | This SDK is currently a beta. We try to avoid breaking changes, but it will not 7 | have a stable API until it reaches v1.0.0. It follows the same [contributing 8 | guidelines] as Crossplane. 9 | 10 | To learn how to use this SDK: 11 | 12 | * [Follow the guide to writing a composition function in Go][function guide] 13 | * [Learn about how composition functions work][functions] 14 | * [Read the package documentation][package docs] 15 | 16 | If you just want to jump in and get started, consider using the 17 | [function-template-go] template repository. 18 | 19 | [go]: https://go.dev 20 | [functions]: https://docs.crossplane.io/latest/concepts/composition-functions 21 | [contributing guidelines]: https://github.com/crossplane/crossplane/tree/master/contributing 22 | [function guide]: https://docs.crossplane.io/knowledge-base/guides/write-a-composition-function-in-go 23 | [package docs]: https://pkg.go.dev/github.com/crossplane/function-sdk-go 24 | [function-template-go]: https://github.com/crossplane/function-template-go 25 | -------------------------------------------------------------------------------- /context/context.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package context contains utilities for working with Function context. 18 | package context 19 | 20 | // Well-known context keys. 21 | const ( 22 | // KeyEnvironment is the context key Crossplane sets to inject the 23 | // Composition Environment into Function context. 24 | // 25 | // https://github.com/crossplane/crossplane/blob/579702/design/one-pager-composition-environment.md 26 | // 27 | // THIS IS AN ALPHA FEATURE. Do not use it in production. It is not honored 28 | // unless the relevant Crossplane feature flag is enabled, and may be 29 | // changed or removed without notice. 30 | KeyEnvironment = "apiextensions.crossplane.io/environment" 31 | ) 32 | -------------------------------------------------------------------------------- /errors/errors.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package errors is a github.com/pkg/errors compatible API for native errors. 18 | // 19 | // Copied over from github.com/crossplane/crossplane-runtime/pkg/errors for 20 | // better discoverability and possibly diverge in the future. 21 | package errors 22 | 23 | import ( 24 | "errors" 25 | "fmt" 26 | 27 | kerrors "k8s.io/apimachinery/pkg/util/errors" 28 | ) 29 | 30 | // New returns an error that formats as the given text. Each call to New returns 31 | // a distinct error value even if the text is identical. 32 | func New(text string) error { return errors.New(text) } 33 | 34 | // Is reports whether any error in err's chain matches target. 35 | // 36 | // The chain consists of err itself followed by the sequence of errors obtained 37 | // by repeatedly calling Unwrap. 38 | // 39 | // An error is considered to match a target if it is equal to that target or if 40 | // it implements a method Is(error) bool such that Is(target) returns true. 41 | // 42 | // An error type might provide an Is method so it can be treated as equivalent 43 | // to an existing error. For example, if MyError defines 44 | // 45 | // func (m MyError) Is(target error) bool { return target == fs.ErrExist } 46 | // 47 | // then Is(MyError{}, fs.ErrExist) returns true. See syscall.Errno.Is for 48 | // an example in the standard library. 49 | func Is(err, target error) bool { return errors.Is(err, target) } 50 | 51 | // As finds the first error in err's chain that matches target, and if so, sets 52 | // target to that error value and returns true. Otherwise, it returns false. 53 | // 54 | // The chain consists of err itself followed by the sequence of errors obtained 55 | // by repeatedly calling Unwrap. 56 | // 57 | // An error matches target if the error's concrete value is assignable to the 58 | // value pointed to by target, or if the error has a method As(any) bool 59 | // such that As(target) returns true. In the latter case, the As method is 60 | // responsible for setting target. 61 | // 62 | // An error type might provide an As method so it can be treated as if it were a 63 | // different error type. 64 | // 65 | // As panics if target is not a non-nil pointer to either a type that implements 66 | // error, or to any interface type. 67 | func As(err error, target any) bool { return errors.As(err, target) } 68 | 69 | // Unwrap returns the result of calling the Unwrap method on err, if err's type 70 | // contains an Unwrap method returning error. Otherwise, Unwrap returns nil. 71 | func Unwrap(err error) error { return errors.Unwrap(err) } 72 | 73 | // Errorf formats according to a format specifier and returns the string as a 74 | // value that satisfies error. 75 | // 76 | // If the format specifier includes a %w verb with an error operand, the 77 | // returned error will implement an Unwrap method returning the operand. It is 78 | // invalid to include more than one %w verb or to supply it with an operand that 79 | // does not implement the error interface. The %w verb is otherwise a synonym 80 | // for %v. 81 | func Errorf(format string, a ...any) error { return fmt.Errorf(format, a...) } 82 | 83 | // WithMessage annotates err with a new message. If err is nil, WithMessage 84 | // returns nil. 85 | func WithMessage(err error, message string) error { 86 | if err == nil { 87 | return nil 88 | } 89 | return fmt.Errorf("%s: %w", message, err) 90 | } 91 | 92 | // WithMessagef annotates err with the format specifier. If err is nil, 93 | // WithMessagef returns nil. 94 | func WithMessagef(err error, format string, args ...any) error { 95 | if err == nil { 96 | return nil 97 | } 98 | return fmt.Errorf("%s: %w", fmt.Sprintf(format, args...), err) 99 | } 100 | 101 | // Wrap is an alias for WithMessage. 102 | func Wrap(err error, message string) error { 103 | return WithMessage(err, message) 104 | } 105 | 106 | // Wrapf is an alias for WithMessagef 107 | func Wrapf(err error, format string, args ...any) error { 108 | return WithMessagef(err, format, args...) 109 | } 110 | 111 | // Cause calls Unwrap on each error it finds. It returns the first error it 112 | // finds that does not have an Unwrap method - i.e. the first error that was not 113 | // the result of a Wrap call, a Wrapf call, or an Errorf call with %w wrapping. 114 | func Cause(err error) error { 115 | type wrapped interface { 116 | Unwrap() error 117 | } 118 | 119 | for err != nil { 120 | w, ok := err.(wrapped) 121 | if !ok { 122 | return err 123 | } 124 | err = w.Unwrap() 125 | } 126 | 127 | return err 128 | } 129 | 130 | // MultiError is an error that wraps multiple errors. 131 | type MultiError interface { 132 | error 133 | Unwrap() []error 134 | } 135 | 136 | // Join returns an error that wraps the given errors. Any nil error values are 137 | // discarded. Join returns nil if errs contains no non-nil values. The error 138 | // formats as the concatenation of the strings obtained by calling the Error 139 | // method of each element of errs and formatting like: 140 | // 141 | // [first error, second error, third error] 142 | // 143 | // Note: aggregating errors should not be the default. Usually, return only the 144 | // first error, and only aggregate if there is clear value to the user. 145 | func Join(errs ...error) MultiError { 146 | err := kerrors.NewAggregate(errs) 147 | if err == nil { 148 | return nil 149 | } 150 | return multiError{aggregate: err} 151 | } 152 | 153 | type multiError struct { 154 | aggregate kerrors.Aggregate 155 | } 156 | 157 | func (m multiError) Error() string { 158 | return m.aggregate.Error() 159 | } 160 | func (m multiError) Unwrap() []error { 161 | return m.aggregate.Errors() 162 | } 163 | -------------------------------------------------------------------------------- /errors/errors_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package errors 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | 24 | "github.com/crossplane/crossplane-runtime/pkg/test" 25 | ) 26 | 27 | func TestWrap(t *testing.T) { 28 | type args struct { 29 | err error 30 | message string 31 | } 32 | cases := map[string]struct { 33 | args args 34 | want error 35 | }{ 36 | "NilError": { 37 | args: args{ 38 | err: nil, 39 | message: "very useful context", 40 | }, 41 | want: nil, 42 | }, 43 | "NonNilError": { 44 | args: args{ 45 | err: New("boom"), 46 | message: "very useful context", 47 | }, 48 | want: Errorf("very useful context: %w", New("boom")), 49 | }, 50 | } 51 | 52 | for name, tc := range cases { 53 | t.Run(name, func(t *testing.T) { 54 | got := Wrap(tc.args.err, tc.args.message) 55 | if diff := cmp.Diff(tc.want, got, test.EquateErrors()); diff != "" { 56 | t.Errorf("Wrap(...): -want, +got:\n%s", diff) 57 | } 58 | }) 59 | } 60 | } 61 | 62 | func TestWrapf(t *testing.T) { 63 | type args struct { 64 | err error 65 | message string 66 | args []any 67 | } 68 | cases := map[string]struct { 69 | args args 70 | want error 71 | }{ 72 | "NilError": { 73 | args: args{ 74 | err: nil, 75 | message: "very useful context", 76 | }, 77 | want: nil, 78 | }, 79 | "NonNilError": { 80 | args: args{ 81 | err: New("boom"), 82 | message: "very useful context about %s", 83 | args: []any{"ducks"}, 84 | }, 85 | want: Errorf("very useful context about %s: %w", "ducks", New("boom")), 86 | }, 87 | } 88 | 89 | for name, tc := range cases { 90 | t.Run(name, func(t *testing.T) { 91 | got := Wrapf(tc.args.err, tc.args.message, tc.args.args...) 92 | if diff := cmp.Diff(tc.want, got, test.EquateErrors()); diff != "" { 93 | t.Errorf("Wrapf(...): -want, +got:\n%s", diff) 94 | } 95 | }) 96 | } 97 | } 98 | 99 | func TestCause(t *testing.T) { 100 | cases := map[string]struct { 101 | err error 102 | want error 103 | }{ 104 | "NilError": { 105 | err: nil, 106 | want: nil, 107 | }, 108 | "BareError": { 109 | err: New("boom"), 110 | want: New("boom"), 111 | }, 112 | "WrappedError": { 113 | err: Wrap(Wrap(New("boom"), "interstitial context"), "very important context"), 114 | want: New("boom"), 115 | }, 116 | } 117 | 118 | for name, tc := range cases { 119 | t.Run(name, func(t *testing.T) { 120 | got := Cause(tc.err) 121 | if diff := cmp.Diff(tc.want, got, test.EquateErrors()); diff != "" { 122 | t.Errorf("Cause(...): -want, +got:\n%s", diff) 123 | } 124 | }) 125 | } 126 | } 127 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/crossplane/function-sdk-go 2 | 3 | go 1.23 4 | 5 | toolchain go1.23.2 6 | 7 | require ( 8 | github.com/bufbuild/buf v1.42.0 9 | github.com/crossplane/crossplane-runtime v1.18.0 10 | github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 11 | github.com/go-logr/logr v1.4.2 12 | github.com/go-logr/zapr v1.3.0 13 | github.com/google/go-cmp v0.6.0 14 | github.com/pkg/errors v0.9.1 15 | github.com/upbound/provider-aws v1.14.0 16 | go.uber.org/zap v1.27.0 17 | google.golang.org/grpc v1.67.0 18 | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 19 | google.golang.org/protobuf v1.34.3-0.20240816073751-94ecbc261689 20 | k8s.io/api v0.31.0 21 | k8s.io/apimachinery v0.31.0 22 | k8s.io/utils v0.0.0-20240902221715-702e33fdd3c3 23 | sigs.k8s.io/yaml v1.4.0 24 | ) 25 | 26 | require ( 27 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.34.2-20240904181154-a0be11449112.2 // indirect 28 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.2-20240717164558-a6c49f84cc0f.2 // indirect 29 | buf.build/gen/go/bufbuild/registry/connectrpc/go v1.16.2-20240821192916-45ba72cdd479.1 // indirect 30 | buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.34.2-20240821192916-45ba72cdd479.2 // indirect 31 | buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.34.2-20240828222655-5345c0a56177.2 // indirect 32 | buf.build/go/bufplugin v0.2.0 // indirect 33 | buf.build/go/protoyaml v0.2.0 // indirect 34 | connectrpc.com/connect v1.16.2 // indirect 35 | connectrpc.com/otelconnect v0.7.1 // indirect 36 | dario.cat/mergo v1.0.1 // indirect 37 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 38 | github.com/Microsoft/go-winio v0.6.2 // indirect 39 | github.com/Microsoft/hcsshim v0.12.6 // indirect 40 | github.com/agext/levenshtein v1.2.3 // indirect 41 | github.com/antchfx/htmlquery v1.2.4 // indirect 42 | github.com/antchfx/xpath v1.2.0 // indirect 43 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 44 | github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect 45 | github.com/beorn7/perks v1.0.1 // indirect 46 | github.com/blang/semver/v4 v4.0.0 // indirect 47 | github.com/bufbuild/protocompile v0.14.1 // indirect 48 | github.com/bufbuild/protoplugin v0.0.0-20240911180120-7bb73e41a54a // indirect 49 | github.com/bufbuild/protovalidate-go v0.6.5 // indirect 50 | github.com/cespare/xxhash/v2 v2.3.0 // indirect 51 | github.com/containerd/cgroups/v3 v3.0.3 // indirect 52 | github.com/containerd/containerd v1.7.22 // indirect 53 | github.com/containerd/continuity v0.4.3 // indirect 54 | github.com/containerd/errdefs v0.2.0 // indirect 55 | github.com/containerd/log v0.1.0 // indirect 56 | github.com/containerd/platforms v0.2.1 // indirect 57 | github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect 58 | github.com/containerd/ttrpc v1.2.5 // indirect 59 | github.com/containerd/typeurl/v2 v2.2.0 // indirect 60 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 61 | github.com/crossplane/upjet v1.4.1-0.20240911184956-3afbb7796d46 // indirect 62 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 63 | github.com/distribution/reference v0.6.0 // indirect 64 | github.com/docker/cli v27.2.1+incompatible // indirect 65 | github.com/docker/distribution v2.8.3+incompatible // indirect 66 | github.com/docker/docker v27.2.1+incompatible // indirect 67 | github.com/docker/docker-credential-helpers v0.8.2 // indirect 68 | github.com/docker/go-connections v0.5.0 // indirect 69 | github.com/docker/go-units v0.5.0 // indirect 70 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 71 | github.com/evanphx/json-patch v5.9.0+incompatible // indirect 72 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 73 | github.com/fatih/camelcase v1.0.0 // indirect 74 | github.com/fatih/color v1.17.0 // indirect 75 | github.com/felixge/fgprof v0.9.5 // indirect 76 | github.com/felixge/httpsnoop v1.0.4 // indirect 77 | github.com/fsnotify/fsnotify v1.7.0 // indirect 78 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 79 | github.com/go-chi/chi/v5 v5.1.0 // indirect 80 | github.com/go-logr/stdr v1.2.2 // indirect 81 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 82 | github.com/go-openapi/jsonreference v0.20.2 // indirect 83 | github.com/go-openapi/swag v0.22.4 // indirect 84 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 85 | github.com/gobuffalo/flect v1.0.2 // indirect 86 | github.com/gofrs/flock v0.12.1 // indirect 87 | github.com/gofrs/uuid/v5 v5.3.0 // indirect 88 | github.com/gogo/protobuf v1.3.2 // indirect 89 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 90 | github.com/golang/protobuf v1.5.4 // indirect 91 | github.com/google/cel-go v0.21.0 // indirect 92 | github.com/google/gnostic-models v0.6.8 // indirect 93 | github.com/google/go-containerregistry v0.20.2 // indirect 94 | github.com/google/gofuzz v1.2.0 // indirect 95 | github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // indirect 96 | github.com/google/uuid v1.6.0 // indirect 97 | github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 // indirect 98 | github.com/hashicorp/go-hclog v1.6.3 // indirect 99 | github.com/hashicorp/go-uuid v1.0.3 // indirect 100 | github.com/hashicorp/go-version v1.7.0 // indirect 101 | github.com/hashicorp/hcl/v2 v2.21.0 // indirect 102 | github.com/hashicorp/logutils v1.0.0 // indirect 103 | github.com/hashicorp/terraform-json v0.22.1 // indirect 104 | github.com/hashicorp/terraform-plugin-framework v1.10.0 // indirect 105 | github.com/hashicorp/terraform-plugin-go v0.23.0 // indirect 106 | github.com/hashicorp/terraform-plugin-log v0.9.0 // indirect 107 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.34.0 // indirect 108 | github.com/iancoleman/strcase v0.2.0 // indirect 109 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 110 | github.com/jdx/go-netrc v1.0.0 // indirect 111 | github.com/josharian/intern v1.0.0 // indirect 112 | github.com/json-iterator/go v1.1.12 // indirect 113 | github.com/klauspost/compress v1.17.9 // indirect 114 | github.com/klauspost/pgzip v1.2.6 // indirect 115 | github.com/mailru/easyjson v0.7.7 // indirect 116 | github.com/mattn/go-colorable v0.1.13 // indirect 117 | github.com/mattn/go-isatty v0.0.20 // indirect 118 | github.com/mitchellh/copystructure v1.2.0 // indirect 119 | github.com/mitchellh/go-homedir v1.1.0 // indirect 120 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 121 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 122 | github.com/mitchellh/mapstructure v1.5.0 // indirect 123 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 124 | github.com/moby/docker-image-spec v1.3.1 // indirect 125 | github.com/moby/locker v1.0.1 // indirect 126 | github.com/moby/patternmatcher v0.6.0 // indirect 127 | github.com/moby/sys/mount v0.3.4 // indirect 128 | github.com/moby/sys/mountinfo v0.7.2 // indirect 129 | github.com/moby/sys/sequential v0.6.0 // indirect 130 | github.com/moby/sys/user v0.3.0 // indirect 131 | github.com/moby/sys/userns v0.1.0 // indirect 132 | github.com/moby/term v0.5.0 // indirect 133 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 134 | github.com/modern-go/reflect2 v1.0.2 // indirect 135 | github.com/morikuni/aec v1.0.0 // indirect 136 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 137 | github.com/onsi/ginkgo/v2 v2.20.2 // indirect 138 | github.com/opencontainers/go-digest v1.0.0 // indirect 139 | github.com/opencontainers/image-spec v1.1.0 // indirect 140 | github.com/opencontainers/runtime-spec v1.2.0 // indirect 141 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 142 | github.com/pkg/profile v1.7.0 // indirect 143 | github.com/prometheus/client_golang v1.19.1 // indirect 144 | github.com/prometheus/client_model v0.6.1 // indirect 145 | github.com/prometheus/common v0.55.0 // indirect 146 | github.com/prometheus/procfs v0.15.1 // indirect 147 | github.com/quic-go/qpack v0.5.1 // indirect 148 | github.com/quic-go/quic-go v0.48.2 // indirect 149 | github.com/rs/cors v1.11.1 // indirect 150 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 151 | github.com/sirupsen/logrus v1.9.3 // indirect 152 | github.com/spf13/afero v1.11.0 // indirect 153 | github.com/spf13/cobra v1.8.1 // indirect 154 | github.com/spf13/pflag v1.0.5 // indirect 155 | github.com/stoewer/go-strcase v1.3.0 // indirect 156 | github.com/tmccombs/hcl2json v0.3.3 // indirect 157 | github.com/vbatts/tar-split v0.11.5 // indirect 158 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect 159 | github.com/vmihailenco/msgpack/v5 v5.4.1 // indirect 160 | github.com/vmihailenco/tagparser/v2 v2.0.0 // indirect 161 | github.com/x448/float16 v0.8.4 // indirect 162 | github.com/yuin/goldmark v1.4.13 // indirect 163 | github.com/zclconf/go-cty v1.14.4 // indirect 164 | go.opencensus.io v0.24.0 // indirect 165 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect 166 | go.opentelemetry.io/otel v1.30.0 // indirect 167 | go.opentelemetry.io/otel/metric v1.30.0 // indirect 168 | go.opentelemetry.io/otel/sdk v1.30.0 // indirect 169 | go.opentelemetry.io/otel/trace v1.30.0 // indirect 170 | go.uber.org/atomic v1.11.0 // indirect 171 | go.uber.org/mock v0.4.0 // indirect 172 | go.uber.org/multierr v1.11.0 // indirect 173 | golang.org/x/crypto v0.31.0 // indirect 174 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 175 | golang.org/x/mod v0.21.0 // indirect 176 | golang.org/x/net v0.33.0 // indirect 177 | golang.org/x/oauth2 v0.22.0 // indirect 178 | golang.org/x/sync v0.10.0 // indirect 179 | golang.org/x/sys v0.28.0 // indirect 180 | golang.org/x/term v0.27.0 // indirect 181 | golang.org/x/text v0.21.0 // indirect 182 | golang.org/x/time v0.5.0 // indirect 183 | golang.org/x/tools v0.25.0 // indirect 184 | gomodules.xyz/jsonpatch/v2 v2.4.0 // indirect 185 | google.golang.org/appengine v1.6.8 // indirect 186 | google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect 187 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect 188 | gopkg.in/inf.v0 v0.9.1 // indirect 189 | gopkg.in/yaml.v2 v2.4.0 // indirect 190 | gopkg.in/yaml.v3 v3.0.1 // indirect 191 | k8s.io/apiextensions-apiserver v0.31.0 // indirect 192 | k8s.io/client-go v0.31.0 // indirect 193 | k8s.io/component-base v0.31.0 // indirect 194 | k8s.io/klog/v2 v2.130.1 // indirect 195 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 196 | pluginrpc.com/pluginrpc v0.3.0 // indirect 197 | sigs.k8s.io/controller-runtime v0.19.0 // indirect 198 | sigs.k8s.io/controller-tools v0.16.0 // indirect 199 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 200 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 201 | ) 202 | -------------------------------------------------------------------------------- /logging/logging.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package logging provides function's recommended logging interface. 18 | // 19 | // Mainly a proxy for github.com/crossplane/crossplane-runtime/pkg/logging at 20 | // the moment, but could diverge in the future if we see it fit. 21 | package logging 22 | 23 | import ( 24 | "github.com/go-logr/logr" 25 | "github.com/go-logr/zapr" 26 | "go.uber.org/zap" 27 | 28 | "github.com/crossplane/crossplane-runtime/pkg/logging" 29 | 30 | "github.com/crossplane/function-sdk-go/errors" 31 | ) 32 | 33 | // A Logger logs messages. Messages may be supplemented by structured data. 34 | type Logger logging.Logger 35 | 36 | // NewNopLogger returns a Logger that does nothing. 37 | func NewNopLogger() Logger { return logging.NewNopLogger() } 38 | 39 | // NewLogrLogger returns a Logger that is satisfied by the supplied logr.Logger, 40 | // which may be satisfied in turn by various logging implementations (Zap, klog, 41 | // etc). Debug messages are logged at V(1). 42 | func NewLogrLogger(l logr.Logger) Logger { 43 | return logging.NewLogrLogger(l) 44 | } 45 | 46 | // NewLogger returns a new logger. 47 | func NewLogger(debug bool) (logging.Logger, error) { 48 | o := []zap.Option{zap.AddCallerSkip(1)} 49 | if debug { 50 | zl, err := zap.NewDevelopment(o...) 51 | return NewLogrLogger(zapr.NewLogger(zl)), errors.Wrap(err, "cannot create development zap logger") 52 | } 53 | zl, err := zap.NewProduction(o...) 54 | return NewLogrLogger(zapr.NewLogger(zl)), errors.Wrap(err, "cannot create production zap logger") 55 | } 56 | -------------------------------------------------------------------------------- /proto/buf.gen.yaml: -------------------------------------------------------------------------------- 1 | # This file contains configuration for the `buf generate` command. 2 | # See generate.go for more details. 3 | version: v1 4 | plugins: 5 | - plugin: go 6 | out: . 7 | opt: paths=source_relative 8 | - plugin: go-grpc 9 | out: . 10 | opt: paths=source_relative -------------------------------------------------------------------------------- /proto/generate.go: -------------------------------------------------------------------------------- 1 | //go:build generate 2 | // +build generate 3 | 4 | /* 5 | Copyright 2019 The Crossplane Authors. 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | */ 19 | 20 | // NOTE(negz): See the below link for details on what is happening here. 21 | // https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 22 | 23 | // Generate gRPC types and stubs. 24 | // 25 | // We use buf rather than the traditional protoc because it's pure go and can 26 | // thus be invoked using go run from a pinned dependency. If we used protoc we'd 27 | // need to install it somehow, and there are not currently statically compiled 28 | // binaries available for download (the release binaries for Linux are 29 | // dynamically linked). See buf.gen.yaml for buf's configuration. 30 | // 31 | // We go install the required plugins because they need to be in $PATH for buf 32 | // (or protoc) to invoke them. 33 | 34 | //go:generate go install google.golang.org/protobuf/cmd/protoc-gen-go google.golang.org/grpc/cmd/protoc-gen-go-grpc 35 | //go:generate go run github.com/bufbuild/buf/cmd/buf generate 36 | 37 | package proto 38 | 39 | import ( 40 | _ "github.com/bufbuild/buf/cmd/buf" //nolint:typecheck 41 | _ "google.golang.org/grpc/cmd/protoc-gen-go-grpc" //nolint:typecheck 42 | _ "google.golang.org/protobuf/cmd/protoc-gen-go" //nolint:typecheck 43 | ) 44 | -------------------------------------------------------------------------------- /proto/v1/run_function.proto: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | syntax = "proto3"; 18 | 19 | import "google/protobuf/struct.proto"; 20 | import "google/protobuf/duration.proto"; 21 | 22 | package apiextensions.fn.proto.v1; 23 | 24 | option go_package = "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1"; 25 | 26 | // A FunctionRunnerService is a Composition Function. 27 | service FunctionRunnerService { 28 | // RunFunction runs the Composition Function. 29 | rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {} 30 | } 31 | 32 | // A RunFunctionRequest requests that the Composition Function be run. 33 | message RunFunctionRequest { 34 | // Metadata pertaining to this request. 35 | RequestMeta meta = 1; 36 | 37 | // The observed state prior to invocation of a Function pipeline. State passed 38 | // to each Function is fresh as of the time the pipeline was invoked, not as 39 | // of the time each Function was invoked. 40 | State observed = 2; 41 | 42 | // Desired state according to a Function pipeline. The state passed to a 43 | // particular Function may have been accumulated by previous Functions in the 44 | // pipeline. 45 | // 46 | // Note that the desired state must be a partial object with only the fields 47 | // that this function (and its predecessors in the pipeline) wants to have 48 | // set in the object. Copying a non-partial observed state to desired is most 49 | // likely not what you want to do. Leaving out fields that had been returned 50 | // as desired before will result in them being deleted from the objects in the 51 | // cluster. 52 | State desired = 3; 53 | 54 | // Optional input specific to this Function invocation. A JSON representation 55 | // of the 'input' block of the relevant entry in a Composition's pipeline. 56 | optional google.protobuf.Struct input = 4; 57 | 58 | // Optional context. Crossplane may pass arbitary contextual information to a 59 | // Function. A Function may also return context in its RunFunctionResponse, 60 | // and that context will be passed to subsequent Functions. Crossplane 61 | // discards all context returned by the last Function in the pipeline. 62 | optional google.protobuf.Struct context = 5; 63 | 64 | // Optional extra resources that the Function required. 65 | // Note that extra resources is a map to Resources, plural. 66 | // The map key corresponds to the key in a RunFunctionResponse's 67 | // extra_resources field. If a Function requested extra resources that 68 | // did not exist, Crossplane sets the map key to an empty Resources message to 69 | // indicate that it attempted to satisfy the request. 70 | map extra_resources = 6; 71 | 72 | // Optional credentials that this Function may use to communicate with an 73 | // external system. 74 | map credentials = 7; 75 | } 76 | 77 | // Credentials that a Function may use to communicate with an external system. 78 | message Credentials { 79 | // Source of the credentials. 80 | oneof source { 81 | // Credential data loaded by Crossplane, for example from a Secret. 82 | CredentialData credential_data = 1; 83 | } 84 | } 85 | 86 | // CredentialData loaded by Crossplane, for example from a Secret. 87 | message CredentialData { 88 | map data = 1; 89 | } 90 | 91 | // Resources represents the state of several Crossplane resources. 92 | message Resources { 93 | repeated Resource items = 1; 94 | } 95 | 96 | // A RunFunctionResponse contains the result of a Composition Function run. 97 | message RunFunctionResponse { 98 | // Metadata pertaining to this response. 99 | ResponseMeta meta = 1; 100 | 101 | // Desired state according to a Function pipeline. Functions may add desired 102 | // state, and may mutate or delete any part of the desired state they are 103 | // concerned with. A Function must pass through any part of the desired state 104 | // that it is not concerned with. 105 | // 106 | // 107 | // Note that the desired state must be a partial object with only the fields 108 | // that this function (and its predecessors in the pipeline) wants to have 109 | // set in the object. Copying a non-partial observed state to desired is most 110 | // likely not what you want to do. Leaving out fields that had been returned 111 | // as desired before will result in them being deleted from the objects in the 112 | // cluster. 113 | State desired = 2; 114 | 115 | // Results of the Function run. Results are used for observability purposes. 116 | repeated Result results = 3; 117 | 118 | // Optional context to be passed to the next Function in the pipeline as part 119 | // of the RunFunctionRequest. Dropped on the last function in the pipeline. 120 | optional google.protobuf.Struct context = 4; 121 | 122 | // Requirements that must be satisfied for this Function to run successfully. 123 | Requirements requirements = 5; 124 | 125 | // Status conditions to be applied to the composite resource. Conditions may also 126 | // optionally be applied to the composite resource's associated claim. 127 | repeated Condition conditions = 6; 128 | } 129 | 130 | // RequestMeta contains metadata pertaining to a RunFunctionRequest. 131 | message RequestMeta { 132 | // An opaque string identifying the content of the request. Two identical 133 | // requests should have the same tag. 134 | string tag = 1; 135 | } 136 | 137 | // Requirements that must be satisfied for a Function to run successfully. 138 | message Requirements { 139 | // Extra resources that this Function requires. 140 | // The map key uniquely identifies the group of resources. 141 | map extra_resources = 1; 142 | } 143 | 144 | // ResourceSelector selects a group of resources, either by name or by label. 145 | message ResourceSelector { 146 | // API version of resources to select. 147 | string api_version = 1; 148 | 149 | // Kind of resources to select. 150 | string kind = 2; 151 | 152 | // Resources to match. 153 | oneof match { 154 | // Match the resource with this name. 155 | string match_name = 3; 156 | 157 | // Match all resources with these labels. 158 | MatchLabels match_labels = 4; 159 | } 160 | } 161 | 162 | // MatchLabels defines a set of labels to match resources against. 163 | message MatchLabels { 164 | map labels = 1; 165 | } 166 | 167 | // ResponseMeta contains metadata pertaining to a RunFunctionResponse. 168 | message ResponseMeta { 169 | // An opaque string identifying the content of the request. Must match the 170 | // meta.tag of the corresponding RunFunctionRequest. 171 | string tag = 1; 172 | 173 | // Time-to-live of this response. Deterministic Functions with no side-effects 174 | // (e.g. simple templating Functions) may specify a TTL. Crossplane may choose 175 | // to cache responses until the TTL expires. 176 | optional google.protobuf.Duration ttl = 2; 177 | } 178 | 179 | // State of the composite resource (XR) and any composed resources. 180 | message State { 181 | // The state of the composite resource (XR). 182 | Resource composite = 1; 183 | 184 | // The state of any composed resources. 185 | map resources = 2; 186 | } 187 | 188 | // A Resource represents the state of a composite or composed resource. 189 | message Resource { 190 | // The JSON representation of the resource. 191 | // 192 | // * Crossplane will set this field in a RunFunctionRequest to the entire 193 | // observed state of a resource - including its metadata, spec, and status. 194 | // 195 | // * A Function should set this field in a RunFunctionRequest to communicate 196 | // the desired state of a composite or composed resource. 197 | // 198 | // * A Function may only specify the desired status of a composite resource - 199 | // not its metadata or spec. A Function should not return desired metadata 200 | // or spec for a composite resource. This will be ignored. 201 | // 202 | // * A Function may not specify the desired status of a composed resource - 203 | // only its metadata and spec. A Function should not return desired status 204 | // for a composed resource. This will be ignored. 205 | google.protobuf.Struct resource = 1; 206 | 207 | // The resource's connection details. 208 | // 209 | // * Crossplane will set this field in a RunFunctionRequest to communicate the 210 | // the observed connection details of a composite or composed resource. 211 | // 212 | // * A Function should set this field in a RunFunctionResponse to indicate the 213 | // desired connection details of the composite resource. 214 | // 215 | // * A Function should not set this field in a RunFunctionResponse to indicate 216 | // the desired connection details of a composed resource. This will be 217 | // ignored. 218 | map connection_details = 2; 219 | 220 | // Ready indicates whether the resource should be considered ready. 221 | // 222 | // * Crossplane will never set this field in a RunFunctionRequest. 223 | // 224 | // * A Function should set this field to READY_TRUE in a RunFunctionResponse 225 | // to indicate that a desired composed resource is ready. 226 | // 227 | // * A Function should not set this field in a RunFunctionResponse to indicate 228 | // that the desired composite resource is ready. This will be ignored. 229 | Ready ready = 3; 230 | } 231 | 232 | // Ready indicates whether a composed resource should be considered ready. 233 | enum Ready { 234 | READY_UNSPECIFIED = 0; 235 | 236 | // True means the composed resource has been observed to be ready. 237 | READY_TRUE = 1; 238 | 239 | // False means the composed resource has not been observed to be ready. 240 | READY_FALSE = 2; 241 | } 242 | 243 | // A Result of running a Function. 244 | message Result { 245 | // Severity of this result. 246 | Severity severity = 1; 247 | 248 | // Human-readable details about the result. 249 | string message = 2; 250 | 251 | // Optional PascalCase, machine-readable reason for this result. If omitted, 252 | // the value will be ComposeResources. 253 | optional string reason = 3; 254 | 255 | // The resources this result targets. 256 | optional Target target = 4; 257 | } 258 | 259 | // Severity of Function results. 260 | enum Severity { 261 | SEVERITY_UNSPECIFIED = 0; 262 | 263 | // Fatal results are fatal; subsequent Composition Functions may run, but 264 | // the Composition Function pipeline run will be considered a failure and 265 | // the first fatal result will be returned as an error. 266 | SEVERITY_FATAL = 1; 267 | 268 | // Warning results are non-fatal; the entire Composition will run to 269 | // completion but warning events and debug logs associated with the 270 | // composite resource will be emitted. 271 | SEVERITY_WARNING = 2; 272 | 273 | // Normal results are emitted as normal events and debug logs associated 274 | // with the composite resource. 275 | SEVERITY_NORMAL = 3; 276 | } 277 | 278 | // Target of Function results and conditions. 279 | enum Target { 280 | // If the target is unspecified, the result targets the composite resource. 281 | TARGET_UNSPECIFIED = 0; 282 | 283 | // Target the composite resource. Results that target the composite resource 284 | // should include detailed, advanced information. 285 | TARGET_COMPOSITE = 1; 286 | 287 | // Target the composite and the claim. Results that target the composite and 288 | // the claim should include only end-user friendly information. 289 | TARGET_COMPOSITE_AND_CLAIM = 2; 290 | } 291 | 292 | // Status condition to be applied to the composite resource. Condition may also 293 | // optionally be applied to the composite resource's associated claim. For 294 | // detailed information on proper usage of status conditions, please see 295 | // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties. 296 | message Condition { 297 | // Type of condition in PascalCase. 298 | string type = 1; 299 | 300 | // Status of the condition. 301 | Status status = 2; 302 | 303 | // Reason contains a programmatic identifier indicating the reason for the 304 | // condition's last transition. Producers of specific condition types may 305 | // define expected values and meanings for this field, and whether the values 306 | // are considered a guaranteed API. The value should be a PascalCase string. 307 | // This field may not be empty. 308 | string reason = 3; 309 | 310 | // Message is a human readable message indicating details about the 311 | // transition. This may be an empty string. 312 | optional string message = 4; 313 | 314 | // The resources this condition targets. 315 | optional Target target = 5; 316 | } 317 | 318 | enum Status { 319 | STATUS_CONDITION_UNSPECIFIED = 0; 320 | 321 | STATUS_CONDITION_UNKNOWN = 1; 322 | 323 | STATUS_CONDITION_TRUE = 2; 324 | 325 | STATUS_CONDITION_FALSE = 3; 326 | } 327 | -------------------------------------------------------------------------------- /proto/v1/run_function_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // 2 | //Copyright 2022 The Crossplane Authors. 3 | // 4 | //Licensed under the Apache License, Version 2.0 (the "License"); 5 | //you may not use this file except in compliance with the License. 6 | //You may obtain a copy of the License at 7 | // 8 | //http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | //Unless required by applicable law or agreed to in writing, software 11 | //distributed under the License is distributed on an "AS IS" BASIS, 12 | //WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | //See the License for the specific language governing permissions and 14 | //limitations under the License. 15 | 16 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 17 | // versions: 18 | // - protoc-gen-go-grpc v1.5.1 19 | // - protoc (unknown) 20 | // source: v1/run_function.proto 21 | 22 | package v1 23 | 24 | import ( 25 | context "context" 26 | grpc "google.golang.org/grpc" 27 | codes "google.golang.org/grpc/codes" 28 | status "google.golang.org/grpc/status" 29 | ) 30 | 31 | // This is a compile-time assertion to ensure that this generated file 32 | // is compatible with the grpc package it is being compiled against. 33 | // Requires gRPC-Go v1.64.0 or later. 34 | const _ = grpc.SupportPackageIsVersion9 35 | 36 | const ( 37 | FunctionRunnerService_RunFunction_FullMethodName = "/apiextensions.fn.proto.v1.FunctionRunnerService/RunFunction" 38 | ) 39 | 40 | // FunctionRunnerServiceClient is the client API for FunctionRunnerService service. 41 | // 42 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 43 | // 44 | // A FunctionRunnerService is a Composition Function. 45 | type FunctionRunnerServiceClient interface { 46 | // RunFunction runs the Composition Function. 47 | RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) 48 | } 49 | 50 | type functionRunnerServiceClient struct { 51 | cc grpc.ClientConnInterface 52 | } 53 | 54 | func NewFunctionRunnerServiceClient(cc grpc.ClientConnInterface) FunctionRunnerServiceClient { 55 | return &functionRunnerServiceClient{cc} 56 | } 57 | 58 | func (c *functionRunnerServiceClient) RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) { 59 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 60 | out := new(RunFunctionResponse) 61 | err := c.cc.Invoke(ctx, FunctionRunnerService_RunFunction_FullMethodName, in, out, cOpts...) 62 | if err != nil { 63 | return nil, err 64 | } 65 | return out, nil 66 | } 67 | 68 | // FunctionRunnerServiceServer is the server API for FunctionRunnerService service. 69 | // All implementations must embed UnimplementedFunctionRunnerServiceServer 70 | // for forward compatibility. 71 | // 72 | // A FunctionRunnerService is a Composition Function. 73 | type FunctionRunnerServiceServer interface { 74 | // RunFunction runs the Composition Function. 75 | RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) 76 | mustEmbedUnimplementedFunctionRunnerServiceServer() 77 | } 78 | 79 | // UnimplementedFunctionRunnerServiceServer must be embedded to have 80 | // forward compatible implementations. 81 | // 82 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 83 | // pointer dereference when methods are called. 84 | type UnimplementedFunctionRunnerServiceServer struct{} 85 | 86 | func (UnimplementedFunctionRunnerServiceServer) RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) { 87 | return nil, status.Errorf(codes.Unimplemented, "method RunFunction not implemented") 88 | } 89 | func (UnimplementedFunctionRunnerServiceServer) mustEmbedUnimplementedFunctionRunnerServiceServer() {} 90 | func (UnimplementedFunctionRunnerServiceServer) testEmbeddedByValue() {} 91 | 92 | // UnsafeFunctionRunnerServiceServer may be embedded to opt out of forward compatibility for this service. 93 | // Use of this interface is not recommended, as added methods to FunctionRunnerServiceServer will 94 | // result in compilation errors. 95 | type UnsafeFunctionRunnerServiceServer interface { 96 | mustEmbedUnimplementedFunctionRunnerServiceServer() 97 | } 98 | 99 | func RegisterFunctionRunnerServiceServer(s grpc.ServiceRegistrar, srv FunctionRunnerServiceServer) { 100 | // If the following call pancis, it indicates UnimplementedFunctionRunnerServiceServer was 101 | // embedded by pointer and is nil. This will cause panics if an 102 | // unimplemented method is ever invoked, so we test this at initialization 103 | // time to prevent it from happening at runtime later due to I/O. 104 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 105 | t.testEmbeddedByValue() 106 | } 107 | s.RegisterService(&FunctionRunnerService_ServiceDesc, srv) 108 | } 109 | 110 | func _FunctionRunnerService_RunFunction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 111 | in := new(RunFunctionRequest) 112 | if err := dec(in); err != nil { 113 | return nil, err 114 | } 115 | if interceptor == nil { 116 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, in) 117 | } 118 | info := &grpc.UnaryServerInfo{ 119 | Server: srv, 120 | FullMethod: FunctionRunnerService_RunFunction_FullMethodName, 121 | } 122 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 123 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, req.(*RunFunctionRequest)) 124 | } 125 | return interceptor(ctx, in, info, handler) 126 | } 127 | 128 | // FunctionRunnerService_ServiceDesc is the grpc.ServiceDesc for FunctionRunnerService service. 129 | // It's only intended for direct use with grpc.RegisterService, 130 | // and not to be introspected or modified (even as a copy) 131 | var FunctionRunnerService_ServiceDesc = grpc.ServiceDesc{ 132 | ServiceName: "apiextensions.fn.proto.v1.FunctionRunnerService", 133 | HandlerType: (*FunctionRunnerServiceServer)(nil), 134 | Methods: []grpc.MethodDesc{ 135 | { 136 | MethodName: "RunFunction", 137 | Handler: _FunctionRunnerService_RunFunction_Handler, 138 | }, 139 | }, 140 | Streams: []grpc.StreamDesc{}, 141 | Metadata: "v1/run_function.proto", 142 | } 143 | -------------------------------------------------------------------------------- /proto/v1beta1/run_function.proto: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | syntax = "proto3"; 18 | 19 | import "google/protobuf/struct.proto"; 20 | import "google/protobuf/duration.proto"; 21 | 22 | // Generated from apiextensions/fn/proto/v1/run_function.proto by ../hack/duplicate_proto_type.sh. DO NOT EDIT. 23 | 24 | package apiextensions.fn.proto.v1beta1; 25 | 26 | option go_package = "github.com/crossplane/crossplane/apis/apiextensions/fn/proto/v1beta1"; 27 | 28 | // A FunctionRunnerService is a Composition Function. 29 | service FunctionRunnerService { 30 | // RunFunction runs the Composition Function. 31 | rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {} 32 | } 33 | 34 | // A RunFunctionRequest requests that the Composition Function be run. 35 | message RunFunctionRequest { 36 | // Metadata pertaining to this request. 37 | RequestMeta meta = 1; 38 | 39 | // The observed state prior to invocation of a Function pipeline. State passed 40 | // to each Function is fresh as of the time the pipeline was invoked, not as 41 | // of the time each Function was invoked. 42 | State observed = 2; 43 | 44 | // Desired state according to a Function pipeline. The state passed to a 45 | // particular Function may have been accumulated by previous Functions in the 46 | // pipeline. 47 | // 48 | // Note that the desired state must be a partial object with only the fields 49 | // that this function (and its predecessors in the pipeline) wants to have 50 | // set in the object. Copying a non-partial observed state to desired is most 51 | // likely not what you want to do. Leaving out fields that had been returned 52 | // as desired before will result in them being deleted from the objects in the 53 | // cluster. 54 | State desired = 3; 55 | 56 | // Optional input specific to this Function invocation. A JSON representation 57 | // of the 'input' block of the relevant entry in a Composition's pipeline. 58 | optional google.protobuf.Struct input = 4; 59 | 60 | // Optional context. Crossplane may pass arbitary contextual information to a 61 | // Function. A Function may also return context in its RunFunctionResponse, 62 | // and that context will be passed to subsequent Functions. Crossplane 63 | // discards all context returned by the last Function in the pipeline. 64 | optional google.protobuf.Struct context = 5; 65 | 66 | // Optional extra resources that the Function required. 67 | // Note that extra resources is a map to Resources, plural. 68 | // The map key corresponds to the key in a RunFunctionResponse's 69 | // extra_resources field. If a Function requested extra resources that 70 | // did not exist, Crossplane sets the map key to an empty Resources message to 71 | // indicate that it attempted to satisfy the request. 72 | map extra_resources = 6; 73 | 74 | // Optional credentials that this Function may use to communicate with an 75 | // external system. 76 | map credentials = 7; 77 | } 78 | 79 | // Credentials that a Function may use to communicate with an external system. 80 | message Credentials { 81 | // Source of the credentials. 82 | oneof source { 83 | // Credential data loaded by Crossplane, for example from a Secret. 84 | CredentialData credential_data = 1; 85 | } 86 | } 87 | 88 | // CredentialData loaded by Crossplane, for example from a Secret. 89 | message CredentialData { 90 | map data = 1; 91 | } 92 | 93 | // Resources represents the state of several Crossplane resources. 94 | message Resources { 95 | repeated Resource items = 1; 96 | } 97 | 98 | // A RunFunctionResponse contains the result of a Composition Function run. 99 | message RunFunctionResponse { 100 | // Metadata pertaining to this response. 101 | ResponseMeta meta = 1; 102 | 103 | // Desired state according to a Function pipeline. Functions may add desired 104 | // state, and may mutate or delete any part of the desired state they are 105 | // concerned with. A Function must pass through any part of the desired state 106 | // that it is not concerned with. 107 | // 108 | // 109 | // Note that the desired state must be a partial object with only the fields 110 | // that this function (and its predecessors in the pipeline) wants to have 111 | // set in the object. Copying a non-partial observed state to desired is most 112 | // likely not what you want to do. Leaving out fields that had been returned 113 | // as desired before will result in them being deleted from the objects in the 114 | // cluster. 115 | State desired = 2; 116 | 117 | // Results of the Function run. Results are used for observability purposes. 118 | repeated Result results = 3; 119 | 120 | // Optional context to be passed to the next Function in the pipeline as part 121 | // of the RunFunctionRequest. Dropped on the last function in the pipeline. 122 | optional google.protobuf.Struct context = 4; 123 | 124 | // Requirements that must be satisfied for this Function to run successfully. 125 | Requirements requirements = 5; 126 | 127 | // Status conditions to be applied to the composite resource. Conditions may also 128 | // optionally be applied to the composite resource's associated claim. 129 | repeated Condition conditions = 6; 130 | } 131 | 132 | // RequestMeta contains metadata pertaining to a RunFunctionRequest. 133 | message RequestMeta { 134 | // An opaque string identifying the content of the request. Two identical 135 | // requests should have the same tag. 136 | string tag = 1; 137 | } 138 | 139 | // Requirements that must be satisfied for a Function to run successfully. 140 | message Requirements { 141 | // Extra resources that this Function requires. 142 | // The map key uniquely identifies the group of resources. 143 | map extra_resources = 1; 144 | } 145 | 146 | // ResourceSelector selects a group of resources, either by name or by label. 147 | message ResourceSelector { 148 | // API version of resources to select. 149 | string api_version = 1; 150 | 151 | // Kind of resources to select. 152 | string kind = 2; 153 | 154 | // Resources to match. 155 | oneof match { 156 | // Match the resource with this name. 157 | string match_name = 3; 158 | 159 | // Match all resources with these labels. 160 | MatchLabels match_labels = 4; 161 | } 162 | } 163 | 164 | // MatchLabels defines a set of labels to match resources against. 165 | message MatchLabels { 166 | map labels = 1; 167 | } 168 | 169 | // ResponseMeta contains metadata pertaining to a RunFunctionResponse. 170 | message ResponseMeta { 171 | // An opaque string identifying the content of the request. Must match the 172 | // meta.tag of the corresponding RunFunctionRequest. 173 | string tag = 1; 174 | 175 | // Time-to-live of this response. Deterministic Functions with no side-effects 176 | // (e.g. simple templating Functions) may specify a TTL. Crossplane may choose 177 | // to cache responses until the TTL expires. 178 | optional google.protobuf.Duration ttl = 2; 179 | } 180 | 181 | // State of the composite resource (XR) and any composed resources. 182 | message State { 183 | // The state of the composite resource (XR). 184 | Resource composite = 1; 185 | 186 | // The state of any composed resources. 187 | map resources = 2; 188 | } 189 | 190 | // A Resource represents the state of a composite or composed resource. 191 | message Resource { 192 | // The JSON representation of the resource. 193 | // 194 | // * Crossplane will set this field in a RunFunctionRequest to the entire 195 | // observed state of a resource - including its metadata, spec, and status. 196 | // 197 | // * A Function should set this field in a RunFunctionRequest to communicate 198 | // the desired state of a composite or composed resource. 199 | // 200 | // * A Function may only specify the desired status of a composite resource - 201 | // not its metadata or spec. A Function should not return desired metadata 202 | // or spec for a composite resource. This will be ignored. 203 | // 204 | // * A Function may not specify the desired status of a composed resource - 205 | // only its metadata and spec. A Function should not return desired status 206 | // for a composed resource. This will be ignored. 207 | google.protobuf.Struct resource = 1; 208 | 209 | // The resource's connection details. 210 | // 211 | // * Crossplane will set this field in a RunFunctionRequest to communicate the 212 | // the observed connection details of a composite or composed resource. 213 | // 214 | // * A Function should set this field in a RunFunctionResponse to indicate the 215 | // desired connection details of the composite resource. 216 | // 217 | // * A Function should not set this field in a RunFunctionResponse to indicate 218 | // the desired connection details of a composed resource. This will be 219 | // ignored. 220 | map connection_details = 2; 221 | 222 | // Ready indicates whether the resource should be considered ready. 223 | // 224 | // * Crossplane will never set this field in a RunFunctionRequest. 225 | // 226 | // * A Function should set this field to READY_TRUE in a RunFunctionResponse 227 | // to indicate that a desired composed resource is ready. 228 | // 229 | // * A Function should not set this field in a RunFunctionResponse to indicate 230 | // that the desired composite resource is ready. This will be ignored. 231 | Ready ready = 3; 232 | } 233 | 234 | // Ready indicates whether a composed resource should be considered ready. 235 | enum Ready { 236 | READY_UNSPECIFIED = 0; 237 | 238 | // True means the composed resource has been observed to be ready. 239 | READY_TRUE = 1; 240 | 241 | // False means the composed resource has not been observed to be ready. 242 | READY_FALSE = 2; 243 | } 244 | 245 | // A Result of running a Function. 246 | message Result { 247 | // Severity of this result. 248 | Severity severity = 1; 249 | 250 | // Human-readable details about the result. 251 | string message = 2; 252 | 253 | // Optional PascalCase, machine-readable reason for this result. If omitted, 254 | // the value will be ComposeResources. 255 | optional string reason = 3; 256 | 257 | // The resources this result targets. 258 | optional Target target = 4; 259 | } 260 | 261 | // Severity of Function results. 262 | enum Severity { 263 | SEVERITY_UNSPECIFIED = 0; 264 | 265 | // Fatal results are fatal; subsequent Composition Functions may run, but 266 | // the Composition Function pipeline run will be considered a failure and 267 | // the first fatal result will be returned as an error. 268 | SEVERITY_FATAL = 1; 269 | 270 | // Warning results are non-fatal; the entire Composition will run to 271 | // completion but warning events and debug logs associated with the 272 | // composite resource will be emitted. 273 | SEVERITY_WARNING = 2; 274 | 275 | // Normal results are emitted as normal events and debug logs associated 276 | // with the composite resource. 277 | SEVERITY_NORMAL = 3; 278 | } 279 | 280 | // Target of Function results and conditions. 281 | enum Target { 282 | // If the target is unspecified, the result targets the composite resource. 283 | TARGET_UNSPECIFIED = 0; 284 | 285 | // Target the composite resource. Results that target the composite resource 286 | // should include detailed, advanced information. 287 | TARGET_COMPOSITE = 1; 288 | 289 | // Target the composite and the claim. Results that target the composite and 290 | // the claim should include only end-user friendly information. 291 | TARGET_COMPOSITE_AND_CLAIM = 2; 292 | } 293 | 294 | // Status condition to be applied to the composite resource. Condition may also 295 | // optionally be applied to the composite resource's associated claim. For 296 | // detailed information on proper usage of status conditions, please see 297 | // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties. 298 | message Condition { 299 | // Type of condition in PascalCase. 300 | string type = 1; 301 | 302 | // Status of the condition. 303 | Status status = 2; 304 | 305 | // Reason contains a programmatic identifier indicating the reason for the 306 | // condition's last transition. Producers of specific condition types may 307 | // define expected values and meanings for this field, and whether the values 308 | // are considered a guaranteed API. The value should be a PascalCase string. 309 | // This field may not be empty. 310 | string reason = 3; 311 | 312 | // Message is a human readable message indicating details about the 313 | // transition. This may be an empty string. 314 | optional string message = 4; 315 | 316 | // The resources this condition targets. 317 | optional Target target = 5; 318 | } 319 | 320 | enum Status { 321 | STATUS_CONDITION_UNSPECIFIED = 0; 322 | 323 | STATUS_CONDITION_UNKNOWN = 1; 324 | 325 | STATUS_CONDITION_TRUE = 2; 326 | 327 | STATUS_CONDITION_FALSE = 3; 328 | } 329 | -------------------------------------------------------------------------------- /proto/v1beta1/run_function_grpc.pb.go: -------------------------------------------------------------------------------- 1 | // 2 | //Copyright 2022 The Crossplane Authors. 3 | // 4 | //Licensed under the Apache License, Version 2.0 (the "License"); 5 | //you may not use this file except in compliance with the License. 6 | //You may obtain a copy of the License at 7 | // 8 | //http://www.apache.org/licenses/LICENSE-2.0 9 | // 10 | //Unless required by applicable law or agreed to in writing, software 11 | //distributed under the License is distributed on an "AS IS" BASIS, 12 | //WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | //See the License for the specific language governing permissions and 14 | //limitations under the License. 15 | 16 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 17 | // versions: 18 | // - protoc-gen-go-grpc v1.5.1 19 | // - protoc (unknown) 20 | // source: v1beta1/run_function.proto 21 | 22 | // Generated from apiextensions/fn/proto/v1/run_function.proto by ../hack/duplicate_proto_type.sh. DO NOT EDIT. 23 | 24 | package v1beta1 25 | 26 | import ( 27 | context "context" 28 | grpc "google.golang.org/grpc" 29 | codes "google.golang.org/grpc/codes" 30 | status "google.golang.org/grpc/status" 31 | ) 32 | 33 | // This is a compile-time assertion to ensure that this generated file 34 | // is compatible with the grpc package it is being compiled against. 35 | // Requires gRPC-Go v1.64.0 or later. 36 | const _ = grpc.SupportPackageIsVersion9 37 | 38 | const ( 39 | FunctionRunnerService_RunFunction_FullMethodName = "/apiextensions.fn.proto.v1beta1.FunctionRunnerService/RunFunction" 40 | ) 41 | 42 | // FunctionRunnerServiceClient is the client API for FunctionRunnerService service. 43 | // 44 | // For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. 45 | // 46 | // A FunctionRunnerService is a Composition Function. 47 | type FunctionRunnerServiceClient interface { 48 | // RunFunction runs the Composition Function. 49 | RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) 50 | } 51 | 52 | type functionRunnerServiceClient struct { 53 | cc grpc.ClientConnInterface 54 | } 55 | 56 | func NewFunctionRunnerServiceClient(cc grpc.ClientConnInterface) FunctionRunnerServiceClient { 57 | return &functionRunnerServiceClient{cc} 58 | } 59 | 60 | func (c *functionRunnerServiceClient) RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) { 61 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 62 | out := new(RunFunctionResponse) 63 | err := c.cc.Invoke(ctx, FunctionRunnerService_RunFunction_FullMethodName, in, out, cOpts...) 64 | if err != nil { 65 | return nil, err 66 | } 67 | return out, nil 68 | } 69 | 70 | // FunctionRunnerServiceServer is the server API for FunctionRunnerService service. 71 | // All implementations must embed UnimplementedFunctionRunnerServiceServer 72 | // for forward compatibility. 73 | // 74 | // A FunctionRunnerService is a Composition Function. 75 | type FunctionRunnerServiceServer interface { 76 | // RunFunction runs the Composition Function. 77 | RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) 78 | mustEmbedUnimplementedFunctionRunnerServiceServer() 79 | } 80 | 81 | // UnimplementedFunctionRunnerServiceServer must be embedded to have 82 | // forward compatible implementations. 83 | // 84 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 85 | // pointer dereference when methods are called. 86 | type UnimplementedFunctionRunnerServiceServer struct{} 87 | 88 | func (UnimplementedFunctionRunnerServiceServer) RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) { 89 | return nil, status.Errorf(codes.Unimplemented, "method RunFunction not implemented") 90 | } 91 | func (UnimplementedFunctionRunnerServiceServer) mustEmbedUnimplementedFunctionRunnerServiceServer() {} 92 | func (UnimplementedFunctionRunnerServiceServer) testEmbeddedByValue() {} 93 | 94 | // UnsafeFunctionRunnerServiceServer may be embedded to opt out of forward compatibility for this service. 95 | // Use of this interface is not recommended, as added methods to FunctionRunnerServiceServer will 96 | // result in compilation errors. 97 | type UnsafeFunctionRunnerServiceServer interface { 98 | mustEmbedUnimplementedFunctionRunnerServiceServer() 99 | } 100 | 101 | func RegisterFunctionRunnerServiceServer(s grpc.ServiceRegistrar, srv FunctionRunnerServiceServer) { 102 | // If the following call pancis, it indicates UnimplementedFunctionRunnerServiceServer was 103 | // embedded by pointer and is nil. This will cause panics if an 104 | // unimplemented method is ever invoked, so we test this at initialization 105 | // time to prevent it from happening at runtime later due to I/O. 106 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 107 | t.testEmbeddedByValue() 108 | } 109 | s.RegisterService(&FunctionRunnerService_ServiceDesc, srv) 110 | } 111 | 112 | func _FunctionRunnerService_RunFunction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 113 | in := new(RunFunctionRequest) 114 | if err := dec(in); err != nil { 115 | return nil, err 116 | } 117 | if interceptor == nil { 118 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, in) 119 | } 120 | info := &grpc.UnaryServerInfo{ 121 | Server: srv, 122 | FullMethod: FunctionRunnerService_RunFunction_FullMethodName, 123 | } 124 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 125 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, req.(*RunFunctionRequest)) 126 | } 127 | return interceptor(ctx, in, info, handler) 128 | } 129 | 130 | // FunctionRunnerService_ServiceDesc is the grpc.ServiceDesc for FunctionRunnerService service. 131 | // It's only intended for direct use with grpc.RegisterService, 132 | // and not to be introspected or modified (even as a copy) 133 | var FunctionRunnerService_ServiceDesc = grpc.ServiceDesc{ 134 | ServiceName: "apiextensions.fn.proto.v1beta1.FunctionRunnerService", 135 | HandlerType: (*FunctionRunnerServiceServer)(nil), 136 | Methods: []grpc.MethodDesc{ 137 | { 138 | MethodName: "RunFunction", 139 | Handler: _FunctionRunnerService_RunFunction_Handler, 140 | }, 141 | }, 142 | Streams: []grpc.StreamDesc{}, 143 | Metadata: "v1beta1/run_function.proto", 144 | } 145 | -------------------------------------------------------------------------------- /request/request.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package request contains utilities for working with RunFunctionRequests. 18 | package request 19 | 20 | import ( 21 | "google.golang.org/protobuf/types/known/structpb" 22 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 23 | "k8s.io/apimachinery/pkg/runtime" 24 | 25 | "github.com/crossplane/function-sdk-go/errors" 26 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 27 | "github.com/crossplane/function-sdk-go/resource" 28 | "github.com/crossplane/function-sdk-go/resource/composed" 29 | "github.com/crossplane/function-sdk-go/resource/composite" 30 | ) 31 | 32 | // GetInput from the supplied request. Input is loaded into the supplied object. 33 | func GetInput(req *v1.RunFunctionRequest, into runtime.Object) error { 34 | return errors.Wrapf(resource.AsObject(req.GetInput(), into), "cannot get function input %T from %T", into, req) 35 | } 36 | 37 | // GetContextKey gets context from the supplied key. 38 | func GetContextKey(req *v1.RunFunctionRequest, key string) (*structpb.Value, bool) { 39 | f := req.GetContext().GetFields() 40 | if f == nil { 41 | return nil, false 42 | } 43 | v, ok := f[key] 44 | return v, ok 45 | } 46 | 47 | // GetObservedCompositeResource from the supplied request. 48 | func GetObservedCompositeResource(req *v1.RunFunctionRequest) (*resource.Composite, error) { 49 | xr := &resource.Composite{ 50 | Resource: composite.New(), 51 | ConnectionDetails: req.GetObserved().GetComposite().GetConnectionDetails(), 52 | } 53 | 54 | if xr.ConnectionDetails == nil { 55 | xr.ConnectionDetails = make(resource.ConnectionDetails) 56 | } 57 | 58 | err := resource.AsObject(req.GetObserved().GetComposite().GetResource(), xr.Resource) 59 | return xr, err 60 | } 61 | 62 | // GetObservedComposedResources from the supplied request. 63 | func GetObservedComposedResources(req *v1.RunFunctionRequest) (map[resource.Name]resource.ObservedComposed, error) { 64 | ocds := map[resource.Name]resource.ObservedComposed{} 65 | for name, r := range req.GetObserved().GetResources() { 66 | ocd := resource.ObservedComposed{Resource: composed.New(), ConnectionDetails: r.GetConnectionDetails()} 67 | 68 | if ocd.ConnectionDetails == nil { 69 | ocd.ConnectionDetails = make(resource.ConnectionDetails) 70 | } 71 | 72 | if err := resource.AsObject(r.GetResource(), ocd.Resource); err != nil { 73 | return nil, err 74 | } 75 | ocds[resource.Name(name)] = ocd 76 | } 77 | return ocds, nil 78 | } 79 | 80 | // GetDesiredCompositeResource from the supplied request. 81 | func GetDesiredCompositeResource(req *v1.RunFunctionRequest) (*resource.Composite, error) { 82 | xr := &resource.Composite{ 83 | Resource: composite.New(), 84 | ConnectionDetails: req.GetDesired().GetComposite().GetConnectionDetails(), 85 | } 86 | 87 | if xr.ConnectionDetails == nil { 88 | xr.ConnectionDetails = make(resource.ConnectionDetails) 89 | } 90 | 91 | err := resource.AsObject(req.GetDesired().GetComposite().GetResource(), xr.Resource) 92 | return xr, err 93 | } 94 | 95 | // GetDesiredComposedResources from the supplied request. 96 | func GetDesiredComposedResources(req *v1.RunFunctionRequest) (map[resource.Name]*resource.DesiredComposed, error) { 97 | dcds := map[resource.Name]*resource.DesiredComposed{} 98 | for name, r := range req.GetDesired().GetResources() { 99 | dcd := &resource.DesiredComposed{Resource: composed.New()} 100 | if err := resource.AsObject(r.GetResource(), dcd.Resource); err != nil { 101 | return nil, err 102 | } 103 | switch r.GetReady() { 104 | case v1.Ready_READY_UNSPECIFIED: 105 | dcd.Ready = resource.ReadyUnspecified 106 | case v1.Ready_READY_TRUE: 107 | dcd.Ready = resource.ReadyTrue 108 | case v1.Ready_READY_FALSE: 109 | dcd.Ready = resource.ReadyFalse 110 | } 111 | dcds[resource.Name(name)] = dcd 112 | } 113 | return dcds, nil 114 | } 115 | 116 | // GetExtraResources from the supplied request. 117 | func GetExtraResources(req *v1.RunFunctionRequest) (map[string][]resource.Extra, error) { 118 | out := make(map[string][]resource.Extra, len(req.GetExtraResources())) 119 | for name, ers := range req.GetExtraResources() { 120 | out[name] = []resource.Extra{} 121 | for _, i := range ers.GetItems() { 122 | r := &resource.Extra{Resource: &unstructured.Unstructured{}} 123 | if err := resource.AsObject(i.GetResource(), r.Resource); err != nil { 124 | return nil, err 125 | } 126 | out[name] = append(out[name], *r) 127 | } 128 | } 129 | return out, nil 130 | } 131 | 132 | // GetCredentials from the supplied request. 133 | func GetCredentials(req *v1.RunFunctionRequest, name string) (resource.Credentials, error) { 134 | cred, exists := req.GetCredentials()[name] 135 | if !exists { 136 | return resource.Credentials{}, errors.Errorf("%s: credential not found", name) 137 | } 138 | 139 | switch t := cred.GetSource().(type) { 140 | case *v1.Credentials_CredentialData: 141 | return resource.Credentials{Type: resource.CredentialsTypeData, Data: cred.GetCredentialData().GetData()}, nil 142 | default: 143 | return resource.Credentials{}, errors.Errorf("%s: not a supported credential source", t) 144 | } 145 | } 146 | -------------------------------------------------------------------------------- /request/request_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package request contains utilities for working with RunFunctionRequests. 18 | package request 19 | 20 | import ( 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 25 | 26 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 27 | "github.com/crossplane/function-sdk-go/resource" 28 | "github.com/crossplane/function-sdk-go/resource/composed" 29 | "github.com/crossplane/function-sdk-go/resource/composite" 30 | ) 31 | 32 | func TestGetObservedCompositeResource(t *testing.T) { 33 | type want struct { 34 | oxr *resource.Composite 35 | err error 36 | } 37 | 38 | cases := map[string]struct { 39 | reason string 40 | req *v1.RunFunctionRequest 41 | want want 42 | }{ 43 | "NoObservedXR": { 44 | reason: "In the unlikely event the request has no observed XR we should return a usable, empty Composite.", 45 | req: &v1.RunFunctionRequest{}, 46 | want: want{ 47 | oxr: &resource.Composite{ 48 | Resource: composite.New(), 49 | ConnectionDetails: resource.ConnectionDetails{}, 50 | }, 51 | }, 52 | }, 53 | "ObservedXR": { 54 | reason: "We should return the XR read from the request.", 55 | req: &v1.RunFunctionRequest{ 56 | Observed: &v1.State{ 57 | Composite: &v1.Resource{ 58 | Resource: resource.MustStructJSON(`{ 59 | "apiVersion": "test.crossplane.io/v1", 60 | "kind": "XR" 61 | }`), 62 | ConnectionDetails: map[string][]byte{ 63 | "super": []byte("secret"), 64 | }, 65 | }, 66 | }, 67 | }, 68 | want: want{ 69 | oxr: &resource.Composite{ 70 | Resource: &composite.Unstructured{Unstructured: unstructured.Unstructured{ 71 | Object: map[string]any{ 72 | "apiVersion": "test.crossplane.io/v1", 73 | "kind": "XR", 74 | }, 75 | }}, 76 | ConnectionDetails: resource.ConnectionDetails{ 77 | "super": []byte("secret"), 78 | }, 79 | }, 80 | }, 81 | }, 82 | } 83 | 84 | for name, tc := range cases { 85 | t.Run(name, func(t *testing.T) { 86 | oxr, err := GetObservedCompositeResource(tc.req) 87 | 88 | if diff := cmp.Diff(tc.want.oxr, oxr); diff != "" { 89 | t.Errorf("\n%s\nGetObservedCompositeResource(...): -want, +got:\n%s", tc.reason, diff) 90 | } 91 | if diff := cmp.Diff(tc.want.err, err); diff != "" { 92 | t.Errorf("\n%s\nGetObservedCompositeResource(...): -want error, +got error:\n%s", tc.reason, diff) 93 | } 94 | }) 95 | } 96 | } 97 | 98 | func TestGetDesiredCompositeResource(t *testing.T) { 99 | type want struct { 100 | oxr *resource.Composite 101 | err error 102 | } 103 | 104 | cases := map[string]struct { 105 | reason string 106 | req *v1.RunFunctionRequest 107 | want want 108 | }{ 109 | "NoDesiredXR": { 110 | reason: "If the request has no desired XR we should return a usable, empty Composite.", 111 | req: &v1.RunFunctionRequest{}, 112 | want: want{ 113 | oxr: &resource.Composite{ 114 | Resource: composite.New(), 115 | ConnectionDetails: resource.ConnectionDetails{}, 116 | }, 117 | }, 118 | }, 119 | "DesiredXR": { 120 | reason: "We should return the XR read from the request.", 121 | req: &v1.RunFunctionRequest{ 122 | Desired: &v1.State{ 123 | Composite: &v1.Resource{ 124 | Resource: resource.MustStructJSON(`{ 125 | "apiVersion": "test.crossplane.io/v1", 126 | "kind": "XR" 127 | }`), 128 | ConnectionDetails: map[string][]byte{ 129 | "super": []byte("secret"), 130 | }, 131 | }, 132 | }, 133 | }, 134 | want: want{ 135 | oxr: &resource.Composite{ 136 | Resource: &composite.Unstructured{Unstructured: unstructured.Unstructured{ 137 | Object: map[string]any{ 138 | "apiVersion": "test.crossplane.io/v1", 139 | "kind": "XR", 140 | }, 141 | }}, 142 | ConnectionDetails: resource.ConnectionDetails{ 143 | "super": []byte("secret"), 144 | }, 145 | }, 146 | }, 147 | }, 148 | } 149 | 150 | for name, tc := range cases { 151 | t.Run(name, func(t *testing.T) { 152 | oxr, err := GetDesiredCompositeResource(tc.req) 153 | 154 | if diff := cmp.Diff(tc.want.oxr, oxr); diff != "" { 155 | t.Errorf("\n%s\nGetDesiredCompositeResource(...): -want, +got:\n%s", tc.reason, diff) 156 | } 157 | if diff := cmp.Diff(tc.want.err, err); diff != "" { 158 | t.Errorf("\n%s\nGetDesiredCompositeResource(...): -want error, +got error:\n%s", tc.reason, diff) 159 | } 160 | }) 161 | } 162 | } 163 | 164 | func TestGetObservedComposedResources(t *testing.T) { 165 | type want struct { 166 | ocds map[resource.Name]resource.ObservedComposed 167 | err error 168 | } 169 | 170 | cases := map[string]struct { 171 | reason string 172 | req *v1.RunFunctionRequest 173 | want want 174 | }{ 175 | "NoObservedComposedResources": { 176 | reason: "If the request has no observed composed resources we should return an empty, non-nil map.", 177 | req: &v1.RunFunctionRequest{}, 178 | want: want{ 179 | ocds: map[resource.Name]resource.ObservedComposed{}, 180 | }, 181 | }, 182 | "ObservedComposedResources": { 183 | reason: "If the request has observed composed resources we should return them.", 184 | req: &v1.RunFunctionRequest{ 185 | Observed: &v1.State{ 186 | Resources: map[string]*v1.Resource{ 187 | "observed-composed-resource": { 188 | Resource: resource.MustStructJSON(`{ 189 | "apiVersion": "test.crossplane.io/v1", 190 | "kind": "Composed" 191 | }`), 192 | ConnectionDetails: map[string][]byte{ 193 | "super": []byte("secret"), 194 | }, 195 | }, 196 | }, 197 | }, 198 | }, 199 | want: want{ 200 | ocds: map[resource.Name]resource.ObservedComposed{ 201 | "observed-composed-resource": { 202 | Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{ 203 | Object: map[string]any{ 204 | "apiVersion": "test.crossplane.io/v1", 205 | "kind": "Composed", 206 | }, 207 | }}, 208 | ConnectionDetails: resource.ConnectionDetails{ 209 | "super": []byte("secret"), 210 | }, 211 | }, 212 | }, 213 | }, 214 | }, 215 | } 216 | 217 | for name, tc := range cases { 218 | t.Run(name, func(t *testing.T) { 219 | ocds, err := GetObservedComposedResources(tc.req) 220 | 221 | if diff := cmp.Diff(tc.want.ocds, ocds); diff != "" { 222 | t.Errorf("\n%s\nGetObservedComposedResources(...): -want, +got:\n%s", tc.reason, diff) 223 | } 224 | if diff := cmp.Diff(tc.want.err, err); diff != "" { 225 | t.Errorf("\n%s\nGetObservedComposedResources(...): -want error, +got error:\n%s", tc.reason, diff) 226 | } 227 | }) 228 | } 229 | } 230 | 231 | func TestGetDesiredComposedResources(t *testing.T) { 232 | type want struct { 233 | dcds map[resource.Name]*resource.DesiredComposed 234 | err error 235 | } 236 | 237 | cases := map[string]struct { 238 | reason string 239 | req *v1.RunFunctionRequest 240 | want want 241 | }{ 242 | "NoDesiredComposedResources": { 243 | reason: "If the request has no desired composed resources we should return an empty, non-nil map.", 244 | req: &v1.RunFunctionRequest{}, 245 | want: want{ 246 | dcds: map[resource.Name]*resource.DesiredComposed{}, 247 | }, 248 | }, 249 | "DesiredComposedResources": { 250 | reason: "If the request has desired composed resources we should return them.", 251 | req: &v1.RunFunctionRequest{ 252 | Desired: &v1.State{ 253 | Resources: map[string]*v1.Resource{ 254 | "desired-composed-resource": { 255 | Resource: resource.MustStructJSON(`{ 256 | "apiVersion": "test.crossplane.io/v1", 257 | "kind": "Composed" 258 | }`), 259 | Ready: v1.Ready_READY_TRUE, 260 | }, 261 | }, 262 | }, 263 | }, 264 | want: want{ 265 | dcds: map[resource.Name]*resource.DesiredComposed{ 266 | "desired-composed-resource": { 267 | Resource: &composed.Unstructured{Unstructured: unstructured.Unstructured{ 268 | Object: map[string]any{ 269 | "apiVersion": "test.crossplane.io/v1", 270 | "kind": "Composed", 271 | }, 272 | }}, 273 | Ready: resource.ReadyTrue, 274 | }, 275 | }, 276 | }, 277 | }, 278 | } 279 | 280 | for name, tc := range cases { 281 | t.Run(name, func(t *testing.T) { 282 | ocds, err := GetDesiredComposedResources(tc.req) 283 | 284 | if diff := cmp.Diff(tc.want.dcds, ocds); diff != "" { 285 | t.Errorf("\n%s\nGetDesiredComposedResources(...): -want, +got:\n%s", tc.reason, diff) 286 | } 287 | if diff := cmp.Diff(tc.want.err, err); diff != "" { 288 | t.Errorf("\n%s\nGetDesiredComposedResources(...): -want error, +got error:\n%s", tc.reason, diff) 289 | } 290 | }) 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /resource/composed/composed.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package composed contains an unstructured composed resource. 18 | package composed 19 | 20 | import ( 21 | "github.com/go-json-experiment/json" 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 24 | "k8s.io/apimachinery/pkg/runtime" 25 | 26 | xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 27 | "github.com/crossplane/crossplane-runtime/pkg/fieldpath" 28 | "github.com/crossplane/crossplane-runtime/pkg/resource" 29 | 30 | "github.com/crossplane/function-sdk-go/errors" 31 | ) 32 | 33 | // Scheme used to determine the type of any runtime.Object passed to From. 34 | var Scheme *runtime.Scheme 35 | 36 | func init() { 37 | Scheme = runtime.NewScheme() 38 | } 39 | 40 | // New returns a new unstructured composed resource. 41 | func New() *Unstructured { 42 | return &Unstructured{unstructured.Unstructured{Object: make(map[string]any)}} 43 | } 44 | 45 | // From creates a new unstructured composed resource from the supplied object. 46 | func From(o runtime.Object) (*Unstructured, error) { 47 | // If the supplied object is already unstructured content, avoid a JSON 48 | // round trip and use it. 49 | if u, ok := o.(interface{ UnstructuredContent() map[string]any }); ok { 50 | return &Unstructured{unstructured.Unstructured{Object: u.UnstructuredContent()}}, nil 51 | } 52 | 53 | // Set the object's GVK from our scheme. 54 | gvks, _, err := Scheme.ObjectKinds(o) 55 | if err != nil { 56 | return nil, errors.Wrap(err, "did you add it to composed.Scheme?") 57 | } 58 | // There should almost never be more than one GVK for a type. 59 | for _, gvk := range gvks { 60 | o.GetObjectKind().SetGroupVersionKind(gvk) 61 | } 62 | 63 | // Round-trip the supplied object through JSON to convert it. We use the 64 | // go-json-experiment package for this because it honors the omitempty field 65 | // for non-pointer struct fields. 66 | // 67 | // At the time of writing many Crossplane structs contain fields that have 68 | // the omitempty struct tag, but non-pointer struct values. pkg/json does 69 | // not omit these fields. It instead includes them as empty JSON objects. 70 | // Crossplane will interpret this as part of a server-side apply fully 71 | // specified intent and assume the function actually has opinion about the 72 | // field when it doesn't. We should make these fields pointers, but it's 73 | // easier and safer in the meantime to work around it here. 74 | // 75 | // https://github.com/go-json-experiment/json#behavior-changes 76 | j, err := json.Marshal(o) 77 | if err != nil { 78 | return nil, err 79 | } 80 | obj := make(map[string]any) 81 | if err := json.Unmarshal(j, &obj); err != nil { 82 | return nil, err 83 | } 84 | 85 | // Unfortunately we still need to cleanup some object metadata. 86 | cleanupMetadata(obj) 87 | 88 | return &Unstructured{unstructured.Unstructured{Object: obj}}, nil 89 | } 90 | 91 | func cleanupMetadata(obj map[string]any) { 92 | m, ok := obj["metadata"] 93 | if !ok { 94 | // If there's no metadata there's nothing to do. 95 | return 96 | } 97 | 98 | mo, ok := m.(map[string]any) 99 | if !ok { 100 | // If metadata isn't an object there's nothing to do. 101 | return 102 | } 103 | 104 | // The ObjectMeta struct that all Kubernetes types include has a non-nil 105 | // integer Generation field with the omitempty tag. Regular pkg/json removes 106 | // this, but go-json-experiment does not (it would need the new omitzero 107 | // tag). So, we clean it up manually. No function should ever be setting it. 108 | delete(mo, "generation") 109 | 110 | // If metadata has no fields, delete it. This prevents us from serializing 111 | // metadata: {}, which SSA would interpret as "make metadata empty". 112 | if len(mo) == 0 { 113 | delete(obj, "metadata") 114 | } 115 | } 116 | 117 | // An Unstructured composed resource. 118 | type Unstructured struct { 119 | unstructured.Unstructured 120 | } 121 | 122 | var _ runtime.Object = &Unstructured{} 123 | var _ metav1.Object = &Unstructured{} 124 | var _ runtime.Unstructured = &Unstructured{} 125 | var _ resource.Composed = &Unstructured{} 126 | 127 | // DeepCopy this composed resource. 128 | func (cd *Unstructured) DeepCopy() *Unstructured { 129 | if cd == nil { 130 | return nil 131 | } 132 | out := new(Unstructured) 133 | *out = *cd 134 | out.Object = runtime.DeepCopyJSON(cd.Object) 135 | return out 136 | } 137 | 138 | // DeepCopyObject of this composed resource. 139 | func (cd *Unstructured) DeepCopyObject() runtime.Object { 140 | return cd.DeepCopy() 141 | } 142 | 143 | // DeepCopyInto the supplied composed resource. 144 | func (cd *Unstructured) DeepCopyInto(out *Unstructured) { 145 | clone := cd.DeepCopy() 146 | *out = *clone 147 | } 148 | 149 | // MarshalJSON for this composed resource. 150 | func (cd *Unstructured) MarshalJSON() ([]byte, error) { 151 | return cd.Unstructured.MarshalJSON() 152 | } 153 | 154 | // GetCondition of this Composed resource. 155 | func (cd *Unstructured) GetCondition(ct xpv1.ConditionType) xpv1.Condition { 156 | conditioned := xpv1.ConditionedStatus{} 157 | // The path is directly `status` because conditions are inline. 158 | if err := fieldpath.Pave(cd.Object).GetValueInto("status", &conditioned); err != nil { 159 | return xpv1.Condition{} 160 | } 161 | return conditioned.GetCondition(ct) 162 | } 163 | 164 | // SetConditions of this Composed resource. 165 | func (cd *Unstructured) SetConditions(c ...xpv1.Condition) { 166 | conditioned := xpv1.ConditionedStatus{} 167 | // The path is directly `status` because conditions are inline. 168 | _ = fieldpath.Pave(cd.Object).GetValueInto("status", &conditioned) 169 | conditioned.SetConditions(c...) 170 | _ = fieldpath.Pave(cd.Object).SetValue("status.conditions", conditioned.Conditions) 171 | } 172 | 173 | // GetWriteConnectionSecretToReference of this Composed resource. 174 | func (cd *Unstructured) GetWriteConnectionSecretToReference() *xpv1.SecretReference { 175 | out := &xpv1.SecretReference{} 176 | if err := fieldpath.Pave(cd.Object).GetValueInto("spec.writeConnectionSecretToRef", out); err != nil { 177 | return nil 178 | } 179 | return out 180 | } 181 | 182 | // SetWriteConnectionSecretToReference of this Composed resource. 183 | func (cd *Unstructured) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) { 184 | _ = fieldpath.Pave(cd.Object).SetValue("spec.writeConnectionSecretToRef", r) 185 | } 186 | 187 | // GetPublishConnectionDetailsTo of this Composed resource. 188 | func (cd *Unstructured) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo { 189 | out := &xpv1.PublishConnectionDetailsTo{} 190 | if err := fieldpath.Pave(cd.Object).GetValueInto("spec.publishConnectionDetailsTo", out); err != nil { 191 | return nil 192 | } 193 | return out 194 | } 195 | 196 | // SetPublishConnectionDetailsTo of this Composed resource. 197 | func (cd *Unstructured) SetPublishConnectionDetailsTo(ref *xpv1.PublishConnectionDetailsTo) { 198 | _ = fieldpath.Pave(cd.Object).SetValue("spec.publishConnectionDetailsTo", ref) 199 | } 200 | 201 | // GetValue of the supplied field path. 202 | func (cd *Unstructured) GetValue(path string) (any, error) { 203 | return fieldpath.Pave(cd.Object).GetValue(path) 204 | } 205 | 206 | // GetValueInto the supplied type. 207 | func (cd *Unstructured) GetValueInto(path string, out any) error { 208 | return fieldpath.Pave(cd.Object).GetValueInto(path, out) 209 | } 210 | 211 | // GetString value of the supplied field path. 212 | func (cd *Unstructured) GetString(path string) (string, error) { 213 | return fieldpath.Pave(cd.Object).GetString(path) 214 | } 215 | 216 | // GetStringArray value of the supplied field path. 217 | func (cd *Unstructured) GetStringArray(path string) ([]string, error) { 218 | return fieldpath.Pave(cd.Object).GetStringArray(path) 219 | } 220 | 221 | // GetStringObject value of the supplied field path. 222 | func (cd *Unstructured) GetStringObject(path string) (map[string]string, error) { 223 | return fieldpath.Pave(cd.Object).GetStringObject(path) 224 | } 225 | 226 | // GetBool value of the supplied field path. 227 | func (cd *Unstructured) GetBool(path string) (bool, error) { 228 | return fieldpath.Pave(cd.Object).GetBool(path) 229 | } 230 | 231 | // GetInteger value of the supplied field path. 232 | func (cd *Unstructured) GetInteger(path string) (int64, error) { 233 | // This is a bit of a hack. Kubernetes JSON decoders will get us a 234 | // map[string]any where number values are int64, but protojson and structpb 235 | // will get us one where number values are float64. 236 | // https://pkg.go.dev/sigs.k8s.io/json#UnmarshalCaseSensitivePreserveInts 237 | p := fieldpath.Pave(cd.Object) 238 | 239 | // If we find an int64, return it. 240 | i64, err := p.GetInteger(path) 241 | if err == nil { 242 | return i64, nil 243 | 244 | } 245 | 246 | // If not, try return (and truncate) a float64. 247 | if f64, err := getNumber(p, path); err == nil { 248 | return int64(f64), nil 249 | } 250 | 251 | // If both fail, return our original error. 252 | return 0, err 253 | } 254 | 255 | func getNumber(p *fieldpath.Paved, path string) (float64, error) { 256 | v, err := p.GetValue(path) 257 | if err != nil { 258 | return 0, err 259 | } 260 | 261 | f, ok := v.(float64) 262 | if !ok { 263 | return 0, errors.Errorf("%s: not a (float64) number", path) 264 | } 265 | return f, nil 266 | } 267 | 268 | // SetValue at the supplied field path. 269 | func (cd *Unstructured) SetValue(path string, value any) error { 270 | return fieldpath.Pave(cd.Object).SetValue(path, value) 271 | } 272 | 273 | // SetString value at the supplied field path. 274 | func (cd *Unstructured) SetString(path, value string) error { 275 | return cd.SetValue(path, value) 276 | } 277 | 278 | // SetBool value at the supplied field path. 279 | func (cd *Unstructured) SetBool(path string, value bool) error { 280 | return cd.SetValue(path, value) 281 | } 282 | 283 | // SetInteger value at the supplied field path. 284 | func (cd *Unstructured) SetInteger(path string, value int64) error { 285 | return cd.SetValue(path, value) 286 | } 287 | 288 | // SetObservedGeneration of this Composed resource. 289 | func (cd *Unstructured) SetObservedGeneration(generation int64) { 290 | status := &xpv1.ObservedStatus{} 291 | _ = fieldpath.Pave(cd.Object).GetValueInto("status", status) 292 | status.SetObservedGeneration(generation) 293 | _ = fieldpath.Pave(cd.Object).SetValue("status.observedGeneration", status.ObservedGeneration) 294 | } 295 | 296 | // GetObservedGeneration of this Composed resource. 297 | func (cd *Unstructured) GetObservedGeneration() int64 { 298 | status := &xpv1.ObservedStatus{} 299 | _ = fieldpath.Pave(cd.Object).GetValueInto("status", status) 300 | return status.GetObservedGeneration() 301 | } 302 | -------------------------------------------------------------------------------- /resource/composed/composed_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package composed 18 | 19 | import ( 20 | "fmt" 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | "github.com/google/go-cmp/cmp/cmpopts" 25 | "github.com/upbound/provider-aws/apis/s3/v1beta2" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 28 | "k8s.io/apimachinery/pkg/runtime" 29 | "k8s.io/utils/ptr" 30 | "sigs.k8s.io/yaml" 31 | ) 32 | 33 | func Example() { 34 | // Create a new, empty composed resource. 35 | cd := New() 36 | 37 | // Set our composed resource's type metadata. 38 | cd.SetAPIVersion("example.org/v1") 39 | cd.SetKind("CoolComposedResource") 40 | 41 | // Set our composed resource's object metadata. 42 | cd.SetLabels(map[string]string{"coolness": "high"}) 43 | 44 | // Set an arbitrary spec field. 45 | cd.SetInteger("spec.coolness", 9001) 46 | 47 | // Marshal our composed resource to YAML. We just do this for illustration 48 | // purposes. Normally you'd add it to the map of desired resources you send 49 | // in your RunFunctionResponse. 50 | y, _ := yaml.Marshal(cd) 51 | 52 | fmt.Println(string(y)) 53 | 54 | // Output: 55 | // apiVersion: example.org/v1 56 | // kind: CoolComposedResource 57 | // metadata: 58 | // labels: 59 | // coolness: high 60 | // spec: 61 | // coolness: 9001 62 | } 63 | 64 | func ExampleScheme() { 65 | // Add all v1beta2 types to the scheme so that From can automatically 66 | // determine their apiVersion and kind. 67 | v1beta2.AddToScheme(Scheme) 68 | } 69 | 70 | func ExampleFrom() { 71 | // Add all v1beta2 types to the scheme so that From can automatically 72 | // determine their apiVersion and kind. 73 | v1beta2.AddToScheme(Scheme) 74 | 75 | // Create a strongly typed runtime.Object, imported from a provider. 76 | b := &v1beta2.Bucket{ 77 | ObjectMeta: metav1.ObjectMeta{ 78 | Labels: map[string]string{ 79 | "coolness": "high", 80 | }, 81 | }, 82 | Spec: v1beta2.BucketSpec{ 83 | ForProvider: v1beta2.BucketParameters{ 84 | Region: ptr.To[string]("us-east-2"), 85 | }, 86 | }, 87 | } 88 | 89 | // Create a composed resource from the runtime.Object. 90 | cd, err := From(b) 91 | if err != nil { 92 | panic(err) 93 | } 94 | 95 | // Marshal our composed resource to YAML. We just do this for illustration 96 | // purposes. Normally you'd add it to the map of desired resources you send 97 | // in your RunFunctionResponse. 98 | y, _ := yaml.Marshal(cd) 99 | 100 | fmt.Println(string(y)) 101 | 102 | // Output: 103 | // apiVersion: s3.aws.upbound.io/v1beta2 104 | // kind: Bucket 105 | // metadata: 106 | // labels: 107 | // coolness: high 108 | // spec: 109 | // forProvider: 110 | // region: us-east-2 111 | // status: 112 | // observedGeneration: 0 113 | } 114 | 115 | func TestFrom(t *testing.T) { 116 | v1beta2.AddToScheme(Scheme) 117 | 118 | type args struct { 119 | o runtime.Object 120 | } 121 | type want struct { 122 | cd *Unstructured 123 | err error 124 | } 125 | cases := map[string]struct { 126 | reason string 127 | args args 128 | want want 129 | }{ 130 | "WithMetadata": { 131 | reason: "A resource with metadata should not grow any extra metadata fields during conversion", 132 | args: args{ 133 | o: &v1beta2.Bucket{ 134 | ObjectMeta: metav1.ObjectMeta{ 135 | Name: "cool-bucket", 136 | }, 137 | Spec: v1beta2.BucketSpec{ 138 | ForProvider: v1beta2.BucketParameters{ 139 | Region: ptr.To[string]("us-east-2"), 140 | }, 141 | }, 142 | }, 143 | }, 144 | want: want{ 145 | cd: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ 146 | "apiVersion": v1beta2.CRDGroupVersion.String(), 147 | "kind": v1beta2.Bucket_Kind, 148 | "metadata": map[string]any{ 149 | "name": "cool-bucket", 150 | }, 151 | "spec": map[string]any{ 152 | "forProvider": map[string]any{ 153 | "region": "us-east-2", 154 | }, 155 | }, 156 | "status": map[string]any{ 157 | "observedGeneration": float64(0), 158 | }, 159 | }}}, 160 | }, 161 | }, 162 | "WithoutMetadata": { 163 | reason: "A resource with no metadata should not grow a metadata object during conversion", 164 | args: args{ 165 | o: &v1beta2.Bucket{ 166 | Spec: v1beta2.BucketSpec{ 167 | ForProvider: v1beta2.BucketParameters{ 168 | Region: ptr.To[string]("us-east-2"), 169 | }, 170 | }, 171 | }, 172 | }, 173 | want: want{ 174 | cd: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ 175 | "apiVersion": v1beta2.CRDGroupVersion.String(), 176 | "kind": v1beta2.Bucket_Kind, 177 | "spec": map[string]any{ 178 | "forProvider": map[string]any{ 179 | "region": "us-east-2", 180 | }, 181 | }, 182 | "status": map[string]any{ 183 | "observedGeneration": float64(0), 184 | }, 185 | }}}, 186 | }, 187 | }, 188 | } 189 | 190 | for name, tc := range cases { 191 | t.Run(name, func(t *testing.T) { 192 | cd, err := From(tc.args.o) 193 | 194 | if diff := cmp.Diff(tc.want.cd, cd); diff != "" { 195 | t.Errorf("\n%s\nFrom(...): -want, +got:\n%s", tc.reason, diff) 196 | } 197 | 198 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 199 | t.Errorf("\n%s\nFrom(...): -want error, +got error:\n%s", tc.reason, diff) 200 | } 201 | }) 202 | } 203 | } 204 | -------------------------------------------------------------------------------- /resource/composite/composite.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package composite contains an unstructured composite resource (XR). 18 | // This resource has getters and setters for common Kubernetes object metadata, 19 | // as well as common composite resource fields like spec.claimRef. It also has 20 | // generic fieldpath-based getters and setters to access arbitrary data. 21 | package composite 22 | 23 | import ( 24 | "github.com/pkg/errors" 25 | corev1 "k8s.io/api/core/v1" 26 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 27 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 28 | "k8s.io/apimachinery/pkg/runtime" 29 | 30 | xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 31 | "github.com/crossplane/crossplane-runtime/pkg/fieldpath" 32 | "github.com/crossplane/crossplane-runtime/pkg/resource" 33 | "github.com/crossplane/crossplane-runtime/pkg/resource/unstructured/reference" 34 | ) 35 | 36 | // New returns a new unstructured composite resource (XR). 37 | func New() *Unstructured { 38 | return &Unstructured{unstructured.Unstructured{Object: make(map[string]any)}} 39 | } 40 | 41 | // An Unstructured composed resource (XR). 42 | type Unstructured struct { 43 | unstructured.Unstructured 44 | } 45 | 46 | var _ runtime.Object = &Unstructured{} 47 | var _ metav1.Object = &Unstructured{} 48 | var _ runtime.Unstructured = &Unstructured{} 49 | var _ resource.Composite = &Unstructured{} 50 | 51 | // DeepCopy this composite resource. 52 | func (xr *Unstructured) DeepCopy() *Unstructured { 53 | if xr == nil { 54 | return nil 55 | } 56 | out := new(Unstructured) 57 | *out = *xr 58 | out.Object = runtime.DeepCopyJSON(xr.Object) 59 | return out 60 | } 61 | 62 | // DeepCopyObject of this composite resource. 63 | func (xr *Unstructured) DeepCopyObject() runtime.Object { 64 | return xr.DeepCopy() 65 | } 66 | 67 | // DeepCopyInto the supplied composite resource. 68 | func (xr *Unstructured) DeepCopyInto(out *Unstructured) { 69 | clone := xr.DeepCopy() 70 | *out = *clone 71 | } 72 | 73 | // MarshalJSON for this composite resource. 74 | func (xr *Unstructured) MarshalJSON() ([]byte, error) { 75 | return xr.Unstructured.MarshalJSON() 76 | } 77 | 78 | // GetCompositionSelector of this composite resource. 79 | func (xr *Unstructured) GetCompositionSelector() *metav1.LabelSelector { 80 | out := &metav1.LabelSelector{} 81 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionSelector", out); err != nil { 82 | return nil 83 | } 84 | return out 85 | } 86 | 87 | // SetCompositionSelector of this composite resource. 88 | func (xr *Unstructured) SetCompositionSelector(sel *metav1.LabelSelector) { 89 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionSelector", sel) 90 | } 91 | 92 | // GetCompositionReference of this composite resource. 93 | func (xr *Unstructured) GetCompositionReference() *corev1.ObjectReference { 94 | out := &corev1.ObjectReference{} 95 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionRef", out); err != nil { 96 | return nil 97 | } 98 | return out 99 | } 100 | 101 | // SetCompositionReference of this composite resource. 102 | func (xr *Unstructured) SetCompositionReference(ref *corev1.ObjectReference) { 103 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionRef", ref) 104 | } 105 | 106 | // GetCompositionRevisionReference of this composite resource. 107 | func (xr *Unstructured) GetCompositionRevisionReference() *corev1.LocalObjectReference { 108 | out := &corev1.LocalObjectReference{} 109 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionRevisionRef", out); err != nil { 110 | return nil 111 | } 112 | return out 113 | } 114 | 115 | // SetCompositionRevisionReference of this composite resource. 116 | func (xr *Unstructured) SetCompositionRevisionReference(ref *corev1.LocalObjectReference) { 117 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionRevisionRef", ref) 118 | } 119 | 120 | // GetCompositionRevisionSelector of this composite resource. 121 | func (xr *Unstructured) GetCompositionRevisionSelector() *metav1.LabelSelector { 122 | out := &metav1.LabelSelector{} 123 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionRevisionSelector", out); err != nil { 124 | return nil 125 | } 126 | return out 127 | } 128 | 129 | // SetCompositionRevisionSelector of this composite resource. 130 | func (xr *Unstructured) SetCompositionRevisionSelector(sel *metav1.LabelSelector) { 131 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionRevisionSelector", sel) 132 | } 133 | 134 | // SetCompositionUpdatePolicy of this composite resource. 135 | func (xr *Unstructured) SetCompositionUpdatePolicy(p *xpv1.UpdatePolicy) { 136 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionUpdatePolicy", p) 137 | } 138 | 139 | // GetCompositionUpdatePolicy of this composite resource. 140 | func (xr *Unstructured) GetCompositionUpdatePolicy() *xpv1.UpdatePolicy { 141 | p, err := fieldpath.Pave(xr.Object).GetString("spec.compositionUpdatePolicy") 142 | if err != nil { 143 | return nil 144 | } 145 | out := xpv1.UpdatePolicy(p) 146 | return &out 147 | } 148 | 149 | // GetClaimReference of this composite resource. 150 | func (xr *Unstructured) GetClaimReference() *reference.Claim { 151 | out := &reference.Claim{} 152 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.claimRef", out); err != nil { 153 | return nil 154 | } 155 | return out 156 | } 157 | 158 | // SetClaimReference of this composite resource. 159 | func (xr *Unstructured) SetClaimReference(ref *reference.Claim) { 160 | _ = fieldpath.Pave(xr.Object).SetValue("spec.claimRef", ref) 161 | } 162 | 163 | // GetResourceReferences of this composite resource. 164 | func (xr *Unstructured) GetResourceReferences() []corev1.ObjectReference { 165 | out := &[]corev1.ObjectReference{} 166 | _ = fieldpath.Pave(xr.Object).GetValueInto("spec.resourceRefs", out) 167 | return *out 168 | } 169 | 170 | // SetResourceReferences of this composite resource. 171 | func (xr *Unstructured) SetResourceReferences(refs []corev1.ObjectReference) { 172 | empty := corev1.ObjectReference{} 173 | filtered := make([]corev1.ObjectReference, 0, len(refs)) 174 | for _, ref := range refs { 175 | // TODO(negz): Ask muvaf to explain what this is working around. :) 176 | // TODO(muvaf): temporary workaround. 177 | if ref.String() == empty.String() { 178 | continue 179 | } 180 | filtered = append(filtered, ref) 181 | } 182 | _ = fieldpath.Pave(xr.Object).SetValue("spec.resourceRefs", filtered) 183 | } 184 | 185 | // GetWriteConnectionSecretToReference of this composite resource. 186 | func (xr *Unstructured) GetWriteConnectionSecretToReference() *xpv1.SecretReference { 187 | out := &xpv1.SecretReference{} 188 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.writeConnectionSecretToRef", out); err != nil { 189 | return nil 190 | } 191 | return out 192 | } 193 | 194 | // SetWriteConnectionSecretToReference of this composite resource. 195 | func (xr *Unstructured) SetWriteConnectionSecretToReference(ref *xpv1.SecretReference) { 196 | _ = fieldpath.Pave(xr.Object).SetValue("spec.writeConnectionSecretToRef", ref) 197 | } 198 | 199 | // GetPublishConnectionDetailsTo of this composite resource. 200 | func (xr *Unstructured) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo { 201 | out := &xpv1.PublishConnectionDetailsTo{} 202 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.publishConnectionDetailsTo", out); err != nil { 203 | return nil 204 | } 205 | return out 206 | } 207 | 208 | // SetPublishConnectionDetailsTo of this composite resource. 209 | func (xr *Unstructured) SetPublishConnectionDetailsTo(ref *xpv1.PublishConnectionDetailsTo) { 210 | _ = fieldpath.Pave(xr.Object).SetValue("spec.publishConnectionDetailsTo", ref) 211 | } 212 | 213 | // GetCondition of this composite resource. 214 | func (xr *Unstructured) GetCondition(ct xpv1.ConditionType) xpv1.Condition { 215 | conditioned := xpv1.ConditionedStatus{} 216 | // The path is directly `status` because conditions are inline. 217 | if err := fieldpath.Pave(xr.Object).GetValueInto("status", &conditioned); err != nil { 218 | return xpv1.Condition{} 219 | } 220 | return conditioned.GetCondition(ct) 221 | } 222 | 223 | // SetConditions of this composite resource. 224 | func (xr *Unstructured) SetConditions(conditions ...xpv1.Condition) { 225 | conditioned := xpv1.ConditionedStatus{} 226 | // The path is directly `status` because conditions are inline. 227 | _ = fieldpath.Pave(xr.Object).GetValueInto("status", &conditioned) 228 | conditioned.SetConditions(conditions...) 229 | _ = fieldpath.Pave(xr.Object).SetValue("status.conditions", conditioned.Conditions) 230 | } 231 | 232 | // GetConnectionDetailsLastPublishedTime of this composite resource. 233 | func (xr *Unstructured) GetConnectionDetailsLastPublishedTime() *metav1.Time { 234 | out := &metav1.Time{} 235 | if err := fieldpath.Pave(xr.Object).GetValueInto("status.connectionDetails.lastPublishedTime", out); err != nil { 236 | return nil 237 | } 238 | return out 239 | } 240 | 241 | // SetConnectionDetailsLastPublishedTime of this composite resource. 242 | func (xr *Unstructured) SetConnectionDetailsLastPublishedTime(t *metav1.Time) { 243 | _ = fieldpath.Pave(xr.Object).SetValue("status.connectionDetails.lastPublishedTime", t) 244 | } 245 | 246 | // GetEnvironmentConfigReferences of this composite resource. 247 | func (xr *Unstructured) GetEnvironmentConfigReferences() []corev1.ObjectReference { 248 | out := &[]corev1.ObjectReference{} 249 | _ = fieldpath.Pave(xr.Object).GetValueInto("spec.environmentConfigRefs", out) 250 | return *out 251 | } 252 | 253 | // SetEnvironmentConfigReferences of this composite resource. 254 | func (xr *Unstructured) SetEnvironmentConfigReferences(refs []corev1.ObjectReference) { 255 | empty := corev1.ObjectReference{} 256 | filtered := make([]corev1.ObjectReference, 0, len(refs)) 257 | for _, ref := range refs { 258 | // TODO(negz): Ask muvaf to explain what this is working around. :) 259 | // TODO(muvaf): temporary workaround. 260 | if ref.String() == empty.String() { 261 | continue 262 | } 263 | filtered = append(filtered, ref) 264 | } 265 | _ = fieldpath.Pave(xr.Object).SetValue("spec.environmentConfigRefs", filtered) 266 | } 267 | 268 | // GetValue of the supplied field path. 269 | func (xr *Unstructured) GetValue(path string) (any, error) { 270 | return fieldpath.Pave(xr.Object).GetValue(path) 271 | } 272 | 273 | // GetValueInto the supplied type. 274 | func (xr *Unstructured) GetValueInto(path string, out any) error { 275 | return fieldpath.Pave(xr.Object).GetValueInto(path, out) 276 | } 277 | 278 | // GetString value of the supplied field path. 279 | func (xr *Unstructured) GetString(path string) (string, error) { 280 | return fieldpath.Pave(xr.Object).GetString(path) 281 | } 282 | 283 | // GetStringArray value of the supplied field path. 284 | func (xr *Unstructured) GetStringArray(path string) ([]string, error) { 285 | return fieldpath.Pave(xr.Object).GetStringArray(path) 286 | } 287 | 288 | // GetStringObject value of the supplied field path. 289 | func (xr *Unstructured) GetStringObject(path string) (map[string]string, error) { 290 | return fieldpath.Pave(xr.Object).GetStringObject(path) 291 | } 292 | 293 | // GetBool value of the supplied field path. 294 | func (xr *Unstructured) GetBool(path string) (bool, error) { 295 | return fieldpath.Pave(xr.Object).GetBool(path) 296 | } 297 | 298 | // GetInteger value of the supplied field path. 299 | func (xr *Unstructured) GetInteger(path string) (int64, error) { 300 | // This is a bit of a hack. Kubernetes JSON decoders will get us a 301 | // map[string]any where number values are int64, but protojson and structpb 302 | // will get us one where number values are float64. 303 | // https://pkg.go.dev/sigs.k8s.io/json#UnmarshalCaseSensitivePreserveInts 304 | p := fieldpath.Pave(xr.Object) 305 | 306 | // If we find an int64, return it. 307 | i64, err := p.GetInteger(path) 308 | if err == nil { 309 | return i64, nil 310 | 311 | } 312 | 313 | // If not, try return (and truncate) a float64. 314 | if f64, err := getNumber(p, path); err == nil { 315 | return int64(f64), nil 316 | } 317 | 318 | return 0, err 319 | } 320 | 321 | func getNumber(p *fieldpath.Paved, path string) (float64, error) { 322 | v, err := p.GetValue(path) 323 | if err != nil { 324 | return 0, err 325 | } 326 | 327 | f, ok := v.(float64) 328 | if !ok { 329 | return 0, errors.Errorf("%s: not a (float64) number", path) 330 | } 331 | return f, nil 332 | } 333 | 334 | // SetValue at the supplied field path. 335 | func (xr *Unstructured) SetValue(path string, value any) error { 336 | return fieldpath.Pave(xr.Object).SetValue(path, value) 337 | } 338 | 339 | // SetString value at the supplied field path. 340 | func (xr *Unstructured) SetString(path, value string) error { 341 | return xr.SetValue(path, value) 342 | } 343 | 344 | // SetBool value at the supplied field path. 345 | func (xr *Unstructured) SetBool(path string, value bool) error { 346 | return xr.SetValue(path, value) 347 | } 348 | 349 | // SetInteger value at the supplied field path. 350 | func (xr *Unstructured) SetInteger(path string, value int64) error { 351 | return xr.SetValue(path, value) 352 | } 353 | 354 | // SetObservedGeneration of this Composite resource. 355 | func (xr *Unstructured) SetObservedGeneration(generation int64) { 356 | status := &xpv1.ObservedStatus{} 357 | _ = fieldpath.Pave(xr.Object).GetValueInto("status", status) 358 | status.SetObservedGeneration(generation) 359 | _ = fieldpath.Pave(xr.Object).SetValue("status.observedGeneration", status.ObservedGeneration) 360 | } 361 | 362 | // GetObservedGeneration of this Composite resource. 363 | func (xr *Unstructured) GetObservedGeneration() int64 { 364 | status := &xpv1.ObservedStatus{} 365 | _ = fieldpath.Pave(xr.Object).GetValueInto("status", status) 366 | return status.GetObservedGeneration() 367 | } 368 | -------------------------------------------------------------------------------- /resource/composite/composite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package composite 18 | 19 | import ( 20 | "errors" 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | "k8s.io/apimachinery/pkg/util/yaml" 25 | 26 | "github.com/crossplane/crossplane-runtime/pkg/fieldpath" 27 | ) 28 | 29 | var manifest = []byte(` 30 | apiVersion: example.org/v1 31 | kind: CoolCompositeResource 32 | metadata: 33 | name: my-cool-xr 34 | spec: 35 | intfield: 9001 36 | floatfield: 9.001 37 | floatexp: 10e3 38 | notanint: foo 39 | `) 40 | 41 | func TestGetInteger(t *testing.T) { 42 | errNotFound := func(path string) error { 43 | p := &fieldpath.Paved{} 44 | _, err := p.GetValue(path) 45 | return err 46 | } 47 | // Create a new, empty XR. 48 | xr := New() 49 | 50 | // Unmarshal our manifest into the XR. This is just for illustration 51 | // purposes - the SDK functions like GetObservedCompositeResource do this 52 | // for you. 53 | _ = yaml.Unmarshal(manifest, xr) 54 | 55 | type args struct { 56 | path string 57 | } 58 | type want struct { 59 | i int64 60 | err error 61 | } 62 | 63 | cases := map[string]struct { 64 | reason string 65 | args args 66 | want want 67 | }{ 68 | "Integer": { 69 | reason: "Correctly return an integer", 70 | args: args{ 71 | path: "spec.intfield", 72 | }, 73 | want: want{ 74 | i: 9001, 75 | err: nil, 76 | }, 77 | }, 78 | "Float": { 79 | reason: "Correctly truncates a float", 80 | args: args{ 81 | path: "spec.floatfield", 82 | }, 83 | want: want{ 84 | i: 9, 85 | err: nil, 86 | }, 87 | }, 88 | "FloatExp": { 89 | reason: "Correctly truncates a float with an exponent", 90 | args: args{ 91 | path: "spec.floatexp", 92 | }, 93 | want: want{ 94 | i: 10000, 95 | err: nil, 96 | }, 97 | }, 98 | "ParseString": { 99 | reason: "Error when not a number", 100 | args: args{ 101 | path: "spec.notanint", 102 | }, 103 | want: want{ 104 | i: 0, 105 | err: errors.New("spec.notanint: not a (int64) number"), 106 | }, 107 | }, 108 | "MissingField": { 109 | reason: "Return 0 and error on a missing field", 110 | args: args{ 111 | path: "badfield", 112 | }, 113 | want: want{ 114 | i: 0, 115 | err: errNotFound("badfield"), 116 | }, 117 | }, 118 | } 119 | 120 | for name, tc := range cases { 121 | t.Run(name, func(t *testing.T) { 122 | i, err := xr.GetInteger(tc.args.path) 123 | 124 | if diff := cmp.Diff(tc.want.i, i); diff != "" { 125 | t.Errorf("%s\nGetInteger(...): -want i, +got i:\n%s", tc.reason, diff) 126 | } 127 | if diff := cmp.Diff(tc.want.err, err, EquateErrors()); diff != "" { 128 | t.Errorf("%s\nGetInteger(...): -want err, +got err:\n%s", tc.reason, diff) 129 | } 130 | }) 131 | } 132 | 133 | } 134 | 135 | // EquateErrors returns true if the supplied errors are of the same type and 136 | // produce identical strings. This mirrors the error comparison behaviour of 137 | // https://github.com/go-test/deep, 138 | // 139 | // This differs from cmpopts.EquateErrors, which does not test for error strings 140 | // and instead returns whether one error 'is' (in the errors.Is sense) the 141 | // other. 142 | func EquateErrors() cmp.Option { 143 | return cmp.Comparer(func(a, b error) bool { 144 | if a == nil || b == nil { 145 | return a == nil && b == nil 146 | } 147 | return a.Error() == b.Error() 148 | }) 149 | } 150 | -------------------------------------------------------------------------------- /resource/resource.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package resource contains utilities to convert protobuf representations of 18 | // Crossplane resources to unstructured Go types, often with convenient getters 19 | // and setters. 20 | package resource 21 | 22 | import ( 23 | "github.com/go-json-experiment/json" 24 | "google.golang.org/protobuf/encoding/protojson" 25 | "google.golang.org/protobuf/types/known/structpb" 26 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 27 | "k8s.io/apimachinery/pkg/runtime" 28 | 29 | "github.com/crossplane/function-sdk-go/errors" 30 | "github.com/crossplane/function-sdk-go/resource/composed" 31 | "github.com/crossplane/function-sdk-go/resource/composite" 32 | ) 33 | 34 | // ConnectionDetails created or updated during an operation on an external 35 | // resource, for example usernames, passwords, endpoints, ports, etc. 36 | type ConnectionDetails map[string][]byte 37 | 38 | // A Composite resource - aka an XR. 39 | type Composite struct { 40 | Resource *composite.Unstructured 41 | ConnectionDetails ConnectionDetails 42 | } 43 | 44 | // A Name uniquely identifies a composed resource within a Composition Function 45 | // pipeline. It's not the resource's metadata.name. 46 | type Name string 47 | 48 | // DesiredComposed reflects the desired state of a composed resource. 49 | type DesiredComposed struct { 50 | Resource *composed.Unstructured 51 | 52 | Ready Ready 53 | } 54 | 55 | // Extra is a resource requested by a Function. 56 | type Extra struct { 57 | Resource *unstructured.Unstructured 58 | } 59 | 60 | // CredentialsType is the type of credentials. 61 | type CredentialsType string 62 | 63 | const ( 64 | // CredentialsTypeData is a Credential of type Data. 65 | CredentialsTypeData = "Data" 66 | ) 67 | 68 | // Credentials is a secret requested by a Function. 69 | type Credentials struct { 70 | // Type represents the type of credentials. 71 | Type CredentialsType 72 | 73 | // Data is a map of key-value pairs where the keys are strings, and the values are byte slices 74 | // containing sensitive data. 75 | Data map[string][]byte 76 | } 77 | 78 | // Ready indicates whether a composed resource should be considered ready. 79 | type Ready string 80 | 81 | // Composed resource readiness. 82 | const ( 83 | ReadyUnspecified Ready = "Unspecified" 84 | ReadyTrue Ready = "True" 85 | ReadyFalse Ready = "False" 86 | ) 87 | 88 | // NewDesiredComposed returns a new, empty desired composed resource. 89 | func NewDesiredComposed() *DesiredComposed { 90 | return &DesiredComposed{Resource: composed.New()} 91 | } 92 | 93 | // ObservedComposed reflects the observed state of a composed resource. 94 | type ObservedComposed struct { 95 | Resource *composed.Unstructured 96 | ConnectionDetails ConnectionDetails 97 | } 98 | 99 | // AsObject gets the supplied Kubernetes object from the supplied struct. 100 | func AsObject(s *structpb.Struct, o runtime.Object) error { 101 | // We try to avoid a JSON round-trip if o is backed by unstructured data. 102 | // Any type that is or embeds *unstructured.Unstructured has this method. 103 | if u, ok := o.(interface{ SetUnstructuredContent(map[string]any) }); ok { 104 | u.SetUnstructuredContent(s.AsMap()) 105 | return nil 106 | } 107 | 108 | b, err := protojson.Marshal(s) 109 | if err != nil { 110 | return errors.Wrapf(err, "cannot marshal %T to JSON", s) 111 | } 112 | return errors.Wrapf(json.Unmarshal(b, o, json.RejectUnknownMembers(true)), "cannot unmarshal JSON from %T into %T", s, o) 113 | } 114 | 115 | // AsStruct gets the supplied struct from the supplied Kubernetes object. 116 | func AsStruct(o runtime.Object) (*structpb.Struct, error) { 117 | // We try to avoid a JSON round-trip if o is backed by unstructured data. 118 | // Any type that is or embeds *unstructured.Unstructured has this method. 119 | if u, ok := o.(interface{ UnstructuredContent() map[string]any }); ok { 120 | s, err := structpb.NewStruct(u.UnstructuredContent()) 121 | return s, errors.Wrapf(err, "cannot create new Struct from %T", u) 122 | } 123 | 124 | b, err := json.Marshal(o) 125 | if err != nil { 126 | return nil, errors.Wrapf(err, "cannot marshal %T to JSON", o) 127 | } 128 | s := &structpb.Struct{} 129 | return s, errors.Wrapf(protojson.Unmarshal(b, s), "cannot unmarshal JSON from %T into %T", o, s) 130 | } 131 | 132 | // MustStructObject is intended only for use in tests. It returns the supplied 133 | // object as a struct. It panics if it can't. 134 | func MustStructObject(o runtime.Object) *structpb.Struct { 135 | s, err := AsStruct(o) 136 | if err != nil { 137 | panic(err) 138 | } 139 | return s 140 | } 141 | 142 | // MustStructJSON is intended only for use in tests. It returns the supplied 143 | // JSON string as a struct. It panics if it can't. 144 | func MustStructJSON(j string) *structpb.Struct { 145 | s := &structpb.Struct{} 146 | if err := protojson.Unmarshal([]byte(j), s); err != nil { 147 | panic(err) 148 | } 149 | return s 150 | } 151 | -------------------------------------------------------------------------------- /response/condition.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package response 18 | 19 | import ( 20 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 21 | ) 22 | 23 | // ConditionOption allows further customization of the condition. 24 | type ConditionOption struct { 25 | condition *v1.Condition 26 | } 27 | 28 | // ConditionTrue will create a condition with the status of true and add the 29 | // condition to the supplied RunFunctionResponse. 30 | func ConditionTrue(rsp *v1.RunFunctionResponse, typ, reason string) *ConditionOption { 31 | return newCondition(rsp, typ, reason, v1.Status_STATUS_CONDITION_TRUE) 32 | } 33 | 34 | // ConditionFalse will create a condition with the status of false and add the 35 | // condition to the supplied RunFunctionResponse. 36 | func ConditionFalse(rsp *v1.RunFunctionResponse, typ, reason string) *ConditionOption { 37 | return newCondition(rsp, typ, reason, v1.Status_STATUS_CONDITION_FALSE) 38 | } 39 | 40 | // ConditionUnknown will create a condition with the status of unknown and add 41 | // the condition to the supplied RunFunctionResponse. 42 | func ConditionUnknown(rsp *v1.RunFunctionResponse, typ, reason string) *ConditionOption { 43 | return newCondition(rsp, typ, reason, v1.Status_STATUS_CONDITION_UNKNOWN) 44 | } 45 | 46 | func newCondition(rsp *v1.RunFunctionResponse, typ, reason string, s v1.Status) *ConditionOption { 47 | if rsp.GetConditions() == nil { 48 | rsp.Conditions = make([]*v1.Condition, 0, 1) 49 | } 50 | c := &v1.Condition{ 51 | Type: typ, 52 | Status: s, 53 | Reason: reason, 54 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 55 | } 56 | rsp.Conditions = append(rsp.GetConditions(), c) 57 | return &ConditionOption{condition: c} 58 | } 59 | 60 | // TargetComposite updates the condition to target the composite resource. 61 | func (c *ConditionOption) TargetComposite() *ConditionOption { 62 | c.condition.Target = v1.Target_TARGET_COMPOSITE.Enum() 63 | return c 64 | } 65 | 66 | // TargetCompositeAndClaim updates the condition to target both the composite 67 | // resource and claim. 68 | func (c *ConditionOption) TargetCompositeAndClaim() *ConditionOption { 69 | c.condition.Target = v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum() 70 | return c 71 | } 72 | 73 | // WithMessage adds the message to the condition. 74 | func (c *ConditionOption) WithMessage(message string) *ConditionOption { 75 | c.condition.Message = &message 76 | return c 77 | } 78 | -------------------------------------------------------------------------------- /response/condition_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package response_test 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | "google.golang.org/protobuf/testing/protocmp" 24 | "k8s.io/utils/ptr" 25 | 26 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 27 | "github.com/crossplane/function-sdk-go/response" 28 | ) 29 | 30 | // Condition types. 31 | const ( 32 | typeDatabaseReady = "DatabaseReady" 33 | ) 34 | 35 | // Condition reasons. 36 | const ( 37 | reasonAvailable = "ReasonAvailable" 38 | reasonCreating = "ReasonCreating" 39 | reasonPriorFailure = "ReasonPriorFailure" 40 | reasonUnauthorized = "ReasonUnauthorized" 41 | ) 42 | 43 | func TestCondition(t *testing.T) { 44 | type testFn func(*v1.RunFunctionResponse) 45 | type args struct { 46 | fns []testFn 47 | } 48 | type want struct { 49 | conditions []*v1.Condition 50 | } 51 | cases := map[string]struct { 52 | reason string 53 | args args 54 | want want 55 | }{ 56 | "CreateBasicRecords": { 57 | reason: "Correctly adds conditions to the response.", 58 | args: args{ 59 | fns: []testFn{ 60 | func(rsp *v1.RunFunctionResponse) { 61 | response.ConditionTrue(rsp, typeDatabaseReady, reasonAvailable) 62 | }, 63 | func(rsp *v1.RunFunctionResponse) { 64 | response.ConditionFalse(rsp, typeDatabaseReady, reasonCreating) 65 | }, 66 | func(rsp *v1.RunFunctionResponse) { 67 | response.ConditionUnknown(rsp, typeDatabaseReady, reasonPriorFailure) 68 | }, 69 | }, 70 | }, 71 | want: want{ 72 | conditions: []*v1.Condition{ 73 | { 74 | Type: typeDatabaseReady, 75 | Status: v1.Status_STATUS_CONDITION_TRUE, 76 | Reason: reasonAvailable, 77 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 78 | }, 79 | { 80 | Type: typeDatabaseReady, 81 | Status: v1.Status_STATUS_CONDITION_FALSE, 82 | Reason: reasonCreating, 83 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 84 | }, 85 | { 86 | Type: typeDatabaseReady, 87 | Status: v1.Status_STATUS_CONDITION_UNKNOWN, 88 | Reason: reasonPriorFailure, 89 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 90 | }, 91 | }, 92 | }, 93 | }, 94 | "SetTargets": { 95 | reason: "Correctly sets targets on condition and adds it to the response.", 96 | args: args{ 97 | fns: []testFn{ 98 | func(rsp *v1.RunFunctionResponse) { 99 | response.ConditionTrue(rsp, typeDatabaseReady, reasonAvailable).TargetComposite() 100 | }, 101 | func(rsp *v1.RunFunctionResponse) { 102 | response.ConditionTrue(rsp, typeDatabaseReady, reasonAvailable).TargetCompositeAndClaim() 103 | }, 104 | }, 105 | }, 106 | want: want{ 107 | conditions: []*v1.Condition{ 108 | { 109 | Type: typeDatabaseReady, 110 | Status: v1.Status_STATUS_CONDITION_TRUE, 111 | Reason: reasonAvailable, 112 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 113 | }, 114 | { 115 | Type: typeDatabaseReady, 116 | Status: v1.Status_STATUS_CONDITION_TRUE, 117 | Reason: reasonAvailable, 118 | Target: v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 119 | }, 120 | }, 121 | }, 122 | }, 123 | "SetMessage": { 124 | reason: "Correctly sets message on condition and adds it to the response.", 125 | args: args{ 126 | fns: []testFn{ 127 | func(rsp *v1.RunFunctionResponse) { 128 | response.ConditionTrue(rsp, typeDatabaseReady, reasonAvailable).WithMessage("a test message") 129 | }, 130 | }, 131 | }, 132 | want: want{ 133 | conditions: []*v1.Condition{ 134 | { 135 | Type: typeDatabaseReady, 136 | Status: v1.Status_STATUS_CONDITION_TRUE, 137 | Reason: reasonAvailable, 138 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 139 | Message: ptr.To("a test message"), 140 | }, 141 | }, 142 | }, 143 | }, 144 | "ChainOptions": { 145 | reason: "Can chain condition options together.", 146 | args: args{ 147 | fns: []testFn{ 148 | func(rsp *v1.RunFunctionResponse) { 149 | response.ConditionTrue(rsp, typeDatabaseReady, reasonAvailable). 150 | WithMessage("a test message"). 151 | TargetCompositeAndClaim() 152 | }, 153 | func(rsp *v1.RunFunctionResponse) { 154 | response.ConditionTrue(rsp, typeDatabaseReady, reasonAvailable). 155 | TargetCompositeAndClaim(). 156 | WithMessage("a test message") 157 | }, 158 | }, 159 | }, 160 | want: want{ 161 | conditions: []*v1.Condition{ 162 | { 163 | Type: typeDatabaseReady, 164 | Status: v1.Status_STATUS_CONDITION_TRUE, 165 | Reason: reasonAvailable, 166 | Target: v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 167 | Message: ptr.To("a test message"), 168 | }, 169 | { 170 | Type: typeDatabaseReady, 171 | Status: v1.Status_STATUS_CONDITION_TRUE, 172 | Reason: reasonAvailable, 173 | Target: v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 174 | Message: ptr.To("a test message"), 175 | }, 176 | }, 177 | }, 178 | }, 179 | } 180 | for name, tc := range cases { 181 | t.Run(name, func(t *testing.T) { 182 | rsp := &v1.RunFunctionResponse{} 183 | for _, f := range tc.args.fns { 184 | f(rsp) 185 | } 186 | 187 | if diff := cmp.Diff(tc.want.conditions, rsp.GetConditions(), protocmp.Transform()); diff != "" { 188 | t.Errorf("\n%s\nFrom(...): -want, +got:\n%s", tc.reason, diff) 189 | } 190 | 191 | }) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /response/response.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2021 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package response contains utilities for working with RunFunctionResponses. 18 | package response 19 | 20 | import ( 21 | "time" 22 | 23 | "google.golang.org/protobuf/types/known/durationpb" 24 | "google.golang.org/protobuf/types/known/structpb" 25 | 26 | "github.com/crossplane/function-sdk-go/errors" 27 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 28 | "github.com/crossplane/function-sdk-go/resource" 29 | ) 30 | 31 | // DefaultTTL is the default TTL for which a response can be cached. 32 | const DefaultTTL = 1 * time.Minute 33 | 34 | // To bootstraps a response to the supplied request. It automatically copies the 35 | // desired state from the request. 36 | func To(req *v1.RunFunctionRequest, ttl time.Duration) *v1.RunFunctionResponse { 37 | return &v1.RunFunctionResponse{ 38 | Meta: &v1.ResponseMeta{ 39 | Tag: req.GetMeta().GetTag(), 40 | Ttl: durationpb.New(ttl), 41 | }, 42 | Desired: req.GetDesired(), 43 | Context: req.GetContext(), 44 | } 45 | } 46 | 47 | // SetContextKey sets context to the supplied key. 48 | func SetContextKey(rsp *v1.RunFunctionResponse, key string, v *structpb.Value) { 49 | if rsp.GetContext().GetFields() == nil { 50 | rsp.Context = &structpb.Struct{Fields: make(map[string]*structpb.Value)} 51 | } 52 | rsp.Context.Fields[key] = v 53 | } 54 | 55 | // SetDesiredCompositeResource sets the desired composite resource in the 56 | // supplied response. The caller must be sure to avoid overwriting the desired 57 | // state that may have been accumulated by previous Functions in the pipeline, 58 | // unless they intend to. 59 | func SetDesiredCompositeResource(rsp *v1.RunFunctionResponse, xr *resource.Composite) error { 60 | if rsp.GetDesired() == nil { 61 | rsp.Desired = &v1.State{} 62 | } 63 | s, err := resource.AsStruct(xr.Resource) 64 | rsp.Desired.Composite = &v1.Resource{Resource: s, ConnectionDetails: xr.ConnectionDetails} 65 | return errors.Wrapf(err, "cannot convert %T to desired composite resource", xr.Resource) 66 | } 67 | 68 | // SetDesiredComposedResources sets the desired composed resources in the 69 | // supplied response. The caller must be sure to avoid overwriting the desired 70 | // state that may have been accumulated by previous Functions in the pipeline, 71 | // unless they intend to. 72 | func SetDesiredComposedResources(rsp *v1.RunFunctionResponse, dcds map[resource.Name]*resource.DesiredComposed) error { 73 | if rsp.GetDesired() == nil { 74 | rsp.Desired = &v1.State{} 75 | } 76 | if rsp.GetDesired().GetResources() == nil { 77 | rsp.Desired.Resources = map[string]*v1.Resource{} 78 | } 79 | for name, dcd := range dcds { 80 | s, err := resource.AsStruct(dcd.Resource) 81 | if err != nil { 82 | return err 83 | } 84 | r := &v1.Resource{Resource: s} 85 | switch dcd.Ready { 86 | case resource.ReadyUnspecified: 87 | r.Ready = v1.Ready_READY_UNSPECIFIED 88 | case resource.ReadyFalse: 89 | r.Ready = v1.Ready_READY_FALSE 90 | case resource.ReadyTrue: 91 | r.Ready = v1.Ready_READY_TRUE 92 | } 93 | rsp.Desired.Resources[string(name)] = r 94 | } 95 | return nil 96 | } 97 | -------------------------------------------------------------------------------- /response/result.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2024 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package response contains utilities for working with RunFunctionResponses. 18 | package response 19 | 20 | import ( 21 | "fmt" 22 | 23 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 24 | ) 25 | 26 | // ResultOption allows further customization of the result. 27 | type ResultOption struct { 28 | result *v1.Result 29 | } 30 | 31 | // Fatal adds a fatal result to the supplied RunFunctionResponse. 32 | // An event will be created for the Composite Resource. 33 | // A fatal result cannot target the claim. 34 | func Fatal(rsp *v1.RunFunctionResponse, err error) { 35 | newResult(rsp, v1.Severity_SEVERITY_FATAL, err.Error()) 36 | } 37 | 38 | // Warning adds a warning result to the supplied RunFunctionResponse. 39 | // An event will be created for the Composite Resource. 40 | func Warning(rsp *v1.RunFunctionResponse, err error) *ResultOption { 41 | return newResult(rsp, v1.Severity_SEVERITY_WARNING, err.Error()) 42 | } 43 | 44 | // Normal adds a normal result to the supplied RunFunctionResponse. 45 | // An event will be created for the Composite Resource. 46 | func Normal(rsp *v1.RunFunctionResponse, message string) *ResultOption { 47 | return newResult(rsp, v1.Severity_SEVERITY_NORMAL, message) 48 | } 49 | 50 | // Normalf adds a normal result to the supplied RunFunctionResponse. 51 | // An event will be created for the Composite Resource. 52 | func Normalf(rsp *v1.RunFunctionResponse, format string, a ...any) *ResultOption { 53 | return Normal(rsp, fmt.Sprintf(format, a...)) 54 | } 55 | 56 | func newResult(rsp *v1.RunFunctionResponse, s v1.Severity, message string) *ResultOption { 57 | if rsp.GetResults() == nil { 58 | rsp.Results = make([]*v1.Result, 0, 1) 59 | } 60 | 61 | r := &v1.Result{ 62 | Severity: s, 63 | Message: message, 64 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 65 | } 66 | rsp.Results = append(rsp.GetResults(), r) 67 | 68 | return &ResultOption{result: r} 69 | } 70 | 71 | // TargetComposite updates the result and its event to target the composite 72 | // resource. 73 | func (o *ResultOption) TargetComposite() *ResultOption { 74 | o.result.Target = v1.Target_TARGET_COMPOSITE.Enum() 75 | return o 76 | } 77 | 78 | // TargetCompositeAndClaim updates the result and its event to target both the 79 | // composite resource and claim. 80 | func (o *ResultOption) TargetCompositeAndClaim() *ResultOption { 81 | o.result.Target = v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum() 82 | return o 83 | } 84 | 85 | // WithReason sets the reason field on the result and its event. 86 | func (o *ResultOption) WithReason(reason string) *ResultOption { 87 | o.result.Reason = &reason 88 | return o 89 | } 90 | -------------------------------------------------------------------------------- /response/result_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package response_test 18 | 19 | import ( 20 | "errors" 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | "google.golang.org/protobuf/testing/protocmp" 25 | "k8s.io/utils/ptr" 26 | 27 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 28 | "github.com/crossplane/function-sdk-go/response" 29 | ) 30 | 31 | func TestResult(t *testing.T) { 32 | type testFn func(*v1.RunFunctionResponse) 33 | type args struct { 34 | fns []testFn 35 | } 36 | type want struct { 37 | results []*v1.Result 38 | } 39 | cases := map[string]struct { 40 | reason string 41 | args args 42 | want want 43 | }{ 44 | "CreateBasicRecords": { 45 | reason: "Correctly adds results to the response.", 46 | args: args{ 47 | fns: []testFn{ 48 | func(rsp *v1.RunFunctionResponse) { 49 | response.Normal(rsp, "this is a test normal result") 50 | }, 51 | func(rsp *v1.RunFunctionResponse) { 52 | response.Normalf(rsp, "this is a test normal %s result", "formatted") 53 | }, 54 | func(rsp *v1.RunFunctionResponse) { 55 | response.Warning(rsp, errors.New("this is a test warning result")) 56 | }, 57 | func(rsp *v1.RunFunctionResponse) { 58 | response.Fatal(rsp, errors.New("this is a test fatal result")) 59 | }, 60 | }, 61 | }, 62 | want: want{ 63 | results: []*v1.Result{ 64 | { 65 | Severity: v1.Severity_SEVERITY_NORMAL, 66 | Message: "this is a test normal result", 67 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 68 | }, 69 | { 70 | Severity: v1.Severity_SEVERITY_NORMAL, 71 | Message: "this is a test normal formatted result", 72 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 73 | }, 74 | { 75 | Severity: v1.Severity_SEVERITY_WARNING, 76 | Message: "this is a test warning result", 77 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 78 | }, 79 | { 80 | Severity: v1.Severity_SEVERITY_FATAL, 81 | Message: "this is a test fatal result", 82 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 83 | }, 84 | }, 85 | }, 86 | }, 87 | "SetTargets": { 88 | reason: "Correctly sets targets on result and adds it to the response.", 89 | args: args{ 90 | fns: []testFn{ 91 | func(rsp *v1.RunFunctionResponse) { 92 | response.Warning(rsp, errors.New("this is a test warning result targeting the composite")).TargetComposite() 93 | }, 94 | func(rsp *v1.RunFunctionResponse) { 95 | response.Warning(rsp, errors.New("this is a test fatal result targeting both")).TargetCompositeAndClaim() 96 | }, 97 | }, 98 | }, 99 | want: want{ 100 | results: []*v1.Result{ 101 | { 102 | Severity: v1.Severity_SEVERITY_WARNING, 103 | Message: "this is a test warning result targeting the composite", 104 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 105 | }, 106 | { 107 | Severity: v1.Severity_SEVERITY_WARNING, 108 | Message: "this is a test fatal result targeting both", 109 | Target: v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 110 | }, 111 | }, 112 | }, 113 | }, 114 | "SetReason": { 115 | reason: "Correctly sets reason on result and adds it to the response.", 116 | args: args{ 117 | fns: []testFn{ 118 | func(rsp *v1.RunFunctionResponse) { 119 | response.Normal(rsp, "this is a test normal result targeting the composite").WithReason("TestReason") 120 | }, 121 | func(rsp *v1.RunFunctionResponse) { 122 | response.Warning(rsp, errors.New("this is a test warning result targeting the composite")).WithReason("TestReason") 123 | }, 124 | }, 125 | }, 126 | want: want{ 127 | results: []*v1.Result{ 128 | { 129 | Severity: v1.Severity_SEVERITY_NORMAL, 130 | Message: "this is a test normal result targeting the composite", 131 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 132 | Reason: ptr.To("TestReason"), 133 | }, 134 | { 135 | Severity: v1.Severity_SEVERITY_WARNING, 136 | Message: "this is a test warning result targeting the composite", 137 | Target: v1.Target_TARGET_COMPOSITE.Enum(), 138 | Reason: ptr.To("TestReason"), 139 | }, 140 | }, 141 | }, 142 | }, 143 | "ChainOptions": { 144 | reason: "Can chain result options together.", 145 | args: args{ 146 | fns: []testFn{ 147 | func(rsp *v1.RunFunctionResponse) { 148 | response.Normal(rsp, "this is a test normal result targeting the composite and claim"). 149 | WithReason("TestReason"). 150 | TargetCompositeAndClaim() 151 | }, 152 | func(rsp *v1.RunFunctionResponse) { 153 | response.Warning(rsp, errors.New("this is a test warning result targeting the composite and claim")). 154 | TargetCompositeAndClaim(). 155 | WithReason("TestReason") 156 | }, 157 | }, 158 | }, 159 | want: want{ 160 | results: []*v1.Result{ 161 | { 162 | Severity: v1.Severity_SEVERITY_NORMAL, 163 | Message: "this is a test normal result targeting the composite and claim", 164 | Target: v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 165 | Reason: ptr.To("TestReason"), 166 | }, 167 | { 168 | Severity: v1.Severity_SEVERITY_WARNING, 169 | Message: "this is a test warning result targeting the composite and claim", 170 | Target: v1.Target_TARGET_COMPOSITE_AND_CLAIM.Enum(), 171 | Reason: ptr.To("TestReason"), 172 | }, 173 | }, 174 | }, 175 | }, 176 | } 177 | for name, tc := range cases { 178 | t.Run(name, func(t *testing.T) { 179 | rsp := &v1.RunFunctionResponse{} 180 | for _, f := range tc.args.fns { 181 | f(rsp) 182 | } 183 | 184 | if diff := cmp.Diff(tc.want.results, rsp.GetResults(), protocmp.Transform()); diff != "" { 185 | t.Errorf("\n%s\nFrom(...): -want, +got:\n%s", tc.reason, diff) 186 | } 187 | 188 | }) 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /sdk.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | // Package function is an SDK for building Composition Functions. 18 | package function 19 | 20 | import ( 21 | "context" 22 | "crypto/tls" 23 | "crypto/x509" 24 | "net" 25 | "os" 26 | "path/filepath" 27 | 28 | "github.com/pkg/errors" 29 | "google.golang.org/grpc" 30 | "google.golang.org/grpc/credentials" 31 | ginsecure "google.golang.org/grpc/credentials/insecure" 32 | "google.golang.org/grpc/reflection" 33 | "google.golang.org/protobuf/proto" 34 | 35 | "github.com/crossplane/function-sdk-go/logging" 36 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 37 | "github.com/crossplane/function-sdk-go/proto/v1beta1" 38 | ) 39 | 40 | // Default ServeOptions. 41 | const ( 42 | DefaultNetwork = "tcp" 43 | DefaultAddress = ":9443" 44 | DefaultMaxRecvMsgSize = 1024 * 1024 * 4 45 | ) 46 | 47 | // ServeOptions configure how a Function is served. 48 | type ServeOptions struct { 49 | Network string 50 | Address string 51 | MaxRecvMsgSize int 52 | Credentials credentials.TransportCredentials 53 | } 54 | 55 | // A ServeOption configures how a Function is served. 56 | type ServeOption func(o *ServeOptions) error 57 | 58 | // Listen configures the network, address and maximum message size on which the Function will 59 | // listen for RunFunctionRequests. 60 | func Listen(network, address string) ServeOption { 61 | return func(o *ServeOptions) error { 62 | o.Network = network 63 | o.Address = address 64 | return nil 65 | } 66 | } 67 | 68 | // MTLSCertificates specifies a directory from which to load mTLS certificates. 69 | // The directory must contain the server certificate (tls.key and tls.crt), as 70 | // well as a CA certificate (ca.crt) that will be used to authenticate clients. 71 | func MTLSCertificates(dir string) ServeOption { 72 | return func(o *ServeOptions) error { 73 | if dir == "" { 74 | // We want to support passing both MTLSCertificates and 75 | // Insecure as they were supplied as flags. So we don't 76 | // want this to fail because no dir was supplied. 77 | // If no TLS dir is supplied and insecure is false we'll 78 | // return an error due to having no credentials specified. 79 | return nil 80 | } 81 | crt, err := tls.LoadX509KeyPair( 82 | filepath.Clean(filepath.Join(dir, "tls.crt")), 83 | filepath.Clean(filepath.Join(dir, "tls.key")), 84 | ) 85 | if err != nil { 86 | return errors.Wrap(err, "cannot load X509 keypair") 87 | } 88 | 89 | ca, err := os.ReadFile(filepath.Clean(filepath.Join(dir, "ca.crt"))) 90 | if err != nil { 91 | return errors.Wrap(err, "cannot read CA certificate") 92 | } 93 | 94 | pool := x509.NewCertPool() 95 | if !pool.AppendCertsFromPEM(ca) { 96 | return errors.New("invalid CA certificate") 97 | } 98 | 99 | o.Credentials = credentials.NewTLS(&tls.Config{ 100 | MinVersion: tls.VersionTLS12, 101 | Certificates: []tls.Certificate{crt}, 102 | ClientCAs: pool, 103 | ClientAuth: tls.RequireAndVerifyClientCert, 104 | }) 105 | 106 | return nil 107 | } 108 | } 109 | 110 | // Insecure specifies whether this Function should be served insecurely - i.e. 111 | // without mTLS authentication. This is only useful for testing and development. 112 | // Crossplane will always send requests using mTLS. 113 | func Insecure(insecure bool) ServeOption { 114 | return func(o *ServeOptions) error { 115 | if insecure { 116 | o.Credentials = ginsecure.NewCredentials() 117 | } 118 | return nil 119 | } 120 | } 121 | 122 | // MaxRecvMessageSize returns a ServeOption to set the max message size in bytes the server can receive. 123 | // If this is not set, gRPC uses the default limit. 124 | func MaxRecvMessageSize(sz int) ServeOption { 125 | return func(o *ServeOptions) error { 126 | o.MaxRecvMsgSize = sz 127 | return nil 128 | } 129 | } 130 | 131 | // Serve the supplied Function by creating a gRPC server and listening for 132 | // RunFunctionRequests. Blocks until the server returns an error. 133 | func Serve(fn v1.FunctionRunnerServiceServer, o ...ServeOption) error { 134 | so := &ServeOptions{ 135 | Network: DefaultNetwork, 136 | Address: DefaultAddress, 137 | MaxRecvMsgSize: DefaultMaxRecvMsgSize, 138 | } 139 | 140 | for _, fn := range o { 141 | if err := fn(so); err != nil { 142 | return errors.Wrap(err, "cannot apply ServeOption") 143 | } 144 | } 145 | 146 | if so.Credentials == nil { 147 | return errors.New("no credentials provided - did you specify the Insecure or MTLSCertificates options?") 148 | } 149 | 150 | lis, err := net.Listen(so.Network, so.Address) 151 | if err != nil { 152 | return errors.Wrapf(err, "cannot listen for %s connections at address %q", so.Network, so.Address) 153 | } 154 | 155 | srv := grpc.NewServer(grpc.MaxRecvMsgSize(so.MaxRecvMsgSize), grpc.Creds(so.Credentials)) 156 | reflection.Register(srv) 157 | v1.RegisterFunctionRunnerServiceServer(srv, fn) 158 | v1beta1.RegisterFunctionRunnerServiceServer(srv, ServeBeta(fn)) 159 | return errors.Wrap(srv.Serve(lis), "cannot serve mTLS gRPC connections") 160 | } 161 | 162 | // NewLogger returns a new logger. 163 | func NewLogger(debug bool) (logging.Logger, error) { 164 | return logging.NewLogger(debug) 165 | } 166 | 167 | // A BetaServer is a v1beta1 FunctionRunnerServiceServer that wraps an identical 168 | // v1 FunctionRunnerServiceServer. This requires the v1 and v1beta1 protos to be 169 | // identical. 170 | // 171 | // Functions were promoted from v1beta1 to v1 in Crossplane v1.17. Crossplane 172 | // v1.16 and earlier only sends v1beta1 RunFunctionRequests. Functions should 173 | // use the BetaServer for backward compatibility, to support Crossplane v1.16 174 | // and earlier. 175 | type BetaServer struct { 176 | wrapped v1.FunctionRunnerServiceServer 177 | 178 | v1beta1.UnimplementedFunctionRunnerServiceServer 179 | } 180 | 181 | // ServeBeta returns a v1beta1.FunctionRunnerServiceServer that wraps the 182 | // suppled v1.FunctionRunnerServiceServer. 183 | func ServeBeta(s v1.FunctionRunnerServiceServer) *BetaServer { 184 | return &BetaServer{wrapped: s} 185 | } 186 | 187 | // RunFunction calls the RunFunction method of the wrapped 188 | // v1.FunctionRunnerServiceServer. It converts from v1beta1 to v1 and back by 189 | // round-tripping through protobuf marshaling. 190 | func (s *BetaServer) RunFunction(ctx context.Context, req *v1beta1.RunFunctionRequest) (*v1beta1.RunFunctionResponse, error) { 191 | gareq := &v1.RunFunctionRequest{} 192 | 193 | b, err := proto.Marshal(req) 194 | if err != nil { 195 | return nil, errors.Wrap(err, "cannot marshal v1beta1 RunFunctionRequest to protobuf bytes") 196 | } 197 | 198 | if err := proto.Unmarshal(b, gareq); err != nil { 199 | return nil, errors.Wrap(err, "cannot unmarshal v1 RunFunctionRequest from v1beta1 protobuf bytes") 200 | } 201 | 202 | garsp, err := s.wrapped.RunFunction(ctx, gareq) 203 | if err != nil { 204 | // This error is intentionally not wrapped. This middleware is just 205 | // calling an underlying RunFunction. 206 | return nil, err 207 | } 208 | 209 | b, err = proto.Marshal(garsp) 210 | if err != nil { 211 | return nil, errors.Wrap(err, "cannot marshal v1beta1 RunFunctionResponse to protobuf bytes") 212 | } 213 | 214 | rsp := &v1beta1.RunFunctionResponse{} 215 | err = proto.Unmarshal(b, rsp) 216 | return rsp, errors.Wrap(err, "cannot unmarshal v1 RunFunctionResponse from v1beta1 protobuf bytes") 217 | } 218 | -------------------------------------------------------------------------------- /sdk_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2023 The Crossplane Authors. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | package function 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "testing" 23 | 24 | "github.com/google/go-cmp/cmp" 25 | "github.com/google/go-cmp/cmp/cmpopts" 26 | "google.golang.org/protobuf/encoding/protojson" 27 | "google.golang.org/protobuf/testing/protocmp" 28 | 29 | "github.com/crossplane/function-sdk-go/errors" 30 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 31 | "github.com/crossplane/function-sdk-go/proto/v1beta1" 32 | "github.com/crossplane/function-sdk-go/request" 33 | "github.com/crossplane/function-sdk-go/resource" 34 | "github.com/crossplane/function-sdk-go/resource/composed" 35 | "github.com/crossplane/function-sdk-go/response" 36 | ) 37 | 38 | var _ v1beta1.FunctionRunnerServiceServer = &BetaServer{} 39 | 40 | var req = &v1.RunFunctionRequest{ 41 | Observed: &v1.State{ 42 | Composite: &v1.Resource{ 43 | Resource: resource.MustStructJSON(`{"spec":{"widgets":9001}}`), 44 | }, 45 | }, 46 | } 47 | 48 | func Example() { 49 | // Create a response to the request passed to your RunFunction method. 50 | rsp := response.To(req, response.DefaultTTL) 51 | 52 | // Get the observed composite resource (XR) from the request. 53 | oxr, _ := request.GetObservedCompositeResource(req) 54 | 55 | // Read the desired number of widgets from our observed XR. 56 | widgets, _ := oxr.Resource.GetInteger("spec.widgets") 57 | 58 | // Get any existing desired composed resources from the request. 59 | // Desired composed resources would exist if a previous Function in the 60 | // pipeline added them. 61 | desired, _ := request.GetDesiredComposedResources(req) 62 | 63 | // Create a desired composed resource using unstructured data. 64 | desired["new"] = &resource.DesiredComposed{Resource: composed.New()} 65 | desired["new"].Resource.SetAPIVersion("example.org/v1") 66 | desired["new"].Resource.SetKind("CoolResource") 67 | 68 | // Set the desired composed resource's widgets to the value extracted from 69 | // the observed XR. 70 | desired["new"].Resource.SetInteger("spec.widgets", widgets) 71 | 72 | // Create a desired composed resource using structured data. 73 | // db, _ := composed.From(&v1.Instance{}) 74 | // desired["database"] = &resource.DesiredComposed{Resource: db} 75 | 76 | // Add a label to our new desired resource, and any other. 77 | for _, r := range desired { 78 | r.Resource.SetLabels(map[string]string{"coolness": "high"}) 79 | } 80 | 81 | // Set our updated desired composed resource in the response we'll return. 82 | if err := response.SetDesiredComposedResources(rsp, desired); err != nil { 83 | // You can set a custom status condition on the claim. This allows you to 84 | // communicate with the user. See the link below for status condition 85 | // guidance. 86 | // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties 87 | response.ConditionFalse(rsp, "FunctionSuccess", "InternalError"). 88 | WithMessage("Something went wrong."). 89 | TargetCompositeAndClaim() 90 | 91 | // You can emit an event regarding the claim. This allows you to communicate 92 | // with the user. Note that events should be used sparingly and are subject 93 | // to throttling; see the issue below for more information. 94 | // https://github.com/crossplane/crossplane/issues/5802 95 | response.Warning(rsp, errors.New("something went wrong")). 96 | TargetCompositeAndClaim() 97 | } else { 98 | response.ConditionTrue(rsp, "FunctionSuccess", "Success"). 99 | TargetCompositeAndClaim() 100 | } 101 | 102 | j, _ := protojson.Marshal(rsp) 103 | fmt.Println(string(j)) 104 | 105 | // Output: 106 | // {"meta":{"ttl":"60s"},"desired":{"resources":{"new":{"resource":{"apiVersion":"example.org/v1","kind":"CoolResource","metadata":{"labels":{"coolness":"high"}},"spec":{"widgets":9001}}}}},"conditions":[{"type":"FunctionSuccess","status":"STATUS_CONDITION_TRUE","reason":"Success","target":"TARGET_COMPOSITE_AND_CLAIM"}]} 107 | } 108 | 109 | func TestBetaServer(t *testing.T) { 110 | type args struct { 111 | ctx context.Context 112 | req *v1beta1.RunFunctionRequest 113 | } 114 | type want struct { 115 | rsp *v1beta1.RunFunctionResponse 116 | err error 117 | } 118 | 119 | cases := map[string]struct { 120 | reason string 121 | wrapped v1.FunctionRunnerServiceServer 122 | args args 123 | want want 124 | }{ 125 | "RunFunctionError": { 126 | reason: "We should return any error the wrapped server encounters", 127 | wrapped: &MockFunctionServer{err: errors.New("boom")}, 128 | args: args{ 129 | req: &v1beta1.RunFunctionRequest{ 130 | Meta: &v1beta1.RequestMeta{ 131 | Tag: "hi", 132 | }, 133 | }, 134 | }, 135 | want: want{ 136 | err: cmpopts.AnyError, 137 | }, 138 | }, 139 | "Success": { 140 | reason: "We should return the response the wrapped server returns", 141 | wrapped: &MockFunctionServer{ 142 | rsp: &v1.RunFunctionResponse{ 143 | Meta: &v1.ResponseMeta{ 144 | Tag: "hello", 145 | }, 146 | }, 147 | }, 148 | args: args{ 149 | req: &v1beta1.RunFunctionRequest{ 150 | Meta: &v1beta1.RequestMeta{ 151 | Tag: "hi", 152 | }, 153 | }, 154 | }, 155 | want: want{ 156 | rsp: &v1beta1.RunFunctionResponse{ 157 | Meta: &v1beta1.ResponseMeta{ 158 | Tag: "hello", 159 | }, 160 | }, 161 | }, 162 | }, 163 | } 164 | 165 | for name, tc := range cases { 166 | t.Run(name, func(t *testing.T) { 167 | s := ServeBeta(tc.wrapped) 168 | rsp, err := s.RunFunction(tc.args.ctx, tc.args.req) 169 | 170 | if diff := cmp.Diff(tc.want.rsp, rsp, protocmp.Transform()); diff != "" { 171 | t.Errorf("\n%s\ns.RunFunction(...): -want rsp, +got rsp:\n%s", tc.reason, diff) 172 | } 173 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 174 | t.Errorf("\n%s\ns.RunFunction(...): -want err, +got err:\n%s", tc.reason, diff) 175 | } 176 | }) 177 | } 178 | 179 | } 180 | 181 | type MockFunctionServer struct { 182 | v1.UnimplementedFunctionRunnerServiceServer 183 | 184 | rsp *v1.RunFunctionResponse 185 | err error 186 | } 187 | 188 | func (s *MockFunctionServer) RunFunction(context.Context, *v1.RunFunctionRequest) (*v1.RunFunctionResponse, error) { 189 | return s.rsp, s.err 190 | } 191 | --------------------------------------------------------------------------------