├── .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 │ └── testresource_test.go ├── composite │ ├── composite.go │ └── composite_test.go └── resource.go ├── response ├── condition.go ├── condition_test.go ├── response.go ├── response_test.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@08eba0b27e820071cde6df949e0beb9ba4906955 # 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.24.4' 14 | GOLANGCI_VERSION: 'v2.2.1' 15 | 16 | jobs: 17 | check-diff: 18 | runs-on: ubuntu-24.04 19 | 20 | steps: 21 | - name: Checkout 22 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 23 | with: 24 | submodules: true 25 | 26 | - name: Setup Go 27 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 28 | with: 29 | go-version: ${{ env.GO_VERSION }} 30 | 31 | - name: Download Go Modules 32 | run: make modules.download modules.check 33 | 34 | - name: Check Diff 35 | run: make check-diff 36 | 37 | detect-noop: 38 | runs-on: ubuntu-24.04 39 | outputs: 40 | noop: ${{ steps.noop.outputs.should_skip }} 41 | steps: 42 | - name: Detect No-op Changes 43 | id: noop 44 | uses: fkirc/skip-duplicate-actions@f75f66ce1886f00957d99748a42c724f4330bdcf # v5.3.1 45 | with: 46 | github_token: ${{ secrets.GITHUB_TOKEN }} 47 | paths_ignore: '["**.md", "**.png", "**.jpg"]' 48 | do_not_skip: '["workflow_dispatch", "schedule", "push"]' 49 | concurrent_skipping: false 50 | 51 | lint: 52 | runs-on: ubuntu-24.04 53 | needs: detect-noop 54 | if: needs.detect-noop.outputs.noop != 'true' 55 | 56 | steps: 57 | - name: Checkout 58 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 59 | with: 60 | submodules: true 61 | 62 | - name: Setup Go 63 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 64 | with: 65 | go-version: ${{ env.GO_VERSION }} 66 | 67 | - name: Download Go Modules 68 | run: make modules.download modules.check 69 | 70 | # We could run 'make lint' to ensure our desired Go version, but we prefer 71 | # this action because it leaves 'annotations' (i.e. it comments on PRs to 72 | # point out linter violations). 73 | - name: Lint 74 | uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 75 | with: 76 | version: ${{ env.GOLANGCI_VERSION }} 77 | 78 | codeql: 79 | runs-on: ubuntu-24.04 80 | needs: detect-noop 81 | if: needs.detect-noop.outputs.noop != 'true' 82 | 83 | steps: 84 | - name: Checkout 85 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 86 | with: 87 | submodules: true 88 | 89 | - name: Setup Go 90 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 91 | with: 92 | go-version: ${{ env.GO_VERSION }} 93 | 94 | - name: Download Go Modules 95 | run: make modules.download modules.check 96 | 97 | - name: Initialize CodeQL 98 | uses: github/codeql-action/init@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3 99 | with: 100 | languages: go 101 | 102 | - name: Perform CodeQL Analysis 103 | uses: github/codeql-action/analyze@df409f7d9260372bd5f19e5b04e83cb3c43714ae # v3 104 | 105 | trivy-scan-fs: 106 | runs-on: ubuntu-24.04 107 | needs: detect-noop 108 | if: needs.detect-noop.outputs.noop != 'true' 109 | steps: 110 | - name: Checkout 111 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 112 | with: 113 | submodules: true 114 | 115 | - name: Run Trivy vulnerability scanner in fs mode 116 | uses: aquasecurity/trivy-action@6e7b7d1fd3e4fef0c5fa8cce1229c54b2c9bd0d8 # 0.24.0 117 | with: 118 | scan-type: 'fs' 119 | ignore-unfixed: true 120 | skip-dirs: design 121 | scan-ref: '.' 122 | exit-code: '1' 123 | severity: 'CRITICAL,HIGH' 124 | 125 | unit-tests: 126 | runs-on: ubuntu-24.04 127 | needs: detect-noop 128 | if: needs.detect-noop.outputs.noop != 'true' 129 | 130 | steps: 131 | - name: Checkout 132 | uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 133 | with: 134 | submodules: true 135 | 136 | - name: Fetch History 137 | run: git fetch --prune --unshallow 138 | 139 | - name: Setup Go 140 | uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 141 | with: 142 | go-version: ${{ env.GO_VERSION }} 143 | 144 | - name: Download Go Modules 145 | run: make modules.download modules.check 146 | 147 | - name: Run Unit Tests 148 | run: make -j2 test 149 | 150 | - name: Publish Unit Test Coverage 151 | uses: codecov/codecov-action@b9fd7d16f6d7d1b5d2bec1a2887e65ceed900238 # v4 152 | with: 153 | flags: unittests 154 | file: _output/tests/linux_amd64/coverage.txt 155 | -------------------------------------------------------------------------------- /.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@08eba0b27e820071cde6df949e0beb9ba4906955 # 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@08eba0b27e820071cde6df949e0beb9ba4906955 # 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@08eba0b27e820071cde6df949e0beb9ba4906955 # 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 | version: "2" 2 | 3 | output: 4 | formats: 5 | text: 6 | path: stderr 7 | 8 | linters: 9 | default: all 10 | disable: 11 | # These are linters we'd like to enable, but that will be labor intensive to 12 | # make existing code compliant. 13 | - wrapcheck 14 | - varnamelen 15 | - testpackage 16 | - paralleltest 17 | - nilnil 18 | - funcorder 19 | 20 | # Below are linters that lint for things we don't value. Each entry below 21 | # this line must have a comment explaining the rationale. 22 | 23 | # These linters add whitespace in an attempt to make code more readable. 24 | # This isn't a widely accepted Go best practice, and would be laborious to 25 | # apply to existing code. 26 | - wsl 27 | - wsl_v5 28 | - nlreturn 29 | 30 | # Warns about uses of fmt.Sprintf that are less performant than alternatives 31 | # such as string concatenation. We value readability more than performance 32 | # unless performance is measured to be an issue. 33 | - perfsprint 34 | 35 | # This linter: 36 | # 37 | # 1. Requires errors.Is/errors.As to test equality. 38 | # 2. Requires all errors be wrapped with fmt.Errorf specifically. 39 | # 3. Disallows errors.New inline - requires package level errors. 40 | # 41 | # 1 is covered by other linters. 2 is covered by wrapcheck, which can also 42 | # handle our use of crossplane-runtime's errors package. 3 is more strict 43 | # than we need. Not every error needs to be tested for equality. 44 | - err113 45 | 46 | # These linters duplicate gocognit, but calculate complexity differently. 47 | - gocyclo 48 | - cyclop 49 | - nestif 50 | - funlen 51 | - maintidx 52 | 53 | # Enforces max line length. It's not idiomatic to enforce a strict limit on 54 | # line length in Go. We'd prefer to lint for things that often cause long 55 | # lines, like functions with too many parameters or long parameter names 56 | # that duplicate their types. 57 | - lll 58 | 59 | # Warns about struct instantiations that don't specify every field. Could be 60 | # useful in theory to catch fields that are accidentally omitted. Seems like 61 | # it would have many more false positives than useful catches, though. 62 | - exhaustruct 63 | 64 | # Warns about TODO comments. The rationale being they should be issues 65 | # instead. We're okay with using TODO to track minor cleanups for next time 66 | # we touch a particular file. 67 | - godox 68 | 69 | # Warns about duplicated code blocks within the same file. Could be useful 70 | # to prompt folks to think about whether code should be broken out into a 71 | # function, but generally we're less worried about DRY and fine with a 72 | # little copying. We don't want to give folks the impression that we require 73 | # every duplicated code block to be factored out into a function. 74 | - dupl 75 | 76 | # Warns about returning interfaces rather than concrete types. We do think 77 | # it's best to avoid returning interfaces where possible. However, at the 78 | # time of writing enabling this linter would only catch the (many) cases 79 | # where we must return an interface. 80 | - ireturn 81 | 82 | # Warns about returning named variables. We do think it's best to avoid 83 | # returning named variables where possible. However, at the time of writing 84 | # enabling this linter would only catch the (many) cases where returning 85 | # named variables is useful to document what the variables are. For example 86 | # we believe it makes sense to return (ready bool) rather than just (bool) 87 | # to communicate what the bool means. 88 | - nonamedreturns 89 | 90 | # Warns about using magic numbers. We do think it's best to avoid magic 91 | # numbers, but we should not be strict about it. 92 | - mnd 93 | 94 | # Warns about if err := Foo(); err != nil style error checks. Seems to go 95 | # against idiomatic Go programming, which encourages this approach - e.g. 96 | # to scope errors. 97 | - noinlineerr 98 | settings: 99 | depguard: 100 | rules: 101 | no_third_party_test_libraries: 102 | list-mode: lax 103 | files: 104 | - $test 105 | deny: 106 | - pkg: github.com/stretchr/testify 107 | desc: See https://go.dev/wiki/TestComments#assert-libraries 108 | - pkg: github.com/onsi/ginkgo 109 | desc: See https://go.dev/wiki/TestComments#assert-libraries 110 | - pkg: github.com/onsi/gomega 111 | desc: See https://go.dev/wiki/TestComments#assert-libraries 112 | dupl: 113 | threshold: 100 114 | errcheck: 115 | check-type-assertions: false 116 | check-blank: false 117 | goconst: 118 | min-len: 3 119 | min-occurrences: 5 120 | gocritic: 121 | enabled-tags: 122 | - performance 123 | settings: 124 | captLocal: 125 | paramsOnly: true 126 | rangeValCopy: 127 | sizeThreshold: 32 128 | govet: 129 | disable: 130 | - shadow 131 | interfacebloat: 132 | max: 5 133 | lll: 134 | tab-width: 1 135 | nakedret: 136 | max-func-lines: 30 137 | nolintlint: 138 | require-explanation: true 139 | require-specific: true 140 | prealloc: 141 | simple: true 142 | range-loops: true 143 | for-loops: false 144 | tagliatelle: 145 | case: 146 | rules: 147 | json: goCamel 148 | unparam: 149 | check-exported: false 150 | unused: 151 | exported-fields-are-used: true 152 | exclusions: 153 | generated: lax 154 | rules: 155 | - linters: 156 | - containedctx 157 | - errcheck 158 | - forcetypeassert 159 | - gochecknoglobals 160 | - gochecknoinits 161 | - gocognit 162 | - gosec 163 | - scopelint 164 | - unparam 165 | - embeddedstructfieldcheck 166 | path: _test(ing)?\.go 167 | 168 | - linters: 169 | - gocritic 170 | path: _test\.go 171 | text: (unnamedResult|exitAfterDefer) 172 | 173 | # It's idiomatic to register Kubernetes types with a package scoped 174 | # SchemeBuilder using an init function. 175 | - linters: 176 | - gochecknoglobals 177 | - gochecknoinits 178 | path: apis/ 179 | 180 | # These are performance optimisations rather than style issues per se. 181 | # They warn when function arguments or range values copy a lot of memory 182 | # rather than using a pointer. 183 | - linters: 184 | - gocritic 185 | text: '(hugeParam|rangeValCopy):' 186 | 187 | # This "TestMain should call os.Exit to set exit code" warning is not clever 188 | # enough to notice that we call a helper method that calls os.Exit. 189 | - linters: 190 | - staticcheck 191 | text: 'SA3000:' 192 | 193 | # This is a "potential hardcoded credentials" warning. It's triggered by 194 | # any variable with 'secret' in the same, and thus hits a lot of false 195 | # positives in Kubernetes land where a Secret is an object type. 196 | - linters: 197 | - gosec 198 | text: 'G101:' 199 | 200 | # This is an 'errors unhandled' warning that duplicates errcheck. 201 | - linters: 202 | - gosec 203 | text: 'G104:' 204 | 205 | # This is about implicit memory aliasing in a range loop. 206 | # This is a false positive with Go v1.22 and above. 207 | - linters: 208 | - gosec 209 | text: 'G601:' 210 | 211 | # Some k8s dependencies do not have JSON tags on all fields in structs. 212 | - linters: 213 | - musttag 214 | path: k8s.io/ 215 | 216 | # Various fields related to native patch and transform Composition are 217 | # deprecated, but we can't drop support from Crossplane 1.x. We ignore the 218 | # warnings globally instead of suppressing them with comments everywhere. 219 | - linters: 220 | - staticcheck 221 | text: 'SA1019: .+ is deprecated: Use Composition Functions instead.' 222 | 223 | paths: 224 | - zz_generated\..+\.go$ 225 | - .+\.pb.go$ 226 | - third_party$ 227 | - builtin$ 228 | - examples$ 229 | 230 | issues: 231 | max-issues-per-linter: 0 232 | max-same-issues: 0 233 | new: false 234 | 235 | formatters: 236 | enable: 237 | - gci 238 | - gofmt 239 | - gofumpt 240 | - goimports 241 | settings: 242 | gci: 243 | sections: 244 | - standard 245 | - default 246 | - prefix(github.com/crossplane/crossplane-runtime) 247 | - prefix(github.com/crossplane/function-sdk-go) 248 | - blank 249 | - dot 250 | custom-order: true 251 | gofmt: 252 | simplify: true 253 | exclusions: 254 | generated: lax 255 | paths: 256 | - zz_generated\..+\.go$ 257 | - .+\.pb.go$ 258 | - third_party$ 259 | - builtin$ 260 | - examples$ 261 | -------------------------------------------------------------------------------- /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 = 2.2.1 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 | 161 | func (m multiError) Unwrap() []error { 162 | return m.aggregate.Errors() 163 | } 164 | -------------------------------------------------------------------------------- /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.24 4 | 5 | require ( 6 | github.com/bufbuild/buf v1.42.0 7 | github.com/crossplane/crossplane-runtime v1.18.0 8 | github.com/go-json-experiment/json v0.0.0-20240815175050-ebd3a8989ca1 9 | github.com/go-logr/logr v1.4.2 10 | github.com/go-logr/zapr v1.3.0 11 | github.com/google/go-cmp v0.6.0 12 | github.com/pkg/errors v0.9.1 13 | go.uber.org/zap v1.27.0 14 | google.golang.org/grpc v1.67.0 15 | google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.5.1 16 | google.golang.org/protobuf v1.34.3-0.20240816073751-94ecbc261689 17 | k8s.io/api v0.31.0 18 | k8s.io/apimachinery v0.31.0 19 | k8s.io/utils v0.0.0-20240902221715-702e33fdd3c3 20 | sigs.k8s.io/yaml v1.4.0 21 | ) 22 | 23 | require ( 24 | buf.build/gen/go/bufbuild/bufplugin/protocolbuffers/go v1.34.2-20240904181154-a0be11449112.2 // indirect 25 | buf.build/gen/go/bufbuild/protovalidate/protocolbuffers/go v1.34.2-20240717164558-a6c49f84cc0f.2 // indirect 26 | buf.build/gen/go/bufbuild/registry/connectrpc/go v1.16.2-20240821192916-45ba72cdd479.1 // indirect 27 | buf.build/gen/go/bufbuild/registry/protocolbuffers/go v1.34.2-20240821192916-45ba72cdd479.2 // indirect 28 | buf.build/gen/go/pluginrpc/pluginrpc/protocolbuffers/go v1.34.2-20240828222655-5345c0a56177.2 // indirect 29 | buf.build/go/bufplugin v0.2.0 // indirect 30 | buf.build/go/protoyaml v0.2.0 // indirect 31 | connectrpc.com/connect v1.16.2 // indirect 32 | connectrpc.com/otelconnect v0.7.1 // indirect 33 | dario.cat/mergo v1.0.1 // indirect 34 | github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect 35 | github.com/Microsoft/go-winio v0.6.2 // indirect 36 | github.com/Microsoft/hcsshim v0.12.6 // indirect 37 | github.com/antlr4-go/antlr/v4 v4.13.1 // indirect 38 | github.com/bufbuild/protocompile v0.14.1 // indirect 39 | github.com/bufbuild/protoplugin v0.0.0-20240911180120-7bb73e41a54a // indirect 40 | github.com/bufbuild/protovalidate-go v0.6.5 // indirect 41 | github.com/containerd/cgroups/v3 v3.0.3 // indirect 42 | github.com/containerd/containerd v1.7.22 // indirect 43 | github.com/containerd/continuity v0.4.3 // indirect 44 | github.com/containerd/errdefs v0.2.0 // indirect 45 | github.com/containerd/log v0.1.0 // indirect 46 | github.com/containerd/platforms v0.2.1 // indirect 47 | github.com/containerd/stargz-snapshotter/estargz v0.15.1 // indirect 48 | github.com/containerd/ttrpc v1.2.5 // indirect 49 | github.com/containerd/typeurl/v2 v2.2.0 // indirect 50 | github.com/cpuguy83/go-md2man/v2 v2.0.4 // indirect 51 | github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect 52 | github.com/distribution/reference v0.6.0 // indirect 53 | github.com/docker/cli v27.2.1+incompatible // indirect 54 | github.com/docker/distribution v2.8.3+incompatible // indirect 55 | github.com/docker/docker v27.2.1+incompatible // indirect 56 | github.com/docker/docker-credential-helpers v0.8.2 // indirect 57 | github.com/docker/go-connections v0.5.0 // indirect 58 | github.com/docker/go-units v0.5.0 // indirect 59 | github.com/emicklei/go-restful/v3 v3.11.0 // indirect 60 | github.com/evanphx/json-patch/v5 v5.9.0 // indirect 61 | github.com/fatih/color v1.17.0 // indirect 62 | github.com/felixge/fgprof v0.9.5 // indirect 63 | github.com/felixge/httpsnoop v1.0.4 // indirect 64 | github.com/fxamacker/cbor/v2 v2.7.0 // indirect 65 | github.com/go-chi/chi/v5 v5.2.2 // indirect 66 | github.com/go-logr/stdr v1.2.2 // indirect 67 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 68 | github.com/go-openapi/jsonreference v0.20.2 // indirect 69 | github.com/go-openapi/swag v0.22.4 // indirect 70 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 71 | github.com/gobuffalo/flect v1.0.2 // indirect 72 | github.com/gofrs/flock v0.12.1 // indirect 73 | github.com/gofrs/uuid/v5 v5.3.0 // indirect 74 | github.com/gogo/protobuf v1.3.2 // indirect 75 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 76 | github.com/golang/protobuf v1.5.4 // indirect 77 | github.com/google/cel-go v0.21.0 // indirect 78 | github.com/google/gnostic-models v0.6.8 // indirect 79 | github.com/google/go-containerregistry v0.20.2 // indirect 80 | github.com/google/gofuzz v1.2.0 // indirect 81 | github.com/google/pprof v0.0.0-20240910150728-a0b0bb1d4134 // indirect 82 | github.com/google/uuid v1.6.0 // indirect 83 | github.com/inconshreveable/mousetrap v1.1.0 // indirect 84 | github.com/jdx/go-netrc v1.0.0 // indirect 85 | github.com/josharian/intern v1.0.0 // indirect 86 | github.com/json-iterator/go v1.1.12 // indirect 87 | github.com/klauspost/compress v1.17.9 // indirect 88 | github.com/klauspost/pgzip v1.2.6 // indirect 89 | github.com/mailru/easyjson v0.7.7 // indirect 90 | github.com/mattn/go-colorable v0.1.13 // indirect 91 | github.com/mattn/go-isatty v0.0.20 // indirect 92 | github.com/mitchellh/go-homedir v1.1.0 // indirect 93 | github.com/moby/docker-image-spec v1.3.1 // indirect 94 | github.com/moby/locker v1.0.1 // indirect 95 | github.com/moby/patternmatcher v0.6.0 // indirect 96 | github.com/moby/sys/mount v0.3.4 // indirect 97 | github.com/moby/sys/mountinfo v0.7.2 // indirect 98 | github.com/moby/sys/sequential v0.6.0 // indirect 99 | github.com/moby/sys/user v0.3.0 // indirect 100 | github.com/moby/sys/userns v0.1.0 // indirect 101 | github.com/moby/term v0.5.0 // indirect 102 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 103 | github.com/modern-go/reflect2 v1.0.2 // indirect 104 | github.com/morikuni/aec v1.0.0 // indirect 105 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 106 | github.com/onsi/ginkgo/v2 v2.20.2 // indirect 107 | github.com/opencontainers/go-digest v1.0.0 // indirect 108 | github.com/opencontainers/image-spec v1.1.0 // indirect 109 | github.com/opencontainers/runtime-spec v1.2.0 // indirect 110 | github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c // indirect 111 | github.com/pkg/profile v1.7.0 // indirect 112 | github.com/quic-go/qpack v0.5.1 // indirect 113 | github.com/quic-go/quic-go v0.48.2 // indirect 114 | github.com/rs/cors v1.11.1 // indirect 115 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 116 | github.com/sirupsen/logrus v1.9.3 // indirect 117 | github.com/spf13/afero v1.11.0 // indirect 118 | github.com/spf13/cobra v1.8.1 // indirect 119 | github.com/spf13/pflag v1.0.5 // indirect 120 | github.com/stoewer/go-strcase v1.3.0 // indirect 121 | github.com/vbatts/tar-split v0.11.5 // indirect 122 | github.com/x448/float16 v0.8.4 // indirect 123 | go.opencensus.io v0.24.0 // indirect 124 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.55.0 // indirect 125 | go.opentelemetry.io/otel v1.30.0 // indirect 126 | go.opentelemetry.io/otel/metric v1.30.0 // indirect 127 | go.opentelemetry.io/otel/sdk v1.30.0 // indirect 128 | go.opentelemetry.io/otel/trace v1.30.0 // indirect 129 | go.uber.org/atomic v1.11.0 // indirect 130 | go.uber.org/mock v0.4.0 // indirect 131 | go.uber.org/multierr v1.11.0 // indirect 132 | golang.org/x/crypto v0.36.0 // indirect 133 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 // indirect 134 | golang.org/x/mod v0.21.0 // indirect 135 | golang.org/x/net v0.38.0 // indirect 136 | golang.org/x/oauth2 v0.27.0 // indirect 137 | golang.org/x/sync v0.12.0 // indirect 138 | golang.org/x/sys v0.31.0 // indirect 139 | golang.org/x/term v0.30.0 // indirect 140 | golang.org/x/text v0.23.0 // indirect 141 | golang.org/x/time v0.5.0 // indirect 142 | golang.org/x/tools v0.25.0 // indirect 143 | google.golang.org/genproto/googleapis/api v0.0.0-20240903143218-8af14fe29dc1 // indirect 144 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect 145 | gopkg.in/inf.v0 v0.9.1 // indirect 146 | gopkg.in/yaml.v2 v2.4.0 // indirect 147 | gopkg.in/yaml.v3 v3.0.1 // indirect 148 | k8s.io/apiextensions-apiserver v0.31.0 // indirect 149 | k8s.io/client-go v0.31.0 // indirect 150 | k8s.io/klog/v2 v2.130.1 // indirect 151 | k8s.io/kube-openapi v0.0.0-20240228011516-70dd3763d340 // indirect 152 | pluginrpc.com/pluginrpc v0.3.0 // indirect 153 | sigs.k8s.io/controller-runtime v0.19.0 // indirect 154 | sigs.k8s.io/controller-tools v0.16.0 // indirect 155 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 156 | sigs.k8s.io/structured-merge-diff/v4 v4.4.1 // indirect 157 | ) 158 | -------------------------------------------------------------------------------- /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 | // This package defines the RPC for both composition and operation functions. 20 | // Only composition functions are part of the 'apiextensions' API group. In 21 | // retrospect this package should've been crossplane.proto.fn.v1, but it's too 22 | // late to change it now. 23 | 24 | //buf:lint:ignore PACKAGE_DIRECTORY_MATCH 25 | package apiextensions.fn.proto.v1; 26 | 27 | import "google/protobuf/duration.proto"; 28 | import "google/protobuf/struct.proto"; 29 | 30 | option go_package = "github.com/crossplane/crossplane/v2/proto/fn/v1"; 31 | 32 | // A FunctionRunnerService is a function. 33 | service FunctionRunnerService { 34 | // RunFunction runs the function. 35 | rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {} 36 | } 37 | 38 | // A RunFunctionRequest requests that the function be run. 39 | message RunFunctionRequest { 40 | // Metadata pertaining to this request. 41 | RequestMeta meta = 1; 42 | 43 | // The observed state prior to invocation of a function pipeline. State passed 44 | // to each function is fresh as of the time the pipeline was invoked, not as 45 | // of the time each function was invoked. 46 | State observed = 2; 47 | 48 | // Desired state according to a function pipeline. The state passed to a 49 | // particular function may have been accumulated by previous functions in the 50 | // pipeline. 51 | // 52 | // Note that the desired state must be a partial object with only the fields 53 | // that this function (and its predecessors in the pipeline) wants to have set 54 | // in the object. Copying a non-partial observed state to desired is most 55 | // likely not what you want to do. Leaving out fields that had been returned 56 | // as desired before will result in them being deleted from the objects in the 57 | // cluster. 58 | State desired = 3; 59 | 60 | // Optional input specific to this function invocation. A JSON representation 61 | // of the 'input' block of the relevant entry in a function pipeline. 62 | optional google.protobuf.Struct input = 4; 63 | 64 | // Optional context. Crossplane may pass arbitrary contextual information to a 65 | // function. A function may also return context in its RunFunctionResponse, 66 | // and that context will be passed to subsequent functions. Crossplane 67 | // discards all context returned by the last function in the pipeline. 68 | optional google.protobuf.Struct context = 5; 69 | 70 | // Optional resources that the function specified in its requirements. Note 71 | // that resources is a map to Resources, plural. The map key corresponds to 72 | // the key in a RunFunctionResponse's requirements.extra_resources field. If a 73 | // function requested extra resources that did not exist, Crossplane sets 74 | // the map key to an empty Resources message to indicate that it attempted to 75 | // satisfy the request. This field is only populated when the function uses 76 | // extra_resources in its requirements. 77 | // 78 | // Deprecated: Use required_resources instead. 79 | map extra_resources = 6 [deprecated = true]; 80 | 81 | // Optional credentials that this function may use to communicate with an 82 | // external system. 83 | map credentials = 7; 84 | 85 | // Optional resources that the function specified in its requirements. Note 86 | // that resources is a map to Resources, plural. The map key corresponds to 87 | // the key in a RunFunctionResponse's requirements.resources field. If a 88 | // function requested required resources that did not exist, Crossplane sets 89 | // the map key to an empty Resources message to indicate that it attempted to 90 | // satisfy the request. This field is only populated when the function uses 91 | // resources in its requirements. 92 | map required_resources = 8; 93 | } 94 | 95 | // Credentials that a function may use to communicate with an external system. 96 | message Credentials { 97 | // Source of the credentials. 98 | oneof source { 99 | // Credential data loaded by Crossplane, for example from a Secret. 100 | CredentialData credential_data = 1; 101 | } 102 | } 103 | 104 | // CredentialData loaded by Crossplane, for example from a Secret. 105 | message CredentialData { 106 | map data = 1; 107 | } 108 | 109 | // Resources represents the state of several Crossplane resources. 110 | message Resources { 111 | repeated Resource items = 1; 112 | } 113 | 114 | // A RunFunctionResponse contains the result of a function run. 115 | message RunFunctionResponse { 116 | // Metadata pertaining to this response. 117 | ResponseMeta meta = 1; 118 | 119 | // Desired state according to a function pipeline. functions may add desired 120 | // state, and may mutate or delete any part of the desired state they are 121 | // concerned with. A function must pass through any part of the desired state 122 | // that it is not concerned with. 123 | // 124 | // Note that the desired state must be a partial object with only the fields 125 | // that this function (and its predecessors in the pipeline) wants to have set 126 | // in the object. Copying a non-partial observed state to desired is most 127 | // likely not what you want to do. Leaving out fields that had been returned 128 | // as desired before will result in them being deleted from the objects in the 129 | // cluster. 130 | State desired = 2; 131 | 132 | // Results of the function run. Results are used for observability purposes. 133 | repeated Result results = 3; 134 | 135 | // Optional context to be passed to the next function in the pipeline as part 136 | // of the RunFunctionRequest. Dropped on the last function in the pipeline. 137 | optional google.protobuf.Struct context = 4; 138 | 139 | // Requirements that must be satisfied for this function to run successfully. 140 | Requirements requirements = 5; 141 | 142 | // Status conditions to be applied to the XR. Conditions may also optionally 143 | // be applied to the XR's associated claim. 144 | // 145 | // Conditions are only used for composition. They're ignored by Operations. 146 | repeated Condition conditions = 6; 147 | 148 | // Optional output specific to this function invocation. 149 | // 150 | // Only Operations use function output. XRs will discard any function output. 151 | optional google.protobuf.Struct output = 7; 152 | } 153 | 154 | // RequestMeta contains metadata pertaining to a RunFunctionRequest. 155 | message RequestMeta { 156 | // An opaque string identifying a request. Requests with identical tags will 157 | // be otherwise identical. 158 | string tag = 1; 159 | } 160 | 161 | // Requirements that must be satisfied for a function to run successfully. 162 | message Requirements { 163 | // Resources that this function requires. The map key uniquely identifies the 164 | // group of resources. 165 | // 166 | // Deprecated: Use resources instead. 167 | map extra_resources = 1 [deprecated = true]; 168 | 169 | // Resources that this function requires. The map key uniquely identifies the 170 | // group of resources. 171 | map resources = 2; 172 | } 173 | 174 | // ResourceSelector selects a group of resources, either by name or by label. 175 | message ResourceSelector { 176 | // API version of resources to select. 177 | string api_version = 1; 178 | 179 | // Kind of resources to select. 180 | string kind = 2; 181 | 182 | // Resources to match. 183 | oneof match { 184 | // Match the resource with this name. 185 | string match_name = 3; 186 | 187 | // Match all resources with these labels. 188 | MatchLabels match_labels = 4; 189 | } 190 | 191 | // Match resources in this namespace. Omit namespace to match cluster scoped 192 | // resources, or to match namespaced resources by labels across all 193 | // namespaces. 194 | optional string namespace = 5; 195 | } 196 | 197 | // MatchLabels defines a set of labels to match resources against. 198 | message MatchLabels { 199 | map labels = 1; 200 | } 201 | 202 | // ResponseMeta contains metadata pertaining to a RunFunctionResponse. 203 | message ResponseMeta { 204 | // An opaque string identifying the content of the request. Must match the 205 | // meta.tag of the corresponding RunFunctionRequest. 206 | string tag = 1; 207 | 208 | // Time-to-live of this response. Crossplane will call the function again when 209 | // the TTL expires. Crossplane may cache the response to avoid calling the 210 | // function again until the TTL expires. 211 | optional google.protobuf.Duration ttl = 2; 212 | } 213 | 214 | // State of the XR (XR) and any resources. 215 | message State { 216 | // The state of the XR (XR). 217 | Resource composite = 1; 218 | 219 | // The state of any other resources. In composition functions these are the 220 | // composed resources. In operation functions they're arbitrary resources that 221 | // the operation wants to create or update. 222 | map resources = 2; 223 | } 224 | 225 | // A Resource represents the state of a Kubernetes resource. 226 | message Resource { 227 | // The JSON representation of the resource. 228 | // 229 | // * Crossplane will set this field in a RunFunctionRequest to the entire 230 | // observed state of a resource - including its metadata, spec, and status. 231 | // 232 | // * A function should set this field in a RunFunctionRequest to communicate 233 | // the desired state of the resource. 234 | // 235 | // * A function may only specify the desired status of a XR - not its metadata 236 | // or spec. A function should not return desired metadata or spec for a XR. 237 | // This will be ignored. 238 | // 239 | // * A function may not specify the desired status of any other resource - 240 | // e.g. composed resources. It may only specify their metadata and spec. 241 | // Status will be ignored. 242 | google.protobuf.Struct resource = 1; 243 | 244 | // The resource's connection details. 245 | // 246 | // * Crossplane will set this field in a RunFunctionRequest to communicate the 247 | // the observed connection details of a composite or composed resource. 248 | // 249 | // * A function should set this field in a RunFunctionResponse to indicate the 250 | // desired connection details of the XR. 251 | // 252 | // * A function should not set this field in a RunFunctionResponse to indicate 253 | // the desired connection details of a composed resource. This will be 254 | // ignored. 255 | // 256 | // Connection details are only used for composition. They're ignored by 257 | // Operations. 258 | map connection_details = 2; 259 | 260 | // Ready indicates whether the resource should be considered ready. 261 | // 262 | // * Crossplane will never set this field in a RunFunctionRequest. 263 | // 264 | // * A function should set this field to READY_TRUE in a RunFunctionResponse 265 | // to indicate that a desired resource is ready. 266 | // 267 | // * A function should set this field to READY_TRUE in a RunFunctionResponse 268 | // to indicate that a desired XR is ready. This overwrites the standard 269 | // readiness detection that determines the ready state of the composite by the 270 | // ready state of the the composed resources. 271 | // 272 | // Ready is only used for composition. It's ignored by Operations. 273 | Ready ready = 3; 274 | } 275 | 276 | // Ready indicates whether a resource should be considered ready. 277 | enum Ready { 278 | READY_UNSPECIFIED = 0; 279 | 280 | // True means the resource has been observed to be ready. 281 | READY_TRUE = 1; 282 | 283 | // False means the resource has not been observed to be ready. 284 | READY_FALSE = 2; 285 | } 286 | 287 | // A Result of running a function. 288 | message Result { 289 | // Severity of this result. 290 | Severity severity = 1; 291 | 292 | // Human-readable details about the result. 293 | string message = 2; 294 | 295 | // Optional PascalCase, machine-readable reason for this result. If omitted, 296 | // the value will be ComposeResources. 297 | optional string reason = 3; 298 | 299 | // The resources this result targets. 300 | optional Target target = 4; 301 | } 302 | 303 | // Severity of function results. 304 | enum Severity { 305 | SEVERITY_UNSPECIFIED = 0; 306 | 307 | // Fatal results are fatal; subsequent functions may run, but the function 308 | // pipeline run will be considered a failure and the first fatal result will 309 | // be returned as an error. 310 | SEVERITY_FATAL = 1; 311 | 312 | // Warning results are non-fatal; the entire pipeline will run to completion 313 | // but warning events and debug logs associated with the XR or Operation will 314 | // be emitted. 315 | SEVERITY_WARNING = 2; 316 | 317 | // Normal results are emitted as normal events and debug logs associated with 318 | // the XR or operation. 319 | SEVERITY_NORMAL = 3; 320 | } 321 | 322 | // Target of function results and conditions. 323 | enum Target { 324 | // If the target is unspecified, the result targets the XR. 325 | TARGET_UNSPECIFIED = 0; 326 | 327 | // Target the XR. Results that target the XR should include detailed, advanced 328 | // information. 329 | TARGET_COMPOSITE = 1; 330 | 331 | // Target the XR and the claim. Results that target the XR and the claim 332 | // should include only end-user friendly information. 333 | TARGET_COMPOSITE_AND_CLAIM = 2; 334 | } 335 | 336 | // Status condition to be applied to the XR. Condition may also optionally be 337 | // applied to the XR's associated claim. For detailed information on proper 338 | // usage of status conditions, please see 339 | // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties. 340 | message Condition { 341 | // Type of condition in PascalCase. 342 | string type = 1; 343 | 344 | // Status of the condition. 345 | Status status = 2; 346 | 347 | // Reason contains a programmatic identifier indicating the reason for the 348 | // condition's last transition. Producers of specific condition types may 349 | // define expected values and meanings for this field, and whether the values 350 | // are considered a guaranteed API. The value should be a PascalCase string. 351 | // This field may not be empty. 352 | string reason = 3; 353 | 354 | // Message is a human readable message indicating details about the 355 | // transition. This may be an empty string. 356 | optional string message = 4; 357 | 358 | // The resources this condition targets. 359 | optional Target target = 5; 360 | } 361 | 362 | enum Status { 363 | STATUS_CONDITION_UNSPECIFIED = 0; 364 | 365 | STATUS_CONDITION_UNKNOWN = 1; 366 | 367 | STATUS_CONDITION_TRUE = 2; 368 | 369 | STATUS_CONDITION_FALSE = 3; 370 | } 371 | -------------------------------------------------------------------------------- /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 | // This package defines the RPC for both composition and operation functions. 23 | // Only composition functions are part of the 'apiextensions' API group. In 24 | // retrospect this package should've been crossplane.proto.fn.v1, but it's too 25 | // late to change it now. 26 | 27 | //buf:lint:ignore PACKAGE_DIRECTORY_MATCH 28 | 29 | package v1 30 | 31 | import ( 32 | context "context" 33 | grpc "google.golang.org/grpc" 34 | codes "google.golang.org/grpc/codes" 35 | status "google.golang.org/grpc/status" 36 | ) 37 | 38 | // This is a compile-time assertion to ensure that this generated file 39 | // is compatible with the grpc package it is being compiled against. 40 | // Requires gRPC-Go v1.64.0 or later. 41 | const _ = grpc.SupportPackageIsVersion9 42 | 43 | const ( 44 | FunctionRunnerService_RunFunction_FullMethodName = "/apiextensions.fn.proto.v1.FunctionRunnerService/RunFunction" 45 | ) 46 | 47 | // FunctionRunnerServiceClient is the client API for FunctionRunnerService service. 48 | // 49 | // 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. 50 | // 51 | // A FunctionRunnerService is a function. 52 | type FunctionRunnerServiceClient interface { 53 | // RunFunction runs the function. 54 | RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) 55 | } 56 | 57 | type functionRunnerServiceClient struct { 58 | cc grpc.ClientConnInterface 59 | } 60 | 61 | func NewFunctionRunnerServiceClient(cc grpc.ClientConnInterface) FunctionRunnerServiceClient { 62 | return &functionRunnerServiceClient{cc} 63 | } 64 | 65 | func (c *functionRunnerServiceClient) RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) { 66 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 67 | out := new(RunFunctionResponse) 68 | err := c.cc.Invoke(ctx, FunctionRunnerService_RunFunction_FullMethodName, in, out, cOpts...) 69 | if err != nil { 70 | return nil, err 71 | } 72 | return out, nil 73 | } 74 | 75 | // FunctionRunnerServiceServer is the server API for FunctionRunnerService service. 76 | // All implementations must embed UnimplementedFunctionRunnerServiceServer 77 | // for forward compatibility. 78 | // 79 | // A FunctionRunnerService is a function. 80 | type FunctionRunnerServiceServer interface { 81 | // RunFunction runs the function. 82 | RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) 83 | mustEmbedUnimplementedFunctionRunnerServiceServer() 84 | } 85 | 86 | // UnimplementedFunctionRunnerServiceServer must be embedded to have 87 | // forward compatible implementations. 88 | // 89 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 90 | // pointer dereference when methods are called. 91 | type UnimplementedFunctionRunnerServiceServer struct{} 92 | 93 | func (UnimplementedFunctionRunnerServiceServer) RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) { 94 | return nil, status.Errorf(codes.Unimplemented, "method RunFunction not implemented") 95 | } 96 | func (UnimplementedFunctionRunnerServiceServer) mustEmbedUnimplementedFunctionRunnerServiceServer() {} 97 | func (UnimplementedFunctionRunnerServiceServer) testEmbeddedByValue() {} 98 | 99 | // UnsafeFunctionRunnerServiceServer may be embedded to opt out of forward compatibility for this service. 100 | // Use of this interface is not recommended, as added methods to FunctionRunnerServiceServer will 101 | // result in compilation errors. 102 | type UnsafeFunctionRunnerServiceServer interface { 103 | mustEmbedUnimplementedFunctionRunnerServiceServer() 104 | } 105 | 106 | func RegisterFunctionRunnerServiceServer(s grpc.ServiceRegistrar, srv FunctionRunnerServiceServer) { 107 | // If the following call pancis, it indicates UnimplementedFunctionRunnerServiceServer was 108 | // embedded by pointer and is nil. This will cause panics if an 109 | // unimplemented method is ever invoked, so we test this at initialization 110 | // time to prevent it from happening at runtime later due to I/O. 111 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 112 | t.testEmbeddedByValue() 113 | } 114 | s.RegisterService(&FunctionRunnerService_ServiceDesc, srv) 115 | } 116 | 117 | func _FunctionRunnerService_RunFunction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 118 | in := new(RunFunctionRequest) 119 | if err := dec(in); err != nil { 120 | return nil, err 121 | } 122 | if interceptor == nil { 123 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, in) 124 | } 125 | info := &grpc.UnaryServerInfo{ 126 | Server: srv, 127 | FullMethod: FunctionRunnerService_RunFunction_FullMethodName, 128 | } 129 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 130 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, req.(*RunFunctionRequest)) 131 | } 132 | return interceptor(ctx, in, info, handler) 133 | } 134 | 135 | // FunctionRunnerService_ServiceDesc is the grpc.ServiceDesc for FunctionRunnerService service. 136 | // It's only intended for direct use with grpc.RegisterService, 137 | // and not to be introspected or modified (even as a copy) 138 | var FunctionRunnerService_ServiceDesc = grpc.ServiceDesc{ 139 | ServiceName: "apiextensions.fn.proto.v1.FunctionRunnerService", 140 | HandlerType: (*FunctionRunnerServiceServer)(nil), 141 | Methods: []grpc.MethodDesc{ 142 | { 143 | MethodName: "RunFunction", 144 | Handler: _FunctionRunnerService_RunFunction_Handler, 145 | }, 146 | }, 147 | Streams: []grpc.StreamDesc{}, 148 | Metadata: "v1/run_function.proto", 149 | } 150 | -------------------------------------------------------------------------------- /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 | // Generated from proto/fn/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT. 18 | 19 | syntax = "proto3"; 20 | 21 | // This package defines the RPC for both composition and operation functions. 22 | // Only composition functions are part of the 'apiextensions' API group. In 23 | // retrospect this package should've been crossplane.proto.fn.v1, but it's too 24 | // late to change it now. 25 | 26 | //buf:lint:ignore PACKAGE_DIRECTORY_MATCH 27 | package apiextensions.fn.proto.v1beta1; 28 | 29 | import "google/protobuf/duration.proto"; 30 | import "google/protobuf/struct.proto"; 31 | 32 | option go_package = "github.com/crossplane/crossplane/v2/proto/fn/v1beta1"; 33 | 34 | // A FunctionRunnerService is a function. 35 | service FunctionRunnerService { 36 | // RunFunction runs the function. 37 | rpc RunFunction(RunFunctionRequest) returns (RunFunctionResponse) {} 38 | } 39 | 40 | // A RunFunctionRequest requests that the function be run. 41 | message RunFunctionRequest { 42 | // Metadata pertaining to this request. 43 | RequestMeta meta = 1; 44 | 45 | // The observed state prior to invocation of a function pipeline. State passed 46 | // to each function is fresh as of the time the pipeline was invoked, not as 47 | // of the time each function was invoked. 48 | State observed = 2; 49 | 50 | // Desired state according to a function pipeline. The state passed to a 51 | // particular function may have been accumulated by previous functions in the 52 | // pipeline. 53 | // 54 | // Note that the desired state must be a partial object with only the fields 55 | // that this function (and its predecessors in the pipeline) wants to have set 56 | // in the object. Copying a non-partial observed state to desired is most 57 | // likely not what you want to do. Leaving out fields that had been returned 58 | // as desired before will result in them being deleted from the objects in the 59 | // cluster. 60 | State desired = 3; 61 | 62 | // Optional input specific to this function invocation. A JSON representation 63 | // of the 'input' block of the relevant entry in a function pipeline. 64 | optional google.protobuf.Struct input = 4; 65 | 66 | // Optional context. Crossplane may pass arbitrary contextual information to a 67 | // function. A function may also return context in its RunFunctionResponse, 68 | // and that context will be passed to subsequent functions. Crossplane 69 | // discards all context returned by the last function in the pipeline. 70 | optional google.protobuf.Struct context = 5; 71 | 72 | // Optional resources that the function specified in its requirements. Note 73 | // that resources is a map to Resources, plural. The map key corresponds to 74 | // the key in a RunFunctionResponse's requirements.extra_resources field. If a 75 | // function requested extra resources that did not exist, Crossplane sets 76 | // the map key to an empty Resources message to indicate that it attempted to 77 | // satisfy the request. This field is only populated when the function uses 78 | // extra_resources in its requirements. 79 | // 80 | // Deprecated: Use required_resources instead. 81 | map extra_resources = 6 [deprecated = true]; 82 | 83 | // Optional credentials that this function may use to communicate with an 84 | // external system. 85 | map credentials = 7; 86 | 87 | // Optional resources that the function specified in its requirements. Note 88 | // that resources is a map to Resources, plural. The map key corresponds to 89 | // the key in a RunFunctionResponse's requirements.resources field. If a 90 | // function requested required resources that did not exist, Crossplane sets 91 | // the map key to an empty Resources message to indicate that it attempted to 92 | // satisfy the request. This field is only populated when the function uses 93 | // resources in its requirements. 94 | map required_resources = 8; 95 | } 96 | 97 | // Credentials that a function may use to communicate with an external system. 98 | message Credentials { 99 | // Source of the credentials. 100 | oneof source { 101 | // Credential data loaded by Crossplane, for example from a Secret. 102 | CredentialData credential_data = 1; 103 | } 104 | } 105 | 106 | // CredentialData loaded by Crossplane, for example from a Secret. 107 | message CredentialData { 108 | map data = 1; 109 | } 110 | 111 | // Resources represents the state of several Crossplane resources. 112 | message Resources { 113 | repeated Resource items = 1; 114 | } 115 | 116 | // A RunFunctionResponse contains the result of a function run. 117 | message RunFunctionResponse { 118 | // Metadata pertaining to this response. 119 | ResponseMeta meta = 1; 120 | 121 | // Desired state according to a function pipeline. functions may add desired 122 | // state, and may mutate or delete any part of the desired state they are 123 | // concerned with. A function must pass through any part of the desired state 124 | // that it is not concerned with. 125 | // 126 | // Note that the desired state must be a partial object with only the fields 127 | // that this function (and its predecessors in the pipeline) wants to have set 128 | // in the object. Copying a non-partial observed state to desired is most 129 | // likely not what you want to do. Leaving out fields that had been returned 130 | // as desired before will result in them being deleted from the objects in the 131 | // cluster. 132 | State desired = 2; 133 | 134 | // Results of the function run. Results are used for observability purposes. 135 | repeated Result results = 3; 136 | 137 | // Optional context to be passed to the next function in the pipeline as part 138 | // of the RunFunctionRequest. Dropped on the last function in the pipeline. 139 | optional google.protobuf.Struct context = 4; 140 | 141 | // Requirements that must be satisfied for this function to run successfully. 142 | Requirements requirements = 5; 143 | 144 | // Status conditions to be applied to the XR. Conditions may also optionally 145 | // be applied to the XR's associated claim. 146 | // 147 | // Conditions are only used for composition. They're ignored by Operations. 148 | repeated Condition conditions = 6; 149 | 150 | // Optional output specific to this function invocation. 151 | // 152 | // Only Operations use function output. XRs will discard any function output. 153 | optional google.protobuf.Struct output = 7; 154 | } 155 | 156 | // RequestMeta contains metadata pertaining to a RunFunctionRequest. 157 | message RequestMeta { 158 | // An opaque string identifying a request. Requests with identical tags will 159 | // be otherwise identical. 160 | string tag = 1; 161 | } 162 | 163 | // Requirements that must be satisfied for a function to run successfully. 164 | message Requirements { 165 | // Resources that this function requires. The map key uniquely identifies the 166 | // group of resources. 167 | // 168 | // Deprecated: Use resources instead. 169 | map extra_resources = 1 [deprecated = true]; 170 | 171 | // Resources that this function requires. The map key uniquely identifies the 172 | // group of resources. 173 | map resources = 2; 174 | } 175 | 176 | // ResourceSelector selects a group of resources, either by name or by label. 177 | message ResourceSelector { 178 | // API version of resources to select. 179 | string api_version = 1; 180 | 181 | // Kind of resources to select. 182 | string kind = 2; 183 | 184 | // Resources to match. 185 | oneof match { 186 | // Match the resource with this name. 187 | string match_name = 3; 188 | 189 | // Match all resources with these labels. 190 | MatchLabels match_labels = 4; 191 | } 192 | 193 | // Match resources in this namespace. Omit namespace to match cluster scoped 194 | // resources, or to match namespaced resources by labels across all 195 | // namespaces. 196 | optional string namespace = 5; 197 | } 198 | 199 | // MatchLabels defines a set of labels to match resources against. 200 | message MatchLabels { 201 | map labels = 1; 202 | } 203 | 204 | // ResponseMeta contains metadata pertaining to a RunFunctionResponse. 205 | message ResponseMeta { 206 | // An opaque string identifying the content of the request. Must match the 207 | // meta.tag of the corresponding RunFunctionRequest. 208 | string tag = 1; 209 | 210 | // Time-to-live of this response. Crossplane will call the function again when 211 | // the TTL expires. Crossplane may cache the response to avoid calling the 212 | // function again until the TTL expires. 213 | optional google.protobuf.Duration ttl = 2; 214 | } 215 | 216 | // State of the XR (XR) and any resources. 217 | message State { 218 | // The state of the XR (XR). 219 | Resource composite = 1; 220 | 221 | // The state of any other resources. In composition functions these are the 222 | // composed resources. In operation functions they're arbitrary resources that 223 | // the operation wants to create or update. 224 | map resources = 2; 225 | } 226 | 227 | // A Resource represents the state of a Kubernetes resource. 228 | message Resource { 229 | // The JSON representation of the resource. 230 | // 231 | // * Crossplane will set this field in a RunFunctionRequest to the entire 232 | // observed state of a resource - including its metadata, spec, and status. 233 | // 234 | // * A function should set this field in a RunFunctionRequest to communicate 235 | // the desired state of the resource. 236 | // 237 | // * A function may only specify the desired status of a XR - not its metadata 238 | // or spec. A function should not return desired metadata or spec for a XR. 239 | // This will be ignored. 240 | // 241 | // * A function may not specify the desired status of any other resource - 242 | // e.g. composed resources. It may only specify their metadata and spec. 243 | // Status will be ignored. 244 | google.protobuf.Struct resource = 1; 245 | 246 | // The resource's connection details. 247 | // 248 | // * Crossplane will set this field in a RunFunctionRequest to communicate the 249 | // the observed connection details of a composite or composed resource. 250 | // 251 | // * A function should set this field in a RunFunctionResponse to indicate the 252 | // desired connection details of the XR. 253 | // 254 | // * A function should not set this field in a RunFunctionResponse to indicate 255 | // the desired connection details of a composed resource. This will be 256 | // ignored. 257 | // 258 | // Connection details are only used for composition. They're ignored by 259 | // Operations. 260 | map connection_details = 2; 261 | 262 | // Ready indicates whether the resource should be considered ready. 263 | // 264 | // * Crossplane will never set this field in a RunFunctionRequest. 265 | // 266 | // * A function should set this field to READY_TRUE in a RunFunctionResponse 267 | // to indicate that a desired resource is ready. 268 | // 269 | // * A function should set this field to READY_TRUE in a RunFunctionResponse 270 | // to indicate that a desired XR is ready. This overwrites the standard 271 | // readiness detection that determines the ready state of the composite by the 272 | // ready state of the the composed resources. 273 | // 274 | // Ready is only used for composition. It's ignored by Operations. 275 | Ready ready = 3; 276 | } 277 | 278 | // Ready indicates whether a resource should be considered ready. 279 | enum Ready { 280 | READY_UNSPECIFIED = 0; 281 | 282 | // True means the resource has been observed to be ready. 283 | READY_TRUE = 1; 284 | 285 | // False means the resource has not been observed to be ready. 286 | READY_FALSE = 2; 287 | } 288 | 289 | // A Result of running a function. 290 | message Result { 291 | // Severity of this result. 292 | Severity severity = 1; 293 | 294 | // Human-readable details about the result. 295 | string message = 2; 296 | 297 | // Optional PascalCase, machine-readable reason for this result. If omitted, 298 | // the value will be ComposeResources. 299 | optional string reason = 3; 300 | 301 | // The resources this result targets. 302 | optional Target target = 4; 303 | } 304 | 305 | // Severity of function results. 306 | enum Severity { 307 | SEVERITY_UNSPECIFIED = 0; 308 | 309 | // Fatal results are fatal; subsequent functions may run, but the function 310 | // pipeline run will be considered a failure and the first fatal result will 311 | // be returned as an error. 312 | SEVERITY_FATAL = 1; 313 | 314 | // Warning results are non-fatal; the entire pipeline will run to completion 315 | // but warning events and debug logs associated with the XR or Operation will 316 | // be emitted. 317 | SEVERITY_WARNING = 2; 318 | 319 | // Normal results are emitted as normal events and debug logs associated with 320 | // the XR or operation. 321 | SEVERITY_NORMAL = 3; 322 | } 323 | 324 | // Target of function results and conditions. 325 | enum Target { 326 | // If the target is unspecified, the result targets the XR. 327 | TARGET_UNSPECIFIED = 0; 328 | 329 | // Target the XR. Results that target the XR should include detailed, advanced 330 | // information. 331 | TARGET_COMPOSITE = 1; 332 | 333 | // Target the XR and the claim. Results that target the XR and the claim 334 | // should include only end-user friendly information. 335 | TARGET_COMPOSITE_AND_CLAIM = 2; 336 | } 337 | 338 | // Status condition to be applied to the XR. Condition may also optionally be 339 | // applied to the XR's associated claim. For detailed information on proper 340 | // usage of status conditions, please see 341 | // https://github.com/kubernetes/community/blob/master/contributors/devel/sig-architecture/api-conventions.md#typical-status-properties. 342 | message Condition { 343 | // Type of condition in PascalCase. 344 | string type = 1; 345 | 346 | // Status of the condition. 347 | Status status = 2; 348 | 349 | // Reason contains a programmatic identifier indicating the reason for the 350 | // condition's last transition. Producers of specific condition types may 351 | // define expected values and meanings for this field, and whether the values 352 | // are considered a guaranteed API. The value should be a PascalCase string. 353 | // This field may not be empty. 354 | string reason = 3; 355 | 356 | // Message is a human readable message indicating details about the 357 | // transition. This may be an empty string. 358 | optional string message = 4; 359 | 360 | // The resources this condition targets. 361 | optional Target target = 5; 362 | } 363 | 364 | enum Status { 365 | STATUS_CONDITION_UNSPECIFIED = 0; 366 | 367 | STATUS_CONDITION_UNKNOWN = 1; 368 | 369 | STATUS_CONDITION_TRUE = 2; 370 | 371 | STATUS_CONDITION_FALSE = 3; 372 | } 373 | -------------------------------------------------------------------------------- /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 | // Generated from proto/fn/v1/run_function.proto by ./hack/duplicate_proto_type.sh. DO NOT EDIT. 17 | 18 | // Code generated by protoc-gen-go-grpc. DO NOT EDIT. 19 | // versions: 20 | // - protoc-gen-go-grpc v1.5.1 21 | // - protoc (unknown) 22 | // source: v1beta1/run_function.proto 23 | 24 | // This package defines the RPC for both composition and operation functions. 25 | // Only composition functions are part of the 'apiextensions' API group. In 26 | // retrospect this package should've been crossplane.proto.fn.v1, but it's too 27 | // late to change it now. 28 | 29 | //buf:lint:ignore PACKAGE_DIRECTORY_MATCH 30 | 31 | package v1beta1 32 | 33 | import ( 34 | context "context" 35 | grpc "google.golang.org/grpc" 36 | codes "google.golang.org/grpc/codes" 37 | status "google.golang.org/grpc/status" 38 | ) 39 | 40 | // This is a compile-time assertion to ensure that this generated file 41 | // is compatible with the grpc package it is being compiled against. 42 | // Requires gRPC-Go v1.64.0 or later. 43 | const _ = grpc.SupportPackageIsVersion9 44 | 45 | const ( 46 | FunctionRunnerService_RunFunction_FullMethodName = "/apiextensions.fn.proto.v1beta1.FunctionRunnerService/RunFunction" 47 | ) 48 | 49 | // FunctionRunnerServiceClient is the client API for FunctionRunnerService service. 50 | // 51 | // 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. 52 | // 53 | // A FunctionRunnerService is a function. 54 | type FunctionRunnerServiceClient interface { 55 | // RunFunction runs the function. 56 | RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) 57 | } 58 | 59 | type functionRunnerServiceClient struct { 60 | cc grpc.ClientConnInterface 61 | } 62 | 63 | func NewFunctionRunnerServiceClient(cc grpc.ClientConnInterface) FunctionRunnerServiceClient { 64 | return &functionRunnerServiceClient{cc} 65 | } 66 | 67 | func (c *functionRunnerServiceClient) RunFunction(ctx context.Context, in *RunFunctionRequest, opts ...grpc.CallOption) (*RunFunctionResponse, error) { 68 | cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) 69 | out := new(RunFunctionResponse) 70 | err := c.cc.Invoke(ctx, FunctionRunnerService_RunFunction_FullMethodName, in, out, cOpts...) 71 | if err != nil { 72 | return nil, err 73 | } 74 | return out, nil 75 | } 76 | 77 | // FunctionRunnerServiceServer is the server API for FunctionRunnerService service. 78 | // All implementations must embed UnimplementedFunctionRunnerServiceServer 79 | // for forward compatibility. 80 | // 81 | // A FunctionRunnerService is a function. 82 | type FunctionRunnerServiceServer interface { 83 | // RunFunction runs the function. 84 | RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) 85 | mustEmbedUnimplementedFunctionRunnerServiceServer() 86 | } 87 | 88 | // UnimplementedFunctionRunnerServiceServer must be embedded to have 89 | // forward compatible implementations. 90 | // 91 | // NOTE: this should be embedded by value instead of pointer to avoid a nil 92 | // pointer dereference when methods are called. 93 | type UnimplementedFunctionRunnerServiceServer struct{} 94 | 95 | func (UnimplementedFunctionRunnerServiceServer) RunFunction(context.Context, *RunFunctionRequest) (*RunFunctionResponse, error) { 96 | return nil, status.Errorf(codes.Unimplemented, "method RunFunction not implemented") 97 | } 98 | func (UnimplementedFunctionRunnerServiceServer) mustEmbedUnimplementedFunctionRunnerServiceServer() {} 99 | func (UnimplementedFunctionRunnerServiceServer) testEmbeddedByValue() {} 100 | 101 | // UnsafeFunctionRunnerServiceServer may be embedded to opt out of forward compatibility for this service. 102 | // Use of this interface is not recommended, as added methods to FunctionRunnerServiceServer will 103 | // result in compilation errors. 104 | type UnsafeFunctionRunnerServiceServer interface { 105 | mustEmbedUnimplementedFunctionRunnerServiceServer() 106 | } 107 | 108 | func RegisterFunctionRunnerServiceServer(s grpc.ServiceRegistrar, srv FunctionRunnerServiceServer) { 109 | // If the following call pancis, it indicates UnimplementedFunctionRunnerServiceServer was 110 | // embedded by pointer and is nil. This will cause panics if an 111 | // unimplemented method is ever invoked, so we test this at initialization 112 | // time to prevent it from happening at runtime later due to I/O. 113 | if t, ok := srv.(interface{ testEmbeddedByValue() }); ok { 114 | t.testEmbeddedByValue() 115 | } 116 | s.RegisterService(&FunctionRunnerService_ServiceDesc, srv) 117 | } 118 | 119 | func _FunctionRunnerService_RunFunction_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { 120 | in := new(RunFunctionRequest) 121 | if err := dec(in); err != nil { 122 | return nil, err 123 | } 124 | if interceptor == nil { 125 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, in) 126 | } 127 | info := &grpc.UnaryServerInfo{ 128 | Server: srv, 129 | FullMethod: FunctionRunnerService_RunFunction_FullMethodName, 130 | } 131 | handler := func(ctx context.Context, req interface{}) (interface{}, error) { 132 | return srv.(FunctionRunnerServiceServer).RunFunction(ctx, req.(*RunFunctionRequest)) 133 | } 134 | return interceptor(ctx, in, info, handler) 135 | } 136 | 137 | // FunctionRunnerService_ServiceDesc is the grpc.ServiceDesc for FunctionRunnerService service. 138 | // It's only intended for direct use with grpc.RegisterService, 139 | // and not to be introspected or modified (even as a copy) 140 | var FunctionRunnerService_ServiceDesc = grpc.ServiceDesc{ 141 | ServiceName: "apiextensions.fn.proto.v1beta1.FunctionRunnerService", 142 | HandlerType: (*FunctionRunnerServiceServer)(nil), 143 | Methods: []grpc.MethodDesc{ 144 | { 145 | MethodName: "RunFunction", 146 | Handler: _FunctionRunnerService_RunFunction_Handler, 147 | }, 148 | }, 149 | Streams: []grpc.StreamDesc{}, 150 | Metadata: "v1beta1/run_function.proto", 151 | } 152 | -------------------------------------------------------------------------------- /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 | // GetRequiredResources from the supplied request using the new required_resources field. 117 | func GetRequiredResources(req *v1.RunFunctionRequest) (map[string][]resource.Required, error) { 118 | out := make(map[string][]resource.Required, len(req.GetRequiredResources())) 119 | for name, ers := range req.GetRequiredResources() { 120 | out[name] = []resource.Required{} 121 | for _, i := range ers.GetItems() { 122 | r := &resource.Required{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 | // GetExtraResources from the supplied request using the deprecated extra_resources field. 133 | // 134 | // Deprecated: Use GetRequiredResources. 135 | func GetExtraResources(req *v1.RunFunctionRequest) (map[string][]resource.Required, error) { 136 | out := make(map[string][]resource.Required, len(req.GetExtraResources())) 137 | for name, ers := range req.GetExtraResources() { 138 | out[name] = []resource.Required{} 139 | for _, i := range ers.GetItems() { 140 | r := &resource.Required{Resource: &unstructured.Unstructured{}} 141 | if err := resource.AsObject(i.GetResource(), r.Resource); err != nil { 142 | return nil, err 143 | } 144 | out[name] = append(out[name], *r) 145 | } 146 | } 147 | return out, nil 148 | } 149 | 150 | // GetCredentials from the supplied request. 151 | func GetCredentials(req *v1.RunFunctionRequest, name string) (resource.Credentials, error) { 152 | cred, exists := req.GetCredentials()[name] 153 | if !exists { 154 | return resource.Credentials{}, errors.Errorf("%s: credential not found", name) 155 | } 156 | 157 | switch t := cred.GetSource().(type) { 158 | case *v1.Credentials_CredentialData: 159 | return resource.Credentials{Type: resource.CredentialsTypeData, Data: cred.GetCredentialData().GetData()}, nil 160 | default: 161 | return resource.Credentials{}, errors.Errorf("%s: not a supported credential source", t) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /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 | 294 | func TestGetRequiredResources(t *testing.T) { 295 | type want struct { 296 | resources map[string][]resource.Required 297 | err error 298 | } 299 | 300 | cases := map[string]struct { 301 | reason string 302 | req *v1.RunFunctionRequest 303 | want want 304 | }{ 305 | "NoRequiredResources": { 306 | reason: "If the request has no required resources we should return an empty, non-nil map.", 307 | req: &v1.RunFunctionRequest{}, 308 | want: want{ 309 | resources: map[string][]resource.Required{}, 310 | }, 311 | }, 312 | "RequiredResources": { 313 | reason: "If the request has required resources in the new field we should return them.", 314 | req: &v1.RunFunctionRequest{ 315 | RequiredResources: map[string]*v1.Resources{ 316 | "test-resources": { 317 | Items: []*v1.Resource{ 318 | { 319 | Resource: resource.MustStructJSON(`{ 320 | "apiVersion": "test.crossplane.io/v1", 321 | "kind": "TestResource", 322 | "metadata": {"name": "test"} 323 | }`), 324 | }, 325 | }, 326 | }, 327 | }, 328 | }, 329 | want: want{ 330 | resources: map[string][]resource.Required{ 331 | "test-resources": { 332 | { 333 | Resource: &unstructured.Unstructured{ 334 | Object: map[string]any{ 335 | "apiVersion": "test.crossplane.io/v1", 336 | "kind": "TestResource", 337 | "metadata": map[string]any{"name": "test"}, 338 | }, 339 | }, 340 | }, 341 | }, 342 | }, 343 | }, 344 | }, 345 | } 346 | 347 | for name, tc := range cases { 348 | t.Run(name, func(t *testing.T) { 349 | resources, err := GetRequiredResources(tc.req) 350 | 351 | if diff := cmp.Diff(tc.want.resources, resources); diff != "" { 352 | t.Errorf("\n%s\nGetRequiredResources(...): -want, +got:\n%s", tc.reason, diff) 353 | } 354 | if diff := cmp.Diff(tc.want.err, err); diff != "" { 355 | t.Errorf("\n%s\nGetRequiredResources(...): -want error, +got error:\n%s", tc.reason, diff) 356 | } 357 | }) 358 | } 359 | } 360 | 361 | func TestGetExtraResources(t *testing.T) { 362 | type want struct { 363 | resources map[string][]resource.Required 364 | err error 365 | } 366 | 367 | cases := map[string]struct { 368 | reason string 369 | req *v1.RunFunctionRequest 370 | want want 371 | }{ 372 | "NoExtraResources": { 373 | reason: "If the request has no extra resources we should return an empty, non-nil map.", 374 | req: &v1.RunFunctionRequest{}, 375 | want: want{ 376 | resources: map[string][]resource.Required{}, 377 | }, 378 | }, 379 | "ExtraResources": { 380 | reason: "If the request has extra resources in the deprecated field we should return them.", 381 | req: &v1.RunFunctionRequest{ 382 | ExtraResources: map[string]*v1.Resources{ 383 | "test-resources": { 384 | Items: []*v1.Resource{ 385 | { 386 | Resource: resource.MustStructJSON(`{ 387 | "apiVersion": "test.crossplane.io/v1", 388 | "kind": "TestResource", 389 | "metadata": {"name": "test"} 390 | }`), 391 | }, 392 | }, 393 | }, 394 | }, 395 | }, 396 | want: want{ 397 | resources: map[string][]resource.Required{ 398 | "test-resources": { 399 | { 400 | Resource: &unstructured.Unstructured{ 401 | Object: map[string]any{ 402 | "apiVersion": "test.crossplane.io/v1", 403 | "kind": "TestResource", 404 | "metadata": map[string]any{"name": "test"}, 405 | }, 406 | }, 407 | }, 408 | }, 409 | }, 410 | }, 411 | }, 412 | } 413 | 414 | for name, tc := range cases { 415 | t.Run(name, func(t *testing.T) { 416 | resources, err := GetExtraResources(tc.req) 417 | 418 | if diff := cmp.Diff(tc.want.resources, resources); diff != "" { 419 | t.Errorf("\n%s\nGetExtraResources(...): -want, +got:\n%s", tc.reason, diff) 420 | } 421 | if diff := cmp.Diff(tc.want.err, err); diff != "" { 422 | t.Errorf("\n%s\nGetExtraResources(...): -want error, +got error:\n%s", tc.reason, diff) 423 | } 424 | }) 425 | } 426 | } 427 | -------------------------------------------------------------------------------- /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 | // NOTE(negz): I don't love the package-scoped state here but this seems like 34 | // the nicest API to offer callers. 35 | 36 | // Scheme used to determine the type of any runtime.Object passed to From. 37 | var Scheme *runtime.Scheme //nolint:gochecknoglobals // See comment above. 38 | 39 | func init() { //nolint:gochecknoinits // See comment above. 40 | Scheme = runtime.NewScheme() 41 | } 42 | 43 | // New returns a new unstructured composed resource. 44 | func New() *Unstructured { 45 | return &Unstructured{unstructured.Unstructured{Object: make(map[string]any)}} 46 | } 47 | 48 | // From creates a new unstructured composed resource from the supplied object. 49 | func From(o runtime.Object) (*Unstructured, error) { 50 | // If the supplied object is already unstructured content, avoid a JSON 51 | // round trip and use it. 52 | if u, ok := o.(interface{ UnstructuredContent() map[string]any }); ok { 53 | return &Unstructured{unstructured.Unstructured{Object: u.UnstructuredContent()}}, nil 54 | } 55 | 56 | // Set the object's GVK from our scheme. 57 | gvks, _, err := Scheme.ObjectKinds(o) 58 | if err != nil { 59 | return nil, errors.Wrap(err, "did you add it to composed.Scheme?") 60 | } 61 | // There should almost never be more than one GVK for a type. 62 | for _, gvk := range gvks { 63 | o.GetObjectKind().SetGroupVersionKind(gvk) 64 | } 65 | 66 | // Round-trip the supplied object through JSON to convert it. We use the 67 | // go-json-experiment package for this because it honors the omitempty field 68 | // for non-pointer struct fields. 69 | // 70 | // At the time of writing many Crossplane structs contain fields that have 71 | // the omitempty struct tag, but non-pointer struct values. pkg/json does 72 | // not omit these fields. It instead includes them as empty JSON objects. 73 | // Crossplane will interpret this as part of a server-side apply fully 74 | // specified intent and assume the function actually has opinion about the 75 | // field when it doesn't. We should make these fields pointers, but it's 76 | // easier and safer in the meantime to work around it here. 77 | // 78 | // https://github.com/go-json-experiment/json#behavior-changes 79 | j, err := json.Marshal(o) 80 | if err != nil { 81 | return nil, err 82 | } 83 | obj := make(map[string]any) 84 | if err := json.Unmarshal(j, &obj); err != nil { 85 | return nil, err 86 | } 87 | 88 | // Unfortunately we still need to cleanup some object metadata. 89 | cleanupMetadata(obj) 90 | 91 | return &Unstructured{unstructured.Unstructured{Object: obj}}, nil 92 | } 93 | 94 | func cleanupMetadata(obj map[string]any) { 95 | m, ok := obj["metadata"] 96 | if !ok { 97 | // If there's no metadata there's nothing to do. 98 | return 99 | } 100 | 101 | mo, ok := m.(map[string]any) 102 | if !ok { 103 | // If metadata isn't an object there's nothing to do. 104 | return 105 | } 106 | 107 | // The ObjectMeta struct that all Kubernetes types include has a non-nil 108 | // integer Generation field with the omitempty tag. Regular pkg/json removes 109 | // this, but go-json-experiment does not (it would need the new omitzero 110 | // tag). So, we clean it up manually. No function should ever be setting it. 111 | delete(mo, "generation") 112 | 113 | // If metadata has no fields, delete it. This prevents us from serializing 114 | // metadata: {}, which SSA would interpret as "make metadata empty". 115 | if len(mo) == 0 { 116 | delete(obj, "metadata") 117 | } 118 | } 119 | 120 | // An Unstructured composed resource. 121 | type Unstructured struct { 122 | unstructured.Unstructured 123 | } 124 | 125 | var ( 126 | _ runtime.Object = &Unstructured{} 127 | _ metav1.Object = &Unstructured{} 128 | _ runtime.Unstructured = &Unstructured{} 129 | _ resource.Composed = &Unstructured{} 130 | ) 131 | 132 | // DeepCopy this composed resource. 133 | func (cd *Unstructured) DeepCopy() *Unstructured { 134 | if cd == nil { 135 | return nil 136 | } 137 | out := new(Unstructured) 138 | *out = *cd 139 | out.Object = runtime.DeepCopyJSON(cd.Object) 140 | return out 141 | } 142 | 143 | // DeepCopyObject of this composed resource. 144 | func (cd *Unstructured) DeepCopyObject() runtime.Object { 145 | return cd.DeepCopy() 146 | } 147 | 148 | // DeepCopyInto the supplied composed resource. 149 | func (cd *Unstructured) DeepCopyInto(out *Unstructured) { 150 | clone := cd.DeepCopy() 151 | *out = *clone 152 | } 153 | 154 | // MarshalJSON for this composed resource. 155 | func (cd *Unstructured) MarshalJSON() ([]byte, error) { 156 | return cd.Unstructured.MarshalJSON() 157 | } 158 | 159 | // GetCondition of this Composed resource. 160 | func (cd *Unstructured) GetCondition(ct xpv1.ConditionType) xpv1.Condition { 161 | conditioned := xpv1.ConditionedStatus{} 162 | // The path is directly `status` because conditions are inline. 163 | if err := fieldpath.Pave(cd.Object).GetValueInto("status", &conditioned); err != nil { 164 | return xpv1.Condition{} 165 | } 166 | return conditioned.GetCondition(ct) 167 | } 168 | 169 | // SetConditions of this Composed resource. 170 | func (cd *Unstructured) SetConditions(c ...xpv1.Condition) { 171 | conditioned := xpv1.ConditionedStatus{} 172 | // The path is directly `status` because conditions are inline. 173 | _ = fieldpath.Pave(cd.Object).GetValueInto("status", &conditioned) 174 | conditioned.SetConditions(c...) 175 | _ = fieldpath.Pave(cd.Object).SetValue("status.conditions", conditioned.Conditions) 176 | } 177 | 178 | // GetWriteConnectionSecretToReference of this Composed resource. 179 | func (cd *Unstructured) GetWriteConnectionSecretToReference() *xpv1.SecretReference { 180 | out := &xpv1.SecretReference{} 181 | if err := fieldpath.Pave(cd.Object).GetValueInto("spec.writeConnectionSecretToRef", out); err != nil { 182 | return nil 183 | } 184 | return out 185 | } 186 | 187 | // SetWriteConnectionSecretToReference of this Composed resource. 188 | func (cd *Unstructured) SetWriteConnectionSecretToReference(r *xpv1.SecretReference) { 189 | _ = fieldpath.Pave(cd.Object).SetValue("spec.writeConnectionSecretToRef", r) 190 | } 191 | 192 | // GetPublishConnectionDetailsTo of this Composed resource. 193 | func (cd *Unstructured) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo { 194 | out := &xpv1.PublishConnectionDetailsTo{} 195 | if err := fieldpath.Pave(cd.Object).GetValueInto("spec.publishConnectionDetailsTo", out); err != nil { 196 | return nil 197 | } 198 | return out 199 | } 200 | 201 | // SetPublishConnectionDetailsTo of this Composed resource. 202 | func (cd *Unstructured) SetPublishConnectionDetailsTo(ref *xpv1.PublishConnectionDetailsTo) { 203 | _ = fieldpath.Pave(cd.Object).SetValue("spec.publishConnectionDetailsTo", ref) 204 | } 205 | 206 | // GetValue of the supplied field path. 207 | func (cd *Unstructured) GetValue(path string) (any, error) { 208 | return fieldpath.Pave(cd.Object).GetValue(path) 209 | } 210 | 211 | // GetValueInto the supplied type. 212 | func (cd *Unstructured) GetValueInto(path string, out any) error { 213 | return fieldpath.Pave(cd.Object).GetValueInto(path, out) 214 | } 215 | 216 | // GetString value of the supplied field path. 217 | func (cd *Unstructured) GetString(path string) (string, error) { 218 | return fieldpath.Pave(cd.Object).GetString(path) 219 | } 220 | 221 | // GetStringArray value of the supplied field path. 222 | func (cd *Unstructured) GetStringArray(path string) ([]string, error) { 223 | return fieldpath.Pave(cd.Object).GetStringArray(path) 224 | } 225 | 226 | // GetStringObject value of the supplied field path. 227 | func (cd *Unstructured) GetStringObject(path string) (map[string]string, error) { 228 | return fieldpath.Pave(cd.Object).GetStringObject(path) 229 | } 230 | 231 | // GetBool value of the supplied field path. 232 | func (cd *Unstructured) GetBool(path string) (bool, error) { 233 | return fieldpath.Pave(cd.Object).GetBool(path) 234 | } 235 | 236 | // GetInteger value of the supplied field path. 237 | func (cd *Unstructured) GetInteger(path string) (int64, error) { 238 | // This is a bit of a hack. Kubernetes JSON decoders will get us a 239 | // map[string]any where number values are int64, but protojson and structpb 240 | // will get us one where number values are float64. 241 | // https://pkg.go.dev/sigs.k8s.io/json#UnmarshalCaseSensitivePreserveInts 242 | p := fieldpath.Pave(cd.Object) 243 | 244 | // If we find an int64, return it. 245 | i64, err := p.GetInteger(path) 246 | if err == nil { 247 | return i64, nil 248 | } 249 | 250 | // If not, try return (and truncate) a float64. 251 | if f64, err := getNumber(p, path); err == nil { 252 | return int64(f64), nil 253 | } 254 | 255 | // If both fail, return our original error. 256 | return 0, err 257 | } 258 | 259 | func getNumber(p *fieldpath.Paved, path string) (float64, error) { 260 | v, err := p.GetValue(path) 261 | if err != nil { 262 | return 0, err 263 | } 264 | 265 | f, ok := v.(float64) 266 | if !ok { 267 | return 0, errors.Errorf("%s: not a (float64) number", path) 268 | } 269 | return f, nil 270 | } 271 | 272 | // SetValue at the supplied field path. 273 | func (cd *Unstructured) SetValue(path string, value any) error { 274 | return fieldpath.Pave(cd.Object).SetValue(path, value) 275 | } 276 | 277 | // SetString value at the supplied field path. 278 | func (cd *Unstructured) SetString(path, value string) error { 279 | return cd.SetValue(path, value) 280 | } 281 | 282 | // SetBool value at the supplied field path. 283 | func (cd *Unstructured) SetBool(path string, value bool) error { 284 | return cd.SetValue(path, value) 285 | } 286 | 287 | // SetInteger value at the supplied field path. 288 | func (cd *Unstructured) SetInteger(path string, value int64) error { 289 | return cd.SetValue(path, value) 290 | } 291 | 292 | // SetObservedGeneration of this Composed resource. 293 | func (cd *Unstructured) SetObservedGeneration(generation int64) { 294 | status := &xpv1.ObservedStatus{} 295 | _ = fieldpath.Pave(cd.Object).GetValueInto("status", status) 296 | status.SetObservedGeneration(generation) 297 | _ = fieldpath.Pave(cd.Object).SetValue("status.observedGeneration", status.ObservedGeneration) 298 | } 299 | 300 | // GetObservedGeneration of this Composed resource. 301 | func (cd *Unstructured) GetObservedGeneration() int64 { 302 | status := &xpv1.ObservedStatus{} 303 | _ = fieldpath.Pave(cd.Object).GetValueInto("status", status) 304 | return status.GetObservedGeneration() 305 | } 306 | -------------------------------------------------------------------------------- /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 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 26 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 27 | "k8s.io/apimachinery/pkg/runtime" 28 | "k8s.io/utils/ptr" 29 | "sigs.k8s.io/yaml" 30 | ) 31 | 32 | func Example() { 33 | // Create a new, empty composed resource. 34 | cd := New() 35 | 36 | // Set our composed resource's type metadata. 37 | cd.SetAPIVersion("example.org/v1") 38 | cd.SetKind("CoolComposedResource") 39 | 40 | // Set our composed resource's object metadata. 41 | cd.SetLabels(map[string]string{"coolness": "high"}) 42 | 43 | // Set an arbitrary spec field. 44 | cd.SetInteger("spec.coolness", 9001) 45 | 46 | // Marshal our composed resource to YAML. We just do this for illustration 47 | // purposes. Normally you'd add it to the map of desired resources you send 48 | // in your RunFunctionResponse. 49 | y, _ := yaml.Marshal(cd) 50 | 51 | fmt.Println(string(y)) 52 | 53 | // Output: 54 | // apiVersion: example.org/v1 55 | // kind: CoolComposedResource 56 | // metadata: 57 | // labels: 58 | // coolness: high 59 | // spec: 60 | // coolness: 9001 61 | } 62 | 63 | func ExampleScheme() { 64 | // Add test resource types to the scheme so that From can automatically 65 | // determine their apiVersion and kind. 66 | AddTestResourceToScheme(Scheme) 67 | 68 | // Output: 69 | } 70 | 71 | func ExampleFrom() { 72 | // Add test resource types to the scheme so that From can automatically 73 | // determine their apiVersion and kind. 74 | AddTestResourceToScheme(Scheme) 75 | 76 | // Create a strongly typed runtime.Object using our test resource. 77 | tr := &TestResource{ 78 | TypeMeta: metav1.TypeMeta{ 79 | APIVersion: TestResourceAPIVersion, 80 | Kind: TestResourceKind, 81 | }, 82 | ObjectMeta: metav1.ObjectMeta{ 83 | Labels: map[string]string{ 84 | "coolness": "high", 85 | }, 86 | }, 87 | Spec: TestResourceSpec{ 88 | ForProvider: TestResourceParameters{ 89 | Region: ptr.To[string]("us-east-2"), 90 | }, 91 | }, 92 | } 93 | 94 | // Create a composed resource from the runtime.Object. 95 | cd, err := From(tr) 96 | if err != nil { 97 | panic(err) 98 | } 99 | 100 | // Marshal our composed resource to YAML. We just do this for illustration 101 | // purposes. Normally you'd add it to the map of desired resources you send 102 | // in your RunFunctionResponse. 103 | y, _ := yaml.Marshal(cd) 104 | 105 | fmt.Println(string(y)) 106 | 107 | // Output: 108 | // apiVersion: example.org/v1alpha1 109 | // kind: TestResource 110 | // metadata: 111 | // labels: 112 | // coolness: high 113 | // spec: 114 | // forProvider: 115 | // region: us-east-2 116 | // status: 117 | // observedGeneration: 0 118 | } 119 | 120 | func TestFrom(t *testing.T) { 121 | AddTestResourceToScheme(Scheme) 122 | 123 | type args struct { 124 | o runtime.Object 125 | } 126 | type want struct { 127 | cd *Unstructured 128 | err error 129 | } 130 | cases := map[string]struct { 131 | reason string 132 | args args 133 | want want 134 | }{ 135 | "WithMetadata": { 136 | reason: "A resource with metadata should not grow any extra metadata fields during conversion", 137 | args: args{ 138 | o: &TestResource{ 139 | TypeMeta: metav1.TypeMeta{ 140 | APIVersion: TestResourceAPIVersion, 141 | Kind: TestResourceKind, 142 | }, 143 | ObjectMeta: metav1.ObjectMeta{ 144 | Name: "cool-resource", 145 | }, 146 | Spec: TestResourceSpec{ 147 | ForProvider: TestResourceParameters{ 148 | Region: ptr.To[string]("us-east-2"), 149 | }, 150 | }, 151 | }, 152 | }, 153 | want: want{ 154 | cd: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ 155 | "apiVersion": TestResourceGroupVersion.String(), 156 | "kind": TestResourceKind, 157 | "metadata": map[string]any{ 158 | "name": "cool-resource", 159 | }, 160 | "spec": map[string]any{ 161 | "forProvider": map[string]any{ 162 | "region": "us-east-2", 163 | }, 164 | }, 165 | "status": map[string]any{ 166 | "observedGeneration": float64(0), 167 | }, 168 | }}}, 169 | }, 170 | }, 171 | "WithoutMetadata": { 172 | reason: "A resource with no metadata should not grow a metadata object during conversion", 173 | args: args{ 174 | o: &TestResource{ 175 | TypeMeta: metav1.TypeMeta{ 176 | APIVersion: TestResourceAPIVersion, 177 | Kind: TestResourceKind, 178 | }, 179 | Spec: TestResourceSpec{ 180 | ForProvider: TestResourceParameters{ 181 | Region: ptr.To[string]("us-east-2"), 182 | }, 183 | }, 184 | }, 185 | }, 186 | want: want{ 187 | cd: &Unstructured{Unstructured: unstructured.Unstructured{Object: map[string]any{ 188 | "apiVersion": TestResourceGroupVersion.String(), 189 | "kind": TestResourceKind, 190 | "spec": map[string]any{ 191 | "forProvider": map[string]any{ 192 | "region": "us-east-2", 193 | }, 194 | }, 195 | "status": map[string]any{ 196 | "observedGeneration": float64(0), 197 | }, 198 | }}}, 199 | }, 200 | }, 201 | } 202 | 203 | for name, tc := range cases { 204 | t.Run(name, func(t *testing.T) { 205 | cd, err := From(tc.args.o) 206 | 207 | if diff := cmp.Diff(tc.want.cd, cd); diff != "" { 208 | t.Errorf("\n%s\nFrom(...): -want, +got:\n%s", tc.reason, diff) 209 | } 210 | 211 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 212 | t.Errorf("\n%s\nFrom(...): -want error, +got error:\n%s", tc.reason, diff) 213 | } 214 | }) 215 | } 216 | } 217 | -------------------------------------------------------------------------------- /resource/composed/testresource_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 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | "k8s.io/apimachinery/pkg/runtime" 22 | "k8s.io/apimachinery/pkg/runtime/schema" 23 | "k8s.io/utils/ptr" 24 | 25 | v1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 26 | ) 27 | 28 | // TestResource is a generic test resource that mimics a Crossplane managed resource 29 | // without depending on any specific provider. 30 | type TestResource struct { 31 | metav1.TypeMeta `json:",inline"` 32 | metav1.ObjectMeta `json:"metadata,omitempty"` 33 | 34 | Spec TestResourceSpec `json:"spec"` 35 | Status TestResourceStatus `json:"status,omitempty"` 36 | } 37 | 38 | // TestResourceSpec defines the desired state of TestResource. 39 | type TestResourceSpec struct { 40 | v1.ResourceSpec `json:",inline"` 41 | 42 | ForProvider TestResourceParameters `json:"forProvider"` 43 | } 44 | 45 | // TestResourceParameters are the configurable fields of a TestResource. 46 | type TestResourceParameters struct { 47 | Region *string `json:"region,omitempty"` 48 | Name *string `json:"name,omitempty"` 49 | } 50 | 51 | // TestResourceStatus represents the observed state of a TestResource. 52 | type TestResourceStatus struct { 53 | v1.ResourceStatus `json:",inline"` 54 | } 55 | 56 | // Implement runtime.Object interface. 57 | func (tr *TestResource) DeepCopyObject() runtime.Object { 58 | return tr.DeepCopy() 59 | } 60 | 61 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TestResource. 62 | func (tr *TestResource) DeepCopy() *TestResource { 63 | if tr == nil { 64 | return nil 65 | } 66 | out := new(TestResource) 67 | tr.DeepCopyInto(out) 68 | return out 69 | } 70 | 71 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 72 | func (tr *TestResource) DeepCopyInto(out *TestResource) { 73 | *out = *tr 74 | out.TypeMeta = tr.TypeMeta 75 | tr.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 76 | tr.Spec.DeepCopyInto(&out.Spec) 77 | out.Status = tr.Status 78 | } 79 | 80 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 81 | func (trs *TestResourceSpec) DeepCopyInto(out *TestResourceSpec) { 82 | *out = *trs 83 | trs.ForProvider.DeepCopyInto(&out.ForProvider) 84 | } 85 | 86 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 87 | func (trp *TestResourceParameters) DeepCopyInto(out *TestResourceParameters) { 88 | *out = *trp 89 | if trp.Region != nil { 90 | out.Region = ptr.To(*trp.Region) 91 | } 92 | if trp.Name != nil { 93 | out.Name = ptr.To(*trp.Name) 94 | } 95 | } 96 | 97 | // Test resource constants. 98 | const ( 99 | TestResourceKind = "TestResource" 100 | TestResourceAPIVersion = "example.org/v1alpha1" 101 | ) 102 | 103 | var ( 104 | TestResourceGroupVersion = schema.GroupVersion{Group: "example.org", Version: "v1alpha1"} 105 | TestResourceGroupKind = schema.GroupKind{Group: "example.org", Kind: TestResourceKind} 106 | ) 107 | 108 | // AddTestResourceToScheme adds the test resource to the given scheme. 109 | func AddTestResourceToScheme(scheme *runtime.Scheme) { 110 | scheme.AddKnownTypes(TestResourceGroupVersion, &TestResource{}) 111 | metav1.AddToGroupVersion(scheme, TestResourceGroupVersion) 112 | } 113 | -------------------------------------------------------------------------------- /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 ( 47 | _ runtime.Object = &Unstructured{} 48 | _ metav1.Object = &Unstructured{} 49 | _ runtime.Unstructured = &Unstructured{} 50 | _ resource.Composite = &Unstructured{} 51 | ) 52 | 53 | // DeepCopy this composite resource. 54 | func (xr *Unstructured) DeepCopy() *Unstructured { 55 | if xr == nil { 56 | return nil 57 | } 58 | out := new(Unstructured) 59 | *out = *xr 60 | out.Object = runtime.DeepCopyJSON(xr.Object) 61 | return out 62 | } 63 | 64 | // DeepCopyObject of this composite resource. 65 | func (xr *Unstructured) DeepCopyObject() runtime.Object { 66 | return xr.DeepCopy() 67 | } 68 | 69 | // DeepCopyInto the supplied composite resource. 70 | func (xr *Unstructured) DeepCopyInto(out *Unstructured) { 71 | clone := xr.DeepCopy() 72 | *out = *clone 73 | } 74 | 75 | // MarshalJSON for this composite resource. 76 | func (xr *Unstructured) MarshalJSON() ([]byte, error) { 77 | return xr.Unstructured.MarshalJSON() 78 | } 79 | 80 | // GetCompositionSelector of this composite resource. 81 | func (xr *Unstructured) GetCompositionSelector() *metav1.LabelSelector { 82 | out := &metav1.LabelSelector{} 83 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionSelector", out); err != nil { 84 | return nil 85 | } 86 | return out 87 | } 88 | 89 | // SetCompositionSelector of this composite resource. 90 | func (xr *Unstructured) SetCompositionSelector(sel *metav1.LabelSelector) { 91 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionSelector", sel) 92 | } 93 | 94 | // GetCompositionReference of this composite resource. 95 | func (xr *Unstructured) GetCompositionReference() *corev1.ObjectReference { 96 | out := &corev1.ObjectReference{} 97 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionRef", out); err != nil { 98 | return nil 99 | } 100 | return out 101 | } 102 | 103 | // SetCompositionReference of this composite resource. 104 | func (xr *Unstructured) SetCompositionReference(ref *corev1.ObjectReference) { 105 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionRef", ref) 106 | } 107 | 108 | // GetCompositionRevisionReference of this composite resource. 109 | func (xr *Unstructured) GetCompositionRevisionReference() *corev1.LocalObjectReference { 110 | out := &corev1.LocalObjectReference{} 111 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionRevisionRef", out); err != nil { 112 | return nil 113 | } 114 | return out 115 | } 116 | 117 | // SetCompositionRevisionReference of this composite resource. 118 | func (xr *Unstructured) SetCompositionRevisionReference(ref *corev1.LocalObjectReference) { 119 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionRevisionRef", ref) 120 | } 121 | 122 | // GetCompositionRevisionSelector of this composite resource. 123 | func (xr *Unstructured) GetCompositionRevisionSelector() *metav1.LabelSelector { 124 | out := &metav1.LabelSelector{} 125 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.compositionRevisionSelector", out); err != nil { 126 | return nil 127 | } 128 | return out 129 | } 130 | 131 | // SetCompositionRevisionSelector of this composite resource. 132 | func (xr *Unstructured) SetCompositionRevisionSelector(sel *metav1.LabelSelector) { 133 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionRevisionSelector", sel) 134 | } 135 | 136 | // SetCompositionUpdatePolicy of this composite resource. 137 | func (xr *Unstructured) SetCompositionUpdatePolicy(p *xpv1.UpdatePolicy) { 138 | _ = fieldpath.Pave(xr.Object).SetValue("spec.compositionUpdatePolicy", p) 139 | } 140 | 141 | // GetCompositionUpdatePolicy of this composite resource. 142 | func (xr *Unstructured) GetCompositionUpdatePolicy() *xpv1.UpdatePolicy { 143 | p, err := fieldpath.Pave(xr.Object).GetString("spec.compositionUpdatePolicy") 144 | if err != nil { 145 | return nil 146 | } 147 | out := xpv1.UpdatePolicy(p) 148 | return &out 149 | } 150 | 151 | // GetClaimReference of this composite resource. 152 | func (xr *Unstructured) GetClaimReference() *reference.Claim { 153 | out := &reference.Claim{} 154 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.claimRef", out); err != nil { 155 | return nil 156 | } 157 | return out 158 | } 159 | 160 | // SetClaimReference of this composite resource. 161 | func (xr *Unstructured) SetClaimReference(ref *reference.Claim) { 162 | _ = fieldpath.Pave(xr.Object).SetValue("spec.claimRef", ref) 163 | } 164 | 165 | // GetResourceReferences of this composite resource. 166 | func (xr *Unstructured) GetResourceReferences() []corev1.ObjectReference { 167 | out := &[]corev1.ObjectReference{} 168 | _ = fieldpath.Pave(xr.Object).GetValueInto("spec.resourceRefs", out) 169 | return *out 170 | } 171 | 172 | // SetResourceReferences of this composite resource. 173 | func (xr *Unstructured) SetResourceReferences(refs []corev1.ObjectReference) { 174 | empty := corev1.ObjectReference{} 175 | filtered := make([]corev1.ObjectReference, 0, len(refs)) 176 | for _, ref := range refs { 177 | // TODO(negz): Ask muvaf to explain what this is working around. :) 178 | // TODO(muvaf): temporary workaround. 179 | if ref.String() == empty.String() { 180 | continue 181 | } 182 | filtered = append(filtered, ref) 183 | } 184 | _ = fieldpath.Pave(xr.Object).SetValue("spec.resourceRefs", filtered) 185 | } 186 | 187 | // GetWriteConnectionSecretToReference of this composite resource. 188 | func (xr *Unstructured) GetWriteConnectionSecretToReference() *xpv1.SecretReference { 189 | out := &xpv1.SecretReference{} 190 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.writeConnectionSecretToRef", out); err != nil { 191 | return nil 192 | } 193 | return out 194 | } 195 | 196 | // SetWriteConnectionSecretToReference of this composite resource. 197 | func (xr *Unstructured) SetWriteConnectionSecretToReference(ref *xpv1.SecretReference) { 198 | _ = fieldpath.Pave(xr.Object).SetValue("spec.writeConnectionSecretToRef", ref) 199 | } 200 | 201 | // GetPublishConnectionDetailsTo of this composite resource. 202 | func (xr *Unstructured) GetPublishConnectionDetailsTo() *xpv1.PublishConnectionDetailsTo { 203 | out := &xpv1.PublishConnectionDetailsTo{} 204 | if err := fieldpath.Pave(xr.Object).GetValueInto("spec.publishConnectionDetailsTo", out); err != nil { 205 | return nil 206 | } 207 | return out 208 | } 209 | 210 | // SetPublishConnectionDetailsTo of this composite resource. 211 | func (xr *Unstructured) SetPublishConnectionDetailsTo(ref *xpv1.PublishConnectionDetailsTo) { 212 | _ = fieldpath.Pave(xr.Object).SetValue("spec.publishConnectionDetailsTo", ref) 213 | } 214 | 215 | // GetCondition of this composite resource. 216 | func (xr *Unstructured) GetCondition(ct xpv1.ConditionType) xpv1.Condition { 217 | conditioned := xpv1.ConditionedStatus{} 218 | // The path is directly `status` because conditions are inline. 219 | if err := fieldpath.Pave(xr.Object).GetValueInto("status", &conditioned); err != nil { 220 | return xpv1.Condition{} 221 | } 222 | return conditioned.GetCondition(ct) 223 | } 224 | 225 | // SetConditions of this composite resource. 226 | func (xr *Unstructured) SetConditions(conditions ...xpv1.Condition) { 227 | conditioned := xpv1.ConditionedStatus{} 228 | // The path is directly `status` because conditions are inline. 229 | _ = fieldpath.Pave(xr.Object).GetValueInto("status", &conditioned) 230 | conditioned.SetConditions(conditions...) 231 | _ = fieldpath.Pave(xr.Object).SetValue("status.conditions", conditioned.Conditions) 232 | } 233 | 234 | // GetConnectionDetailsLastPublishedTime of this composite resource. 235 | func (xr *Unstructured) GetConnectionDetailsLastPublishedTime() *metav1.Time { 236 | out := &metav1.Time{} 237 | if err := fieldpath.Pave(xr.Object).GetValueInto("status.connectionDetails.lastPublishedTime", out); err != nil { 238 | return nil 239 | } 240 | return out 241 | } 242 | 243 | // SetConnectionDetailsLastPublishedTime of this composite resource. 244 | func (xr *Unstructured) SetConnectionDetailsLastPublishedTime(t *metav1.Time) { 245 | _ = fieldpath.Pave(xr.Object).SetValue("status.connectionDetails.lastPublishedTime", t) 246 | } 247 | 248 | // GetEnvironmentConfigReferences of this composite resource. 249 | func (xr *Unstructured) GetEnvironmentConfigReferences() []corev1.ObjectReference { 250 | out := &[]corev1.ObjectReference{} 251 | _ = fieldpath.Pave(xr.Object).GetValueInto("spec.environmentConfigRefs", out) 252 | return *out 253 | } 254 | 255 | // SetEnvironmentConfigReferences of this composite resource. 256 | func (xr *Unstructured) SetEnvironmentConfigReferences(refs []corev1.ObjectReference) { 257 | empty := corev1.ObjectReference{} 258 | filtered := make([]corev1.ObjectReference, 0, len(refs)) 259 | for _, ref := range refs { 260 | // TODO(negz): Ask muvaf to explain what this is working around. :) 261 | // TODO(muvaf): temporary workaround. 262 | if ref.String() == empty.String() { 263 | continue 264 | } 265 | filtered = append(filtered, ref) 266 | } 267 | _ = fieldpath.Pave(xr.Object).SetValue("spec.environmentConfigRefs", filtered) 268 | } 269 | 270 | // GetValue of the supplied field path. 271 | func (xr *Unstructured) GetValue(path string) (any, error) { 272 | return fieldpath.Pave(xr.Object).GetValue(path) 273 | } 274 | 275 | // GetValueInto the supplied type. 276 | func (xr *Unstructured) GetValueInto(path string, out any) error { 277 | return fieldpath.Pave(xr.Object).GetValueInto(path, out) 278 | } 279 | 280 | // GetString value of the supplied field path. 281 | func (xr *Unstructured) GetString(path string) (string, error) { 282 | return fieldpath.Pave(xr.Object).GetString(path) 283 | } 284 | 285 | // GetStringArray value of the supplied field path. 286 | func (xr *Unstructured) GetStringArray(path string) ([]string, error) { 287 | return fieldpath.Pave(xr.Object).GetStringArray(path) 288 | } 289 | 290 | // GetStringObject value of the supplied field path. 291 | func (xr *Unstructured) GetStringObject(path string) (map[string]string, error) { 292 | return fieldpath.Pave(xr.Object).GetStringObject(path) 293 | } 294 | 295 | // GetBool value of the supplied field path. 296 | func (xr *Unstructured) GetBool(path string) (bool, error) { 297 | return fieldpath.Pave(xr.Object).GetBool(path) 298 | } 299 | 300 | // GetInteger value of the supplied field path. 301 | func (xr *Unstructured) GetInteger(path string) (int64, error) { 302 | // This is a bit of a hack. Kubernetes JSON decoders will get us a 303 | // map[string]any where number values are int64, but protojson and structpb 304 | // will get us one where number values are float64. 305 | // https://pkg.go.dev/sigs.k8s.io/json#UnmarshalCaseSensitivePreserveInts 306 | p := fieldpath.Pave(xr.Object) 307 | 308 | // If we find an int64, return it. 309 | i64, err := p.GetInteger(path) 310 | if err == nil { 311 | return i64, nil 312 | } 313 | 314 | // If not, try return (and truncate) a float64. 315 | if f64, err := getNumber(p, path); err == nil { 316 | return int64(f64), nil 317 | } 318 | 319 | return 0, err 320 | } 321 | 322 | func getNumber(p *fieldpath.Paved, path string) (float64, error) { 323 | v, err := p.GetValue(path) 324 | if err != nil { 325 | return 0, err 326 | } 327 | 328 | f, ok := v.(float64) 329 | if !ok { 330 | return 0, errors.Errorf("%s: not a (float64) number", path) 331 | } 332 | return f, nil 333 | } 334 | 335 | // SetValue at the supplied field path. 336 | func (xr *Unstructured) SetValue(path string, value any) error { 337 | return fieldpath.Pave(xr.Object).SetValue(path, value) 338 | } 339 | 340 | // SetString value at the supplied field path. 341 | func (xr *Unstructured) SetString(path, value string) error { 342 | return xr.SetValue(path, value) 343 | } 344 | 345 | // SetBool value at the supplied field path. 346 | func (xr *Unstructured) SetBool(path string, value bool) error { 347 | return xr.SetValue(path, value) 348 | } 349 | 350 | // SetInteger value at the supplied field path. 351 | func (xr *Unstructured) SetInteger(path string, value int64) error { 352 | return xr.SetValue(path, value) 353 | } 354 | 355 | // SetObservedGeneration of this Composite resource. 356 | func (xr *Unstructured) SetObservedGeneration(generation int64) { 357 | status := &xpv1.ObservedStatus{} 358 | _ = fieldpath.Pave(xr.Object).GetValueInto("status", status) 359 | status.SetObservedGeneration(generation) 360 | _ = fieldpath.Pave(xr.Object).SetValue("status.observedGeneration", status.ObservedGeneration) 361 | } 362 | 363 | // GetObservedGeneration of this Composite resource. 364 | func (xr *Unstructured) GetObservedGeneration() int64 { 365 | status := &xpv1.ObservedStatus{} 366 | _ = fieldpath.Pave(xr.Object).GetValueInto("status", status) 367 | return status.GetObservedGeneration() 368 | } 369 | -------------------------------------------------------------------------------- /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 | // EquateErrors returns true if the supplied errors are of the same type and 135 | // produce identical strings. This mirrors the error comparison behaviour of 136 | // https://github.com/go-test/deep, 137 | // 138 | // This differs from cmpopts.EquateErrors, which does not test for error strings 139 | // and instead returns whether one error 'is' (in the errors.Is sense) the 140 | // other. 141 | func EquateErrors() cmp.Option { 142 | return cmp.Comparer(func(a, b error) bool { 143 | if a == nil || b == nil { 144 | return a == nil && b == nil 145 | } 146 | return a.Error() == b.Error() 147 | }) 148 | } 149 | -------------------------------------------------------------------------------- /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 | Ready Ready 44 | } 45 | 46 | // A Name uniquely identifies a composed resource within a Composition Function 47 | // pipeline. It's not the resource's metadata.name. 48 | type Name string 49 | 50 | // DesiredComposed reflects the desired state of a composed resource. 51 | type DesiredComposed struct { 52 | Resource *composed.Unstructured 53 | 54 | Ready Ready 55 | } 56 | 57 | // Required is a resource explicitly required by a Function. 58 | type Required struct { 59 | Resource *unstructured.Unstructured 60 | } 61 | 62 | // Extra is a resource explicitly required by a Function. 63 | // 64 | // Deprecated: Use Required. 65 | type Extra = Required 66 | 67 | // CredentialsType is the type of credentials. 68 | type CredentialsType string 69 | 70 | const ( 71 | // CredentialsTypeData is a Credential of type Data. 72 | CredentialsTypeData = "Data" 73 | ) 74 | 75 | // Credentials is a secret requested by a Function. 76 | type Credentials struct { 77 | // Type represents the type of credentials. 78 | Type CredentialsType 79 | 80 | // Data is a map of key-value pairs where the keys are strings, and the values are byte slices 81 | // containing sensitive data. 82 | Data map[string][]byte 83 | } 84 | 85 | // Ready indicates whether a composed resource should be considered ready. 86 | type Ready string 87 | 88 | // Composed resource readiness. 89 | const ( 90 | ReadyUnspecified Ready = "Unspecified" 91 | ReadyTrue Ready = "True" 92 | ReadyFalse Ready = "False" 93 | ) 94 | 95 | // NewDesiredComposed returns a new, empty desired composed resource. 96 | func NewDesiredComposed() *DesiredComposed { 97 | return &DesiredComposed{Resource: composed.New()} 98 | } 99 | 100 | // ObservedComposed reflects the observed state of a composed resource. 101 | type ObservedComposed struct { 102 | Resource *composed.Unstructured 103 | ConnectionDetails ConnectionDetails 104 | } 105 | 106 | // AsObject gets the supplied Kubernetes object from the supplied struct. 107 | func AsObject(s *structpb.Struct, o runtime.Object) error { 108 | // We try to avoid a JSON round-trip if o is backed by unstructured data. 109 | // Any type that is or embeds *unstructured.Unstructured has this method. 110 | if u, ok := o.(interface{ SetUnstructuredContent(_ map[string]any) }); ok { 111 | u.SetUnstructuredContent(s.AsMap()) 112 | return nil 113 | } 114 | 115 | b, err := protojson.Marshal(s) 116 | if err != nil { 117 | return errors.Wrapf(err, "cannot marshal %T to JSON", s) 118 | } 119 | return errors.Wrapf(json.Unmarshal(b, o, json.RejectUnknownMembers(true)), "cannot unmarshal JSON from %T into %T", s, o) 120 | } 121 | 122 | // AsStruct gets the supplied struct from the supplied Kubernetes object. 123 | func AsStruct(o runtime.Object) (*structpb.Struct, error) { 124 | // We try to avoid a JSON round-trip if o is backed by unstructured data. 125 | // Any type that is or embeds *unstructured.Unstructured has this method. 126 | if u, ok := o.(interface{ UnstructuredContent() map[string]any }); ok { 127 | s, err := structpb.NewStruct(u.UnstructuredContent()) 128 | return s, errors.Wrapf(err, "cannot create new Struct from %T", u) 129 | } 130 | 131 | b, err := json.Marshal(o) 132 | if err != nil { 133 | return nil, errors.Wrapf(err, "cannot marshal %T to JSON", o) 134 | } 135 | s := &structpb.Struct{} 136 | return s, errors.Wrapf(protojson.Unmarshal(b, s), "cannot unmarshal JSON from %T into %T", o, s) 137 | } 138 | 139 | // MustStructObject is intended only for use in tests. It returns the supplied 140 | // object as a struct. It panics if it can't. 141 | func MustStructObject(o runtime.Object) *structpb.Struct { 142 | s, err := AsStruct(o) 143 | if err != nil { 144 | panic(err) 145 | } 146 | return s 147 | } 148 | 149 | // MustStructJSON is intended only for use in tests. It returns the supplied 150 | // JSON string as a struct. It panics if it can't. 151 | func MustStructJSON(j string) *structpb.Struct { 152 | s := &structpb.Struct{} 153 | if err := protojson.Unmarshal([]byte(j), s); err != nil { 154 | panic(err) 155 | } 156 | return s 157 | } 158 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | "encoding/json" 22 | "time" 23 | 24 | "google.golang.org/protobuf/encoding/protojson" 25 | "google.golang.org/protobuf/types/known/durationpb" 26 | "google.golang.org/protobuf/types/known/structpb" 27 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 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/resource" 32 | ) 33 | 34 | // DefaultTTL is the default TTL for which a response can be cached. 35 | const DefaultTTL = 1 * time.Minute 36 | 37 | // To bootstraps a response to the supplied request. It automatically copies the 38 | // desired state from the request. 39 | func To(req *v1.RunFunctionRequest, ttl time.Duration) *v1.RunFunctionResponse { 40 | return &v1.RunFunctionResponse{ 41 | Meta: &v1.ResponseMeta{ 42 | Tag: req.GetMeta().GetTag(), 43 | Ttl: durationpb.New(ttl), 44 | }, 45 | Desired: req.GetDesired(), 46 | Context: req.GetContext(), 47 | } 48 | } 49 | 50 | // SetContextKey sets context to the supplied key. 51 | func SetContextKey(rsp *v1.RunFunctionResponse, key string, v *structpb.Value) { 52 | if rsp.GetContext().GetFields() == nil { 53 | rsp.Context = &structpb.Struct{Fields: make(map[string]*structpb.Value)} 54 | } 55 | rsp.Context.Fields[key] = v 56 | } 57 | 58 | // SetDesiredCompositeResource sets the desired composite resource in the 59 | // supplied response. The caller must be sure to avoid overwriting the desired 60 | // state that may have been accumulated by previous Functions in the pipeline, 61 | // unless they intend to. 62 | func SetDesiredCompositeResource(rsp *v1.RunFunctionResponse, xr *resource.Composite) error { 63 | if rsp.GetDesired() == nil { 64 | rsp.Desired = &v1.State{} 65 | } 66 | s, err := resource.AsStruct(xr.Resource) 67 | r := &v1.Resource{Resource: s, ConnectionDetails: xr.ConnectionDetails} 68 | if err != nil { 69 | return errors.Wrapf(err, "cannot convert %T to desired composite resource", xr.Resource) 70 | } 71 | switch xr.Ready { 72 | case resource.ReadyUnspecified: 73 | r.Ready = v1.Ready_READY_UNSPECIFIED 74 | case resource.ReadyFalse: 75 | r.Ready = v1.Ready_READY_FALSE 76 | case resource.ReadyTrue: 77 | r.Ready = v1.Ready_READY_TRUE 78 | } 79 | rsp.Desired.Composite = r 80 | return nil 81 | } 82 | 83 | // SetDesiredComposedResources sets the desired composed resources in the 84 | // supplied response. The caller must be sure to avoid overwriting the desired 85 | // state that may have been accumulated by previous Functions in the pipeline, 86 | // unless they intend to. 87 | func SetDesiredComposedResources(rsp *v1.RunFunctionResponse, dcds map[resource.Name]*resource.DesiredComposed) error { 88 | if rsp.GetDesired() == nil { 89 | rsp.Desired = &v1.State{} 90 | } 91 | if rsp.GetDesired().GetResources() == nil { 92 | rsp.Desired.Resources = map[string]*v1.Resource{} 93 | } 94 | for name, dcd := range dcds { 95 | s, err := resource.AsStruct(dcd.Resource) 96 | if err != nil { 97 | return err 98 | } 99 | r := &v1.Resource{Resource: s} 100 | switch dcd.Ready { 101 | case resource.ReadyUnspecified: 102 | r.Ready = v1.Ready_READY_UNSPECIFIED 103 | case resource.ReadyFalse: 104 | r.Ready = v1.Ready_READY_FALSE 105 | case resource.ReadyTrue: 106 | r.Ready = v1.Ready_READY_TRUE 107 | } 108 | rsp.Desired.Resources[string(name)] = r 109 | } 110 | return nil 111 | } 112 | 113 | // SetDesiredResources sets the desired resources in the supplied response. The 114 | // caller must be sure to avoid overwriting the desired state that may have been 115 | // accumulated by previous Functions in the pipeline, unless they intend to. 116 | func SetDesiredResources(rsp *v1.RunFunctionResponse, drs map[resource.Name]*unstructured.Unstructured) error { 117 | if rsp.GetDesired() == nil { 118 | rsp.Desired = &v1.State{} 119 | } 120 | if rsp.GetDesired().GetResources() == nil { 121 | rsp.Desired.Resources = map[string]*v1.Resource{} 122 | } 123 | for name, r := range drs { 124 | s, err := resource.AsStruct(r) 125 | if err != nil { 126 | return err 127 | } 128 | rsp.Desired.Resources[string(name)] = &v1.Resource{Resource: s} 129 | } 130 | return nil 131 | } 132 | 133 | // SetOutput sets the function's output. The supplied output must be marshalable 134 | // as JSON. Only operation functions support setting output. If a composition 135 | // function sets output it'll be ignored. 136 | func SetOutput(rsp *v1.RunFunctionResponse, output any) error { 137 | j, err := json.Marshal(output) 138 | if err != nil { 139 | return errors.Wrap(err, "cannot marshal output to JSON") 140 | } 141 | 142 | rsp.Output = &structpb.Struct{} 143 | return errors.Wrap(protojson.Unmarshal(j, rsp.Output), "cannot unmarshal JSON to protobuf struct") //nolint:protogetter // It's a set. 144 | } 145 | -------------------------------------------------------------------------------- /response/response_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2025 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 | "encoding/json" 22 | "testing" 23 | 24 | "github.com/google/go-cmp/cmp" 25 | "github.com/google/go-cmp/cmp/cmpopts" 26 | "google.golang.org/protobuf/testing/protocmp" 27 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 28 | 29 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 30 | "github.com/crossplane/function-sdk-go/resource" 31 | ) 32 | 33 | func TestSetDesiredResources(t *testing.T) { 34 | type args struct { 35 | rsp *v1.RunFunctionResponse 36 | drs map[resource.Name]*unstructured.Unstructured 37 | } 38 | type want struct { 39 | rsp *v1.RunFunctionResponse 40 | err error 41 | } 42 | cases := map[string]struct { 43 | args args 44 | want want 45 | }{ 46 | "Success": { 47 | args: args{ 48 | rsp: &v1.RunFunctionResponse{}, 49 | drs: map[resource.Name]*unstructured.Unstructured{ 50 | "Cool": MustUnstructJSON(`{ 51 | "apiVersion": "example.org/v1", 52 | "kind": "Test", 53 | "metadata": { 54 | "name": "cool" 55 | }, 56 | "spec" : { 57 | "cool": true 58 | } 59 | }`), 60 | }, 61 | }, 62 | want: want{ 63 | rsp: &v1.RunFunctionResponse{ 64 | Desired: &v1.State{ 65 | Resources: map[string]*v1.Resource{ 66 | "Cool": { 67 | Resource: resource.MustStructJSON(`{ 68 | "apiVersion": "example.org/v1", 69 | "kind": "Test", 70 | "metadata": { 71 | "name": "cool" 72 | }, 73 | "spec" : { 74 | "cool": true 75 | } 76 | }`), 77 | }, 78 | }, 79 | }, 80 | }, 81 | }, 82 | }, 83 | } 84 | 85 | for name, tc := range cases { 86 | t.Run(name, func(t *testing.T) { 87 | err := SetDesiredResources(tc.args.rsp, tc.args.drs) 88 | 89 | if diff := cmp.Diff(tc.want.rsp, tc.args.rsp, protocmp.Transform()); diff != "" { 90 | t.Errorf("SetDesiredResources(...): -want rsp, +got rsp:\n%s", diff) 91 | } 92 | 93 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 94 | t.Errorf("SetDesiredResources(...): -want err, +got err:\n%s", diff) 95 | } 96 | }) 97 | } 98 | } 99 | 100 | func TestOutput(t *testing.T) { 101 | type out struct { 102 | Cool string `json:"cool"` 103 | } 104 | 105 | type args struct { 106 | rsp *v1.RunFunctionResponse 107 | output any 108 | } 109 | type want struct { 110 | rsp *v1.RunFunctionResponse 111 | err error 112 | } 113 | cases := map[string]struct { 114 | args args 115 | want want 116 | }{ 117 | "Unmarshalable": { 118 | args: args{ 119 | rsp: &v1.RunFunctionResponse{}, 120 | output: make(chan<- bool), 121 | }, 122 | want: want{ 123 | rsp: &v1.RunFunctionResponse{}, 124 | err: cmpopts.AnyError, 125 | }, 126 | }, 127 | "Success": { 128 | args: args{ 129 | rsp: &v1.RunFunctionResponse{}, 130 | output: &out{Cool: "very"}, 131 | }, 132 | want: want{ 133 | rsp: &v1.RunFunctionResponse{ 134 | Output: resource.MustStructJSON(`{ 135 | "cool": "very" 136 | }`), 137 | }, 138 | }, 139 | }, 140 | } 141 | 142 | for name, tc := range cases { 143 | t.Run(name, func(t *testing.T) { 144 | err := SetOutput(tc.args.rsp, tc.args.output) 145 | 146 | if diff := cmp.Diff(tc.want.rsp, tc.args.rsp, protocmp.Transform()); diff != "" { 147 | t.Errorf("SetDesiredResources(...): -want rsp, +got rsp:\n%s", diff) 148 | } 149 | 150 | if diff := cmp.Diff(tc.want.err, err, cmpopts.EquateErrors()); diff != "" { 151 | t.Errorf("SetDesiredResources(...): -want err, +got err:\n%s", diff) 152 | } 153 | }) 154 | } 155 | } 156 | 157 | func MustUnstructJSON(j string) *unstructured.Unstructured { 158 | u := &unstructured.Unstructured{} 159 | if err := json.Unmarshal([]byte(j), u); err != nil { 160 | panic(err) 161 | } 162 | return u 163 | } 164 | -------------------------------------------------------------------------------- /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 | -------------------------------------------------------------------------------- /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 | healthgrpc "google.golang.org/grpc/health/grpc_health_v1" 33 | "google.golang.org/grpc/reflection" 34 | "google.golang.org/protobuf/proto" 35 | 36 | "github.com/crossplane/function-sdk-go/logging" 37 | v1 "github.com/crossplane/function-sdk-go/proto/v1" 38 | "github.com/crossplane/function-sdk-go/proto/v1beta1" 39 | ) 40 | 41 | // Default ServeOptions. 42 | const ( 43 | DefaultNetwork = "tcp" 44 | DefaultAddress = ":9443" 45 | DefaultMaxRecvMsgSize = 1024 * 1024 * 4 46 | ) 47 | 48 | // ServeOptions configure how a Function is served. 49 | type ServeOptions struct { 50 | Network string 51 | Address string 52 | MaxRecvMsgSize int 53 | Credentials credentials.TransportCredentials 54 | HealthServer healthgrpc.HealthServer 55 | } 56 | 57 | // A ServeOption configures how a Function is served. 58 | type ServeOption func(o *ServeOptions) error 59 | 60 | // Listen configures the network, address, and maximum message size on which the 61 | // Function will listen for RunFunctionRequests. 62 | func Listen(network, address string) ServeOption { 63 | return func(o *ServeOptions) error { 64 | o.Network = network 65 | o.Address = address 66 | return nil 67 | } 68 | } 69 | 70 | // MTLSCertificates specifies a directory from which to load mTLS certificates. 71 | // The directory must contain the server certificate (tls.key and tls.crt), as 72 | // well as a CA certificate (ca.crt) that will be used to authenticate clients. 73 | func MTLSCertificates(dir string) ServeOption { 74 | return func(o *ServeOptions) error { 75 | if dir == "" { 76 | // We want to support passing both MTLSCertificates and 77 | // Insecure as they were supplied as flags. So we don't 78 | // want this to fail because no dir was supplied. 79 | // If no TLS dir is supplied and insecure is false we'll 80 | // return an error due to having no credentials specified. 81 | return nil 82 | } 83 | crt, err := tls.LoadX509KeyPair( 84 | filepath.Clean(filepath.Join(dir, "tls.crt")), 85 | filepath.Clean(filepath.Join(dir, "tls.key")), 86 | ) 87 | if err != nil { 88 | return errors.Wrap(err, "cannot load X509 keypair") 89 | } 90 | 91 | ca, err := os.ReadFile(filepath.Clean(filepath.Join(dir, "ca.crt"))) 92 | if err != nil { 93 | return errors.Wrap(err, "cannot read CA certificate") 94 | } 95 | 96 | pool := x509.NewCertPool() 97 | if !pool.AppendCertsFromPEM(ca) { 98 | return errors.New("invalid CA certificate") 99 | } 100 | 101 | o.Credentials = credentials.NewTLS(&tls.Config{ 102 | MinVersion: tls.VersionTLS12, 103 | Certificates: []tls.Certificate{crt}, 104 | ClientCAs: pool, 105 | ClientAuth: tls.RequireAndVerifyClientCert, 106 | }) 107 | 108 | return nil 109 | } 110 | } 111 | 112 | // Insecure specifies whether this Function should be served insecurely - i.e. 113 | // without mTLS authentication. This is only useful for testing and development. 114 | // Crossplane will always send requests using mTLS. 115 | func Insecure(insecure bool) ServeOption { 116 | return func(o *ServeOptions) error { 117 | if insecure { 118 | o.Credentials = ginsecure.NewCredentials() 119 | } 120 | return nil 121 | } 122 | } 123 | 124 | // MaxRecvMessageSize returns a ServeOption to set the max message size in bytes the server can receive. 125 | // If this is not set, gRPC uses the default limit. 126 | func MaxRecvMessageSize(sz int) ServeOption { 127 | return func(o *ServeOptions) error { 128 | o.MaxRecvMsgSize = sz 129 | return nil 130 | } 131 | } 132 | 133 | // WithHealthServer lets the server start with a health server that can be called 134 | // to verify that the server is ready to accept connections. 135 | // 136 | // Use it with the HealthServer from google.golang.org/grpc/health like in 137 | // [this](https://github.com/grpc/grpc-go/blob/master/examples/features/health/server/main.go) 138 | // example or provide your own implementation. 139 | func WithHealthServer(srv healthgrpc.HealthServer) ServeOption { 140 | return func(o *ServeOptions) error { 141 | o.HealthServer = srv 142 | return nil 143 | } 144 | } 145 | 146 | // Serve the supplied Function by creating a gRPC server and listening for 147 | // RunFunctionRequests. Blocks until the server returns an error. 148 | func Serve(fn v1.FunctionRunnerServiceServer, o ...ServeOption) error { 149 | so := &ServeOptions{ 150 | Network: DefaultNetwork, 151 | Address: DefaultAddress, 152 | MaxRecvMsgSize: DefaultMaxRecvMsgSize, 153 | } 154 | 155 | for _, fn := range o { 156 | if err := fn(so); err != nil { 157 | return errors.Wrap(err, "cannot apply ServeOption") 158 | } 159 | } 160 | 161 | if so.Credentials == nil { 162 | return errors.New("no credentials provided - did you specify the Insecure or MTLSCertificates options?") 163 | } 164 | 165 | lis, err := net.Listen(so.Network, so.Address) 166 | if err != nil { 167 | return errors.Wrapf(err, "cannot listen for %s connections at address %q", so.Network, so.Address) 168 | } 169 | 170 | srv := grpc.NewServer(grpc.MaxRecvMsgSize(so.MaxRecvMsgSize), grpc.Creds(so.Credentials)) 171 | reflection.Register(srv) 172 | v1.RegisterFunctionRunnerServiceServer(srv, fn) 173 | v1beta1.RegisterFunctionRunnerServiceServer(srv, ServeBeta(fn)) 174 | 175 | if so.HealthServer != nil { 176 | healthgrpc.RegisterHealthServer(srv, so.HealthServer) 177 | } 178 | 179 | return errors.Wrap(srv.Serve(lis), "cannot serve mTLS gRPC connections") 180 | } 181 | 182 | // NewLogger returns a new logger. 183 | func NewLogger(debug bool) (logging.Logger, error) { 184 | return logging.NewLogger(debug) 185 | } 186 | 187 | // A BetaServer is a v1beta1 FunctionRunnerServiceServer that wraps an identical 188 | // v1 FunctionRunnerServiceServer. This requires the v1 and v1beta1 protos to be 189 | // identical. 190 | // 191 | // Functions were promoted from v1beta1 to v1 in Crossplane v1.17. Crossplane 192 | // v1.16 and earlier only sends v1beta1 RunFunctionRequests. Functions should 193 | // use the BetaServer for backward compatibility, to support Crossplane v1.16 194 | // and earlier. 195 | type BetaServer struct { 196 | v1beta1.UnimplementedFunctionRunnerServiceServer 197 | 198 | wrapped v1.FunctionRunnerServiceServer 199 | } 200 | 201 | // ServeBeta returns a v1beta1.FunctionRunnerServiceServer that wraps the 202 | // suppled v1.FunctionRunnerServiceServer. 203 | func ServeBeta(s v1.FunctionRunnerServiceServer) *BetaServer { 204 | return &BetaServer{wrapped: s} 205 | } 206 | 207 | // RunFunction calls the RunFunction method of the wrapped 208 | // v1.FunctionRunnerServiceServer. It converts from v1beta1 to v1 and back by 209 | // round-tripping through protobuf marshaling. 210 | func (s *BetaServer) RunFunction(ctx context.Context, req *v1beta1.RunFunctionRequest) (*v1beta1.RunFunctionResponse, error) { 211 | gareq := &v1.RunFunctionRequest{} 212 | 213 | b, err := proto.Marshal(req) 214 | if err != nil { 215 | return nil, errors.Wrap(err, "cannot marshal v1beta1 RunFunctionRequest to protobuf bytes") 216 | } 217 | 218 | if err := proto.Unmarshal(b, gareq); err != nil { 219 | return nil, errors.Wrap(err, "cannot unmarshal v1 RunFunctionRequest from v1beta1 protobuf bytes") 220 | } 221 | 222 | garsp, err := s.wrapped.RunFunction(ctx, gareq) 223 | if err != nil { 224 | // This error is intentionally not wrapped. This middleware is just 225 | // calling an underlying RunFunction. 226 | return nil, err 227 | } 228 | 229 | b, err = proto.Marshal(garsp) 230 | if err != nil { 231 | return nil, errors.Wrap(err, "cannot marshal v1beta1 RunFunctionResponse to protobuf bytes") 232 | } 233 | 234 | rsp := &v1beta1.RunFunctionResponse{} 235 | err = proto.Unmarshal(b, rsp) 236 | return rsp, errors.Wrap(err, "cannot unmarshal v1 RunFunctionResponse from v1beta1 protobuf bytes") 237 | } 238 | -------------------------------------------------------------------------------- /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 | type MockFunctionServer struct { 181 | v1.UnimplementedFunctionRunnerServiceServer 182 | 183 | rsp *v1.RunFunctionResponse 184 | err error 185 | } 186 | 187 | func (s *MockFunctionServer) RunFunction(context.Context, *v1.RunFunctionRequest) (*v1.RunFunctionResponse, error) { 188 | return s.rsp, s.err 189 | } 190 | --------------------------------------------------------------------------------