├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── PULL_REQUEST_TEMPLATE.md ├── stale.yml └── workflows │ ├── backport.yml │ ├── ci.yml │ ├── codeql.yml │ ├── commands.yml │ └── tag.yml ├── .gitignore ├── .gitmodules ├── .golangci.yml ├── LICENSE ├── Makefile ├── OWNERS.md ├── README.md ├── docs ├── configuring-a-resource.md ├── generating-a-provider.md └── images │ ├── terrajet-externalname.excalidraw │ └── terrajet-externalname.png ├── go.mod ├── go.sum ├── hack └── boilerplate.txt └── pkg ├── config ├── defaults.go ├── defaults_test.go ├── provider.go ├── resource.go └── resource_test.go ├── controller ├── api.go ├── api_test.go ├── external.go ├── external_test.go ├── interfaces.go └── options.go ├── generate.go ├── pipeline ├── controller.go ├── crd.go ├── register.go ├── run.go ├── setup.go ├── templates │ ├── controller.go.tmpl │ ├── crd_types.go.tmpl │ ├── embed.go │ ├── groupversion_info.go.tmpl │ ├── register.go.tmpl │ ├── setup.go.tmpl │ └── terraformed.go.tmpl ├── terraformed.go └── version.go ├── resource ├── conditions.go ├── fake │ ├── mocks │ │ └── mock.go │ └── terraformed.go ├── interfaces.go ├── json │ ├── json.go │ └── statev4.go ├── lateinit.go ├── lateinit_test.go ├── sensitive.go └── sensitive_test.go ├── terraform ├── errors │ ├── errors.go │ └── errors_test.go ├── files.go ├── files_test.go ├── finalizer.go ├── finalizer_test.go ├── operation.go ├── operation_test.go ├── provider_runner.go ├── provider_runner_test.go ├── store.go ├── timeouts.go ├── timeouts_test.go ├── workspace.go └── workspace_test.go ├── types ├── builder.go ├── builder_test.go ├── comments │ ├── comment.go │ └── comment_test.go ├── conversion │ ├── sdkv1 │ │ └── sdkv1.go │ └── tfjson │ │ └── tfjson.go ├── field.go ├── markers │ ├── crossplane.go │ ├── crossplane_test.go │ ├── kubebuilder.go │ ├── kubebuilder_test.go │ ├── options.go │ ├── terrajet.go │ └── terrajet_test.go ├── name │ ├── name.go │ └── name_test.go ├── reference.go └── reference_test.go └── version └── version.go /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug Report 3 | about: Help us diagnose and fix bugs in Terrajet 4 | labels: bug 5 | --- 6 | 13 | 14 | ### What happened? 15 | 19 | 20 | 21 | ### How can we reproduce it? 22 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature Request 3 | about: Help us make Terrajet more useful 4 | labels: enhancement 5 | --- 6 | 13 | 14 | ### What problem are you facing? 15 | 20 | 21 | ### How could Terrajet 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 | - [ ] Added `backport release-x.y` labels to auto-backport this PR if necessary. 27 | 28 | ### How has this code been tested 29 | 30 | 35 | 36 | [contribution process]: https://git.io/fj2m9 37 | -------------------------------------------------------------------------------- /.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-18.04 22 | if: github.event.pull_request.merged 23 | steps: 24 | - name: Checkout 25 | uses: actions/checkout@v2 26 | with: 27 | fetch-depth: 0 28 | 29 | - name: Open Backport PR 30 | uses: zeebe-io/backport-action@v0.0.4 31 | with: 32 | github_token: ${{ secrets.GITHUB_TOKEN }} 33 | github_workspace: ${{ github.workspace }} 34 | version: v0.0.4 35 | -------------------------------------------------------------------------------- /.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.17' 14 | GOLANGCI_VERSION: 'v1.31' 15 | DOCKER_BUILDX_VERSION: 'v0.4.2' 16 | 17 | # Common users. We can't run a step 'if secrets.AWS_USR != ""' but we can run 18 | # a step 'if env.AWS_USR' != ""', so we copy these to succinctly test whether 19 | # credentials have been provided before trying to run steps that need them. 20 | DOCKER_USR: ${{ secrets.DOCKER_USR }} 21 | AWS_USR: ${{ secrets.AWS_USR }} 22 | 23 | jobs: 24 | detect-noop: 25 | runs-on: ubuntu-18.04 26 | outputs: 27 | noop: ${{ steps.noop.outputs.should_skip }} 28 | steps: 29 | - name: Detect No-op Changes 30 | id: noop 31 | uses: fkirc/skip-duplicate-actions@v2.1.0 32 | with: 33 | github_token: ${{ secrets.GITHUB_TOKEN }} 34 | paths_ignore: '["**.md", "**.png", "**.jpg"]' 35 | do_not_skip: '["workflow_dispatch", "schedule", "push"]' 36 | concurrent_skipping: false 37 | 38 | 39 | lint: 40 | runs-on: ubuntu-18.04 41 | needs: detect-noop 42 | if: needs.detect-noop.outputs.noop != 'true' 43 | 44 | steps: 45 | - name: Checkout 46 | uses: actions/checkout@v2 47 | with: 48 | submodules: true 49 | 50 | - name: Setup Go 51 | uses: actions/setup-go@v2 52 | with: 53 | go-version: ${{ env.GO_VERSION }} 54 | 55 | - name: Find the Go Build Cache 56 | id: go 57 | run: echo "::set-output name=cache::$(go env GOCACHE)" 58 | 59 | - name: Cache the Go Build Cache 60 | uses: actions/cache@v2 61 | with: 62 | path: ${{ steps.go.outputs.cache }} 63 | key: ${{ runner.os }}-build-lint-${{ hashFiles('**/go.sum') }} 64 | restore-keys: ${{ runner.os }}-build-lint- 65 | 66 | - name: Cache Go Dependencies 67 | uses: actions/cache@v2 68 | with: 69 | path: .work/pkg 70 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 71 | restore-keys: ${{ runner.os }}-pkg- 72 | 73 | - name: Vendor Dependencies 74 | run: make vendor vendor.check 75 | 76 | # We could run 'make lint' to ensure our desired Go version, but we prefer 77 | # this action because it leaves 'annotations' (i.e. it comments on PRs to 78 | # point out linter violations). 79 | - name: Lint 80 | uses: golangci/golangci-lint-action@v2 81 | with: 82 | version: ${{ env.GOLANGCI_VERSION }} 83 | skip-go-installation: true 84 | 85 | check-diff: 86 | runs-on: ubuntu-18.04 87 | needs: detect-noop 88 | if: needs.detect-noop.outputs.noop != 'true' 89 | 90 | steps: 91 | - name: Checkout 92 | uses: actions/checkout@v2 93 | with: 94 | submodules: true 95 | 96 | - name: Setup Go 97 | uses: actions/setup-go@v2 98 | with: 99 | go-version: ${{ env.GO_VERSION }} 100 | 101 | - name: Find the Go Build Cache 102 | id: go 103 | run: echo "::set-output name=cache::$(go env GOCACHE)" 104 | 105 | - name: Cache the Go Build Cache 106 | uses: actions/cache@v2 107 | with: 108 | path: ${{ steps.go.outputs.cache }} 109 | key: ${{ runner.os }}-build-check-diff-${{ hashFiles('**/go.sum') }} 110 | restore-keys: ${{ runner.os }}-build-check-diff- 111 | 112 | - name: Cache Go Dependencies 113 | uses: actions/cache@v2 114 | with: 115 | path: .work/pkg 116 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 117 | restore-keys: ${{ runner.os }}-pkg- 118 | 119 | - name: Vendor Dependencies 120 | run: make vendor vendor.check 121 | 122 | - name: Check Diff 123 | run: make check-diff 124 | 125 | unit-tests: 126 | runs-on: ubuntu-18.04 127 | needs: detect-noop 128 | if: needs.detect-noop.outputs.noop != 'true' 129 | 130 | steps: 131 | - name: Checkout 132 | uses: actions/checkout@v2 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@v2 141 | with: 142 | go-version: ${{ env.GO_VERSION }} 143 | 144 | - name: Find the Go Build Cache 145 | id: go 146 | run: echo "::set-output name=cache::$(go env GOCACHE)" 147 | 148 | - name: Cache the Go Build Cache 149 | uses: actions/cache@v2 150 | with: 151 | path: ${{ steps.go.outputs.cache }} 152 | key: ${{ runner.os }}-build-unit-tests-${{ hashFiles('**/go.sum') }} 153 | restore-keys: ${{ runner.os }}-build-unit-tests- 154 | 155 | - name: Cache Go Dependencies 156 | uses: actions/cache@v2 157 | with: 158 | path: .work/pkg 159 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 160 | restore-keys: ${{ runner.os }}-pkg- 161 | 162 | - name: Vendor Dependencies 163 | run: make vendor vendor.check 164 | 165 | - name: Run Unit Tests 166 | run: make -j2 test 167 | 168 | - name: Publish Unit Test Coverage 169 | uses: codecov/codecov-action@v1 170 | with: 171 | flags: unittests 172 | file: _output/tests/linux_amd64/coverage.txt 173 | 174 | e2e-tests: 175 | runs-on: ubuntu-18.04 176 | needs: detect-noop 177 | if: needs.detect-noop.outputs.noop != 'true' 178 | 179 | steps: 180 | - name: Checkout 181 | uses: actions/checkout@v2 182 | with: 183 | submodules: true 184 | 185 | - name: Fetch History 186 | run: git fetch --prune --unshallow 187 | 188 | - name: Setup Go 189 | uses: actions/setup-go@v2 190 | with: 191 | go-version: ${{ env.GO_VERSION }} 192 | 193 | - name: Find the Go Build Cache 194 | id: go 195 | run: echo "::set-output name=cache::$(go env GOCACHE)" 196 | 197 | - name: Cache the Go Build Cache 198 | uses: actions/cache@v2 199 | with: 200 | path: ${{ steps.go.outputs.cache }} 201 | key: ${{ runner.os }}-build-e2e-tests-${{ hashFiles('**/go.sum') }} 202 | restore-keys: ${{ runner.os }}-build-e2e-tests- 203 | 204 | - name: Cache Go Dependencies 205 | uses: actions/cache@v2 206 | with: 207 | path: .work/pkg 208 | key: ${{ runner.os }}-pkg-${{ hashFiles('**/go.sum') }} 209 | restore-keys: | 210 | ${{ runner.os }}-pkg- 211 | 212 | - name: Vendor Dependencies 213 | run: make vendor vendor.check 214 | 215 | - name: Run E2E Tests 216 | run: make e2e 217 | -------------------------------------------------------------------------------- /.github/workflows/codeql.yml: -------------------------------------------------------------------------------- 1 | name: CodeQL 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | - release-* 8 | workflow_dispatch: {} 9 | 10 | jobs: 11 | detect-noop: 12 | runs-on: ubuntu-18.04 13 | outputs: 14 | noop: ${{ steps.noop.outputs.should_skip }} 15 | steps: 16 | - name: Detect No-op Changes 17 | id: noop 18 | uses: fkirc/skip-duplicate-actions@v2.1.0 19 | with: 20 | github_token: ${{ secrets.GITHUB_TOKEN }} 21 | paths_ignore: '["**.md", "**.png", "**.jpg"]' 22 | do_not_skip: '["workflow_dispatch", "schedule", "push"]' 23 | concurrent_skipping: false 24 | 25 | analyze: 26 | runs-on: ubuntu-18.04 27 | needs: detect-noop 28 | if: needs.detect-noop.outputs.noop != 'true' 29 | 30 | steps: 31 | - name: Checkout 32 | uses: actions/checkout@v2 33 | with: 34 | submodules: true 35 | 36 | - name: Initialize CodeQL 37 | uses: github/codeql-action/init@v1 38 | with: 39 | languages: go 40 | 41 | - name: Perform CodeQL Analysis 42 | uses: github/codeql-action/analyze@v1 43 | -------------------------------------------------------------------------------- /.github/workflows/commands.yml: -------------------------------------------------------------------------------- 1 | name: Comment Commands 2 | 3 | on: issue_comment 4 | 5 | jobs: 6 | points: 7 | runs-on: ubuntu-18.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@v1 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@v4 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-18.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@v1 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@v2 84 | with: 85 | fetch-depth: 0 86 | 87 | - name: Open Backport PR 88 | uses: zeebe-io/backport-action@v0.0.4 89 | with: 90 | github_token: ${{ secrets.GITHUB_TOKEN }} 91 | github_workspace: ${{ github.workspace }} 92 | version: v0.0.4 93 | -------------------------------------------------------------------------------- /.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-18.04 16 | 17 | steps: 18 | - name: Checkout 19 | uses: actions/checkout@v2 20 | 21 | - name: Create Tag 22 | uses: negz/create-tag@v1 23 | with: 24 | version: ${{ github.event.inputs.version }} 25 | message: ${{ github.event.inputs.message }} 26 | token: ${{ secrets.GITHUB_TOKEN }} -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.cache 2 | /.work 3 | /_output 4 | /config/ 5 | /config 6 | cover.out 7 | /vendor 8 | /.vendor-new 9 | .DS_Store 10 | 11 | # ignore IDE folders 12 | .vscode/ 13 | .idea/ -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "build"] 2 | path = build 3 | url = https://github.com/upbound/build 4 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 10m 3 | 4 | skip-files: 5 | - "zz_generated\\..+\\.go$" 6 | 7 | output: 8 | # colored-line-number|line-number|json|tab|checkstyle|code-climate, default is "colored-line-number" 9 | format: colored-line-number 10 | 11 | linters-settings: 12 | errcheck: 13 | # report about not checking of errors in type assetions: `a := b.(MyStruct)`; 14 | # default is false: such cases aren't reported by default. 15 | check-type-assertions: false 16 | 17 | # report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`; 18 | # default is false: such cases aren't reported by default. 19 | check-blank: false 20 | 21 | # [deprecated] comma-separated list of pairs of the form pkg:regex 22 | # the regex is used to ignore names within pkg. (default "fmt:.*"). 23 | # see https://github.com/kisielk/errcheck#the-deprecated-method for details 24 | ignore: fmt:.*,io/ioutil:^Read.* 25 | 26 | govet: 27 | # report about shadowed variables 28 | check-shadowing: false 29 | 30 | golint: 31 | # minimal confidence for issues, default is 0.8 32 | min-confidence: 0.8 33 | 34 | gofmt: 35 | # simplify code: gofmt with `-s` option, true by default 36 | simplify: true 37 | 38 | goimports: 39 | # put imports beginning with prefix after 3rd-party packages; 40 | # it's a comma-separated list of prefixes 41 | local-prefixes: github.com/crossplane/terrajet 42 | 43 | gocyclo: 44 | # minimal code complexity to report, 30 by default (but we recommend 10-20) 45 | min-complexity: 10 46 | 47 | maligned: 48 | # print struct with more effective memory layout or not, false by default 49 | suggest-new: true 50 | 51 | dupl: 52 | # tokens count to trigger issue, 150 by default 53 | threshold: 100 54 | 55 | goconst: 56 | # minimal length of string constant, 3 by default 57 | min-len: 3 58 | # minimal occurrences count to trigger, 3 by default 59 | min-occurrences: 5 60 | 61 | lll: 62 | # tab width in spaces. Default to 1. 63 | tab-width: 1 64 | 65 | unused: 66 | # treat code as a program (not a library) and report unused exported identifiers; default is false. 67 | # XXX: if you enable this setting, unused will report a lot of false-positives in text editors: 68 | # if it's called for subdir of a project it can't find funcs usages. All text editor integrations 69 | # with golangci-lint call it on a directory with the changed file. 70 | check-exported: false 71 | 72 | unparam: 73 | # Inspect exported functions, default is false. Set to true if no external program/library imports your code. 74 | # XXX: if you enable this setting, unparam will report a lot of false-positives in text editors: 75 | # if it's called for subdir of a project it can't find external interfaces. All text editor integrations 76 | # with golangci-lint call it on a directory with the changed file. 77 | check-exported: false 78 | 79 | nakedret: 80 | # make an issue if func has more lines of code than this setting and it has naked returns; default is 30 81 | max-func-lines: 30 82 | 83 | prealloc: 84 | # XXX: we don't recommend using this linter before doing performance profiling. 85 | # For most programs usage of prealloc will be a premature optimization. 86 | 87 | # Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them. 88 | # True by default. 89 | simple: true 90 | range-loops: true # Report preallocation suggestions on range loops, true by default 91 | for-loops: false # Report preallocation suggestions on for loops, false by default 92 | 93 | gocritic: 94 | # Enable multiple checks by tags, run `GL_DEBUG=gocritic golangci-lint` run to see all tags and checks. 95 | # Empty list by default. See https://github.com/go-critic/go-critic#usage -> section "Tags". 96 | enabled-tags: 97 | - performance 98 | 99 | settings: # settings passed to gocritic 100 | captLocal: # must be valid enabled check name 101 | paramsOnly: true 102 | rangeValCopy: 103 | sizeThreshold: 32 104 | 105 | linters: 106 | enable: 107 | - megacheck 108 | - govet 109 | - gocyclo 110 | - gocritic 111 | - interfacer 112 | - goconst 113 | - goimports 114 | - gofmt # We enable this as well as goimports for its simplify mode. 115 | - prealloc 116 | - golint 117 | - unconvert 118 | - misspell 119 | - nakedret 120 | 121 | presets: 122 | - bugs 123 | - unused 124 | fast: false 125 | 126 | 127 | issues: 128 | # Excluding configuration per-path and per-linter 129 | exclude-rules: 130 | # Exclude some linters from running on tests files. 131 | - path: _test(ing)?\.go 132 | linters: 133 | - gocyclo 134 | - errcheck 135 | - dupl 136 | - gosec 137 | - scopelint 138 | - unparam 139 | 140 | # Ease some gocritic warnings on test files. 141 | - path: _test\.go 142 | text: "(unnamedResult|exitAfterDefer)" 143 | linters: 144 | - gocritic 145 | 146 | # These are performance optimisations rather than style issues per se. 147 | # They warn when function arguments or range values copy a lot of memory 148 | # rather than using a pointer. 149 | - text: "(hugeParam|rangeValCopy):" 150 | linters: 151 | - gocritic 152 | 153 | # This "TestMain should call os.Exit to set exit code" warning is not clever 154 | # enough to notice that we call a helper method that calls os.Exit. 155 | - text: "SA3000:" 156 | linters: 157 | - staticcheck 158 | 159 | - text: "k8s.io/api/core/v1" 160 | linters: 161 | - goimports 162 | 163 | # This is a "potential hardcoded credentials" warning. It's triggered by 164 | # any variable with 'secret' in the same, and thus hits a lot of false 165 | # positives in Kubernetes land where a Secret is an object type. 166 | - text: "G101:" 167 | linters: 168 | - gosec 169 | - gas 170 | 171 | # This is an 'errors unhandled' warning that duplicates errcheck. 172 | - text: "G104:" 173 | linters: 174 | - gosec 175 | - gas 176 | 177 | # The Azure AddToUserAgent method appends to the existing user agent string. 178 | # It returns an error if you pass it an empty string lettinga you know the 179 | # user agent did not change, making it more of a warning. 180 | - text: \.AddToUserAgent 181 | linters: 182 | - errcheck 183 | 184 | # Independently from option `exclude` we use default exclude patterns, 185 | # it can be disabled by this option. To list all 186 | # excluded by default patterns execute `golangci-lint run --help`. 187 | # Default value for this option is true. 188 | exclude-use-default: false 189 | 190 | # Show only new issues: if there are unstaged changes or untracked files, 191 | # only those changes are analyzed, else only changes in HEAD~ are analyzed. 192 | # It's a super-useful option for integration of golangci-lint into existing 193 | # large codebase. It's not practical to fix all existing issues at the moment 194 | # of integration: much better don't allow issues in new code. 195 | # Default is false. 196 | new: false 197 | 198 | # Maximum issues count per one linter. Set to 0 to disable. Default is 50. 199 | max-per-linter: 0 200 | 201 | # Maximum count of issues with the same text. Set to 0 to disable. Default is 3. 202 | max-same-issues: 0 -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # ==================================================================================== 2 | # Setup Project 3 | 4 | PROJECT_NAME := terrajet 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 Images 16 | 17 | # even though this repo doesn't build images (note the no-op img.build target below), 18 | # some of the init is needed for the cross build container, e.g. setting BUILD_REGISTRY 19 | -include build/makelib/image.mk 20 | img.build: 21 | 22 | # ==================================================================================== 23 | # Setup Go 24 | 25 | # Set a sane default so that the nprocs calculation below is less noisy on the initial 26 | # loading of this file 27 | NPROCS ?= 1 28 | 29 | # each of our test suites starts a kube-apiserver and running many test suites in 30 | # parallel can lead to high CPU utilization. by default we reduce the parallelism 31 | # to half the number of CPU cores. 32 | GO_TEST_PARALLEL := $(shell echo $$(( $(NPROCS) / 2 ))) 33 | 34 | GO_LDFLAGS += -X $(GO_PROJECT)/pkg/version.Version=$(VERSION) 35 | GO_SUBDIRS += pkg 36 | GO111MODULE = on 37 | -include build/makelib/golang.mk 38 | 39 | # ==================================================================================== 40 | # Targets 41 | 42 | # run `make help` to see the targets and options 43 | 44 | # We want submodules to be set up the first time `make` is run. 45 | # We manage the build/ folder and its Makefiles as a submodule. 46 | # The first time `make` is run, the includes of build/*.mk files will 47 | # all fail, and this target will be run. The next time, the default as defined 48 | # by the includes will be run instead. 49 | fallthrough: submodules 50 | @echo Initial setup complete. Running make again . . . 51 | @make 52 | 53 | # Generate a coverage report for cobertura applying exclusions on 54 | # - generated file 55 | cobertura: 56 | @cat $(GO_TEST_OUTPUT)/coverage.txt | \ 57 | $(GOCOVER_COBERTURA) > $(GO_TEST_OUTPUT)/cobertura-coverage.xml 58 | 59 | # Update the submodules, such as the common build scripts. 60 | submodules: 61 | @git submodule sync 62 | @git submodule update --init --recursive 63 | 64 | .PHONY: cobertura reviewable submodules fallthrough 65 | -------------------------------------------------------------------------------- /OWNERS.md: -------------------------------------------------------------------------------- 1 | # Terrajet Maintainers 2 | 3 | Please see [GOVERNANCE.md](https://github.com/crossplane/crossplane/blob/master/GOVERNANCE.md) for governance guidelines and responsibilities for the 4 | steering committee and maintainers of repositories under Crossplane organization. 5 | 6 | In alphabetical order: 7 | 8 | * Alper Rifat Uluçınar ([ulucinar](https://github.com/ulucinar)) 9 | * Hasan Türken ([turkenh](https://github.com/turkenh)) 10 | * Muvaffak Onuş ([muvaf](https://github.com/muvaf)) 11 | * Sergen Yalçın ([sergenyalcin](https://github.com/sergenyalcin)) 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # 🚨 PLEASE NOTE 🚨: This repository is no longer maintained. Use its successor [Upjet](https://github.com/upbound/upjet) instead. See details in https://github.com/crossplane/terrajet/issues/308 2 | 3 | ## A guide for migrating Terrajet-based providers to Upjet is available [here](https://github.com/upbound/upjet/blob/main/docs/migrating-from-terrajet-to-upjet.md). 4 | 5 | --- 6 | # Terrajet - Generate Crossplane Providers from any Terraform Provider 7 | 8 | Terrajet is a code generator framework that allows developers to build code 9 | generation pipelines that can generate Crossplane controllers. Developers can 10 | start building their code generation pipeline targeting specific Terraform Providers 11 | by importing Terrajet and wiring all generators together, customizing the whole 12 | pipeline in the process. 13 | 14 | See [design document][design-doc] for more details. 15 | 16 | Feel free to test the following Crossplane providers built using Terrajet: 17 | 18 | * [Provider Jet AWS](https://github.com/crossplane-contrib/provider-jet-aws/releases) 19 | * [Provider Jet Azure](https://github.com/crossplane-contrib/provider-jet-azure/releases) 20 | * [Provider Jet GCP](https://github.com/crossplane-contrib/provider-jet-gcp/releases) 21 | 22 | **NOTE**: Terrajet is in its very early stages and we're making many changes that 23 | can affect the output and the runtime. Please check the generated code before 24 | running in production. 25 | 26 | ## Generating a New Provider Using Terrajet 27 | 28 | Please see [this guide](docs/generating-a-provider.md) for detailed steps on how 29 | to generate a Crossplane provider based on an existing Terraform provider. 30 | 31 | ## Report a Bug 32 | 33 | For filing bugs, suggesting improvements, or requesting new features, please 34 | open an [issue](https://github.com/crossplane/terrajet/issues). 35 | 36 | ## Contact 37 | 38 | Please use the following to reach members of the community: 39 | 40 | * Slack: Join our [slack channel](https://slack.crossplane.io) 41 | * Forums: 42 | [crossplane-dev](https://groups.google.com/forum/#!forum/crossplane-dev) 43 | * Twitter: [@crossplane_io](https://twitter.com/crossplane_io) 44 | * Email: [info@crossplane.io](mailto:info@crossplane.io) 45 | 46 | ## Governance and Owners 47 | 48 | terrajet is run according to the same 49 | [Governance](https://github.com/crossplane/crossplane/blob/master/GOVERNANCE.md) 50 | and [Ownership](https://github.com/crossplane/crossplane/blob/master/OWNERS.md) 51 | structure as the core Crossplane project. 52 | 53 | ## Prior Art 54 | 55 | There are many projects in infrastructure space that builds on top of Terraform. 56 | Each of the projects have their own limitations, additional features and different 57 | license restrictions. 58 | 59 | * [Crossplane: Terraform Provider Runtime](https://github.com/crossplane/crossplane/blob/e2d7278/design/design-doc-terraform-provider-runtime.md) 60 | * [Crossplane: provider-terraform](https://github.com/crossplane-contrib/provider-terraform) 61 | * [Hashicorp Terraform Cloud Operator](https://github.com/hashicorp/terraform-k8s) 62 | * [Rancher Terraform Controller](https://github.com/rancher/terraform-controller) 63 | * [OAM Terraform Controller](https://github.com/oam-dev/terraform-controller) 64 | * [Kubeform](https://github.com/kubeform/kubeform) 65 | * [Terraform Operator](https://github.com/isaaguilar/terraform-operator) 66 | 67 | ## Code of Conduct 68 | 69 | terrajet adheres to the same [Code of 70 | Conduct](https://github.com/crossplane/crossplane/blob/master/CODE_OF_CONDUCT.md) 71 | as the core Crossplane project. 72 | 73 | ## Licensing 74 | 75 | terrajet is under the Apache 2.0 license. 76 | 77 | [![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fcrossplane%2Fterrajet.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fcrossplane%2Fterrajet?ref=badge_large) 78 | 79 | [design-doc]: https://github.com/crossplane/crossplane/blob/master/design/design-doc-terrajet.md 80 | [provider-template]: https://github.com/crossplane/provider-template -------------------------------------------------------------------------------- /docs/images/terrajet-externalname.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/crossplane/terrajet/767e1dc0d0e2b3f1ad130b7699556af6bb967799/docs/images/terrajet-externalname.png -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/crossplane/terrajet 2 | 3 | go 1.17 4 | 5 | // Due to https://github.com/crossplane/terrajet/issues/294 6 | replace github.com/hashicorp/go-getter => github.com/hashicorp/go-getter v1.6.2 7 | 8 | require ( 9 | github.com/crossplane/crossplane-runtime v0.15.1-0.20220315141414-988c9ba9c255 10 | github.com/fatih/camelcase v1.0.0 11 | github.com/golang/mock v1.6.0 12 | github.com/google/go-cmp v0.5.8 13 | github.com/hashicorp/terraform-json v0.14.0 14 | github.com/hashicorp/terraform-plugin-sdk v1.17.3-0.20210830231914-78d95c96af58 15 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.20.0 16 | github.com/iancoleman/strcase v0.2.0 17 | github.com/json-iterator/go v1.1.12 18 | github.com/muvaf/typewriter v0.0.0-20210910160850-80e49fe1eb32 19 | github.com/pkg/errors v0.9.1 20 | github.com/spf13/afero v1.8.0 21 | github.com/zclconf/go-cty v1.10.0 22 | golang.org/x/tools v0.1.6-0.20210820212750-d4cc65f0b2ff 23 | k8s.io/api v0.23.0 24 | k8s.io/apimachinery v0.23.0 25 | k8s.io/utils v0.0.0-20210930125809-cb0fa318a74b 26 | sigs.k8s.io/controller-runtime v0.11.0 27 | ) 28 | 29 | require ( 30 | cloud.google.com/go v0.81.0 // indirect 31 | cloud.google.com/go/storage v1.14.0 // indirect 32 | github.com/Masterminds/goutils v1.1.0 // indirect 33 | github.com/Masterminds/semver v1.5.0 // indirect 34 | github.com/Masterminds/sprig v2.22.0+incompatible // indirect 35 | github.com/agext/levenshtein v1.2.2 // indirect 36 | github.com/apparentlymart/go-cidr v1.1.0 // indirect 37 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 38 | github.com/armon/go-radix v1.0.0 // indirect 39 | github.com/aws/aws-sdk-go v1.37.0 // indirect 40 | github.com/beorn7/perks v1.0.1 // indirect 41 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect 42 | github.com/bgentry/speakeasy v0.1.0 // indirect 43 | github.com/cespare/xxhash/v2 v2.1.1 // indirect 44 | github.com/davecgh/go-spew v1.1.1 // indirect 45 | github.com/evanphx/json-patch v4.12.0+incompatible // indirect 46 | github.com/fatih/color v1.13.0 // indirect 47 | github.com/fsnotify/fsnotify v1.5.1 // indirect 48 | github.com/go-logr/logr v1.2.0 // indirect 49 | github.com/gogo/protobuf v1.3.2 // indirect 50 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 51 | github.com/golang/protobuf v1.5.2 // indirect 52 | github.com/google/gofuzz v1.1.0 // indirect 53 | github.com/google/uuid v1.1.2 // indirect 54 | github.com/googleapis/gax-go/v2 v2.0.5 // indirect 55 | github.com/googleapis/gnostic v0.5.5 // indirect 56 | github.com/hashicorp/errwrap v1.1.0 // indirect 57 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 58 | github.com/hashicorp/go-cty v1.4.1-0.20200414143053-d3edf31b6320 // indirect 59 | github.com/hashicorp/go-getter v1.5.3 // indirect 60 | github.com/hashicorp/go-hclog v1.2.1 // indirect 61 | github.com/hashicorp/go-multierror v1.1.1 // indirect 62 | github.com/hashicorp/go-safetemp v1.0.0 // indirect 63 | github.com/hashicorp/go-uuid v1.0.3 // indirect 64 | github.com/hashicorp/go-version v1.6.0 // indirect 65 | github.com/hashicorp/hcl/v2 v2.13.0 // indirect 66 | github.com/hashicorp/logutils v1.0.0 // indirect 67 | github.com/hashicorp/terraform-plugin-go v0.12.0 // indirect 68 | github.com/hashicorp/terraform-plugin-log v0.7.0 // indirect 69 | github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 // indirect 70 | github.com/huandu/xstrings v1.3.2 // indirect 71 | github.com/imdario/mergo v0.3.12 // indirect 72 | github.com/jmespath/go-jmespath v0.4.0 // indirect 73 | github.com/jstemmer/go-junit-report v0.9.1 // indirect 74 | github.com/klauspost/compress v1.11.2 // indirect 75 | github.com/mattn/go-colorable v0.1.12 // indirect 76 | github.com/mattn/go-isatty v0.0.14 // indirect 77 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect 78 | github.com/mitchellh/cli v1.1.2 // indirect 79 | github.com/mitchellh/copystructure v1.2.0 // indirect 80 | github.com/mitchellh/go-homedir v1.1.0 // indirect 81 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 82 | github.com/mitchellh/go-wordwrap v1.0.0 // indirect 83 | github.com/mitchellh/mapstructure v1.5.0 // indirect 84 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 85 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 86 | github.com/modern-go/reflect2 v1.0.2 // indirect 87 | github.com/posener/complete v1.2.1 // indirect 88 | github.com/prometheus/client_golang v1.11.0 // indirect 89 | github.com/prometheus/client_model v0.2.0 // indirect 90 | github.com/prometheus/common v0.28.0 // indirect 91 | github.com/prometheus/procfs v0.6.0 // indirect 92 | github.com/spf13/pflag v1.0.5 // indirect 93 | github.com/ulikunitz/xz v0.5.8 // indirect 94 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect 95 | github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect 96 | github.com/vmihailenco/tagparser v0.1.1 // indirect 97 | github.com/zclconf/go-cty-yaml v1.0.2 // indirect 98 | go.opencensus.io v0.23.0 // indirect 99 | golang.org/x/crypto v0.0.0-20220517005047-85d78b3ac167 // indirect 100 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 101 | golang.org/x/mod v0.4.2 // indirect 102 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2 // indirect 103 | golang.org/x/oauth2 v0.0.0-20210819190943-2bc19b11175f // indirect 104 | golang.org/x/sys v0.0.0-20220517195934-5e4e11fc645e // indirect 105 | golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b // indirect 106 | golang.org/x/text v0.3.7 // indirect 107 | golang.org/x/time v0.0.0-20210723032227-1f47c861a9ac // indirect 108 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 // indirect 109 | gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect 110 | google.golang.org/api v0.44.0 // indirect 111 | google.golang.org/appengine v1.6.7 // indirect 112 | google.golang.org/genproto v0.0.0-20210831024726-fe130286e0e2 // indirect 113 | google.golang.org/grpc v1.48.0 // indirect 114 | google.golang.org/protobuf v1.28.0 // indirect 115 | gopkg.in/inf.v0 v0.9.1 // indirect 116 | gopkg.in/yaml.v2 v2.4.0 // indirect 117 | gopkg.in/yaml.v3 v3.0.1 // indirect 118 | k8s.io/client-go v0.23.0 // indirect 119 | k8s.io/component-base v0.23.0 // indirect 120 | k8s.io/klog/v2 v2.30.0 // indirect 121 | k8s.io/kube-openapi v0.0.0-20211115234752-e816edb12b65 // indirect 122 | sigs.k8s.io/json v0.0.0-20211020170558-c049b76a60c6 // indirect 123 | sigs.k8s.io/structured-merge-diff/v4 v4.2.0 // indirect 124 | sigs.k8s.io/yaml v1.3.0 // indirect 125 | ) 126 | 127 | // This is a temporary workaround until https://github.com/crossplane/terrajet/issues/131 128 | // is resolved. We basically need this just to be able to import both v1 and v2 129 | // versions of terraform plugin sdk in order to do a schema conversion for 130 | // Terraform providers still using v1 sdk. 131 | replace github.com/hashicorp/terraform-plugin-sdk => github.com/turkenh/terraform-plugin-sdk v1.17.2-patch1 132 | -------------------------------------------------------------------------------- /hack/boilerplate.txt: -------------------------------------------------------------------------------- 1 | Copyright 2021 The Crossplane Authors. 2 | 3 | Licensed under the Apache License, Version 2.0 (the "License"); 4 | you may not use this file except in compliance with the License. 5 | You may obtain a copy of the License at 6 | 7 | http://www.apache.org/licenses/LICENSE-2.0 8 | 9 | Unless required by applicable law or agreed to in writing, software 10 | distributed under the License is distributed on an "AS IS" BASIS, 11 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | See the License for the specific language governing permissions and 13 | limitations under the License. -------------------------------------------------------------------------------- /pkg/config/defaults.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 config 18 | 19 | import ( 20 | "strings" 21 | 22 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 23 | 24 | tjname "github.com/crossplane/terrajet/pkg/types/name" 25 | ) 26 | 27 | // Commonly used resource configurations. 28 | var ( 29 | // NameAsIdentifier uses "name" field in the arguments as the identifier of 30 | // the resource. 31 | NameAsIdentifier = ExternalName{ 32 | SetIdentifierArgumentFn: func(base map[string]interface{}, name string) { 33 | base["name"] = name 34 | }, 35 | GetExternalNameFn: IDAsExternalName, 36 | GetIDFn: ExternalNameAsID, 37 | OmittedFields: []string{ 38 | "name", 39 | "name_prefix", 40 | }, 41 | } 42 | 43 | // IdentifierFromProvider is used in resources whose identifier is assigned by 44 | // the remote client, such as AWS VPC where it gets an identifier like 45 | // vpc-2213das instead of letting user choose a name. 46 | IdentifierFromProvider = ExternalName{ 47 | SetIdentifierArgumentFn: NopSetIdentifierArgument, 48 | GetExternalNameFn: IDAsExternalName, 49 | GetIDFn: ExternalNameAsID, 50 | DisableNameInitializer: true, 51 | } 52 | 53 | DefaultBasePackages = BasePackages{ 54 | APIVersion: []string{ 55 | // Default package for ProviderConfig APIs 56 | "apis/v1alpha1", 57 | }, 58 | Controller: []string{ 59 | // Default package for ProviderConfig controllers 60 | "internal/controller/providerconfig", 61 | }, 62 | } 63 | 64 | // NopSensitive does nothing. 65 | NopSensitive = Sensitive{ 66 | AdditionalConnectionDetailsFn: NopAdditionalConnectionDetails, 67 | } 68 | ) 69 | 70 | // ResourceOption allows setting optional fields of a Resource object. 71 | type ResourceOption func(*Resource) 72 | 73 | // DefaultResource keeps an initial default configuration for all resources of a 74 | // provider. 75 | func DefaultResource(name string, terraformSchema *schema.Resource, opts ...ResourceOption) *Resource { 76 | words := strings.Split(name, "_") 77 | // As group name we default to the second element if resource name 78 | // has at least 3 elements, otherwise, we took the first element as 79 | // default group name, examples: 80 | // - aws_rds_cluster => rds 81 | // - aws_rds_cluster_parameter_group => rds 82 | // - kafka_topic => kafka 83 | group := words[1] 84 | // As kind, we default to camel case version of what is left after dropping 85 | // elements before what is selected as group: 86 | // - aws_rds_cluster => Cluster 87 | // - aws_rds_cluster_parameter_group => ClusterParameterGroup 88 | // - kafka_topic => Topic 89 | kind := tjname.NewFromSnake(strings.Join(words[2:], "_")).Camel 90 | if len(words) < 3 { 91 | group = words[0] 92 | kind = tjname.NewFromSnake(words[1]).Camel 93 | } 94 | 95 | r := &Resource{ 96 | Name: name, 97 | TerraformResource: terraformSchema, 98 | ShortGroup: group, 99 | Kind: kind, 100 | Version: "v1alpha1", 101 | ExternalName: NameAsIdentifier, 102 | References: map[string]Reference{}, 103 | Sensitive: NopSensitive, 104 | } 105 | for _, f := range opts { 106 | f(r) 107 | } 108 | return r 109 | } 110 | -------------------------------------------------------------------------------- /pkg/config/defaults_test.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 config 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | "github.com/google/go-cmp/cmp/cmpopts" 24 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 25 | ) 26 | 27 | func TestDefaultResource(t *testing.T) { 28 | type args struct { 29 | name string 30 | sch *schema.Resource 31 | opts []ResourceOption 32 | } 33 | 34 | cases := map[string]struct { 35 | reason string 36 | args args 37 | want *Resource 38 | }{ 39 | "ThreeSectionsName": { 40 | reason: "It should return GVK properly for names with three sections", 41 | args: args{ 42 | name: "aws_ec2_instance", 43 | }, 44 | want: &Resource{ 45 | Name: "aws_ec2_instance", 46 | ShortGroup: "ec2", 47 | Kind: "Instance", 48 | Version: "v1alpha1", 49 | ExternalName: NameAsIdentifier, 50 | References: map[string]Reference{}, 51 | Sensitive: NopSensitive, 52 | }, 53 | }, 54 | "TwoSectionsName": { 55 | reason: "It should return GVK properly for names with three sections", 56 | args: args{ 57 | name: "aws_instance", 58 | }, 59 | want: &Resource{ 60 | Name: "aws_instance", 61 | ShortGroup: "aws", 62 | Kind: "Instance", 63 | Version: "v1alpha1", 64 | ExternalName: NameAsIdentifier, 65 | References: map[string]Reference{}, 66 | Sensitive: NopSensitive, 67 | }, 68 | }, 69 | "NameWithPrefixAcronym": { 70 | reason: "It should return prefix acronym in capital case", 71 | args: args{ 72 | name: "aws_db_sql_server", 73 | }, 74 | want: &Resource{ 75 | Name: "aws_db_sql_server", 76 | ShortGroup: "db", 77 | Kind: "SQLServer", 78 | Version: "v1alpha1", 79 | ExternalName: NameAsIdentifier, 80 | References: map[string]Reference{}, 81 | Sensitive: NopSensitive, 82 | }, 83 | }, 84 | "NameWithSuffixAcronym": { 85 | reason: "It should return suffix acronym in capital case", 86 | args: args{ 87 | name: "aws_db_server_id", 88 | }, 89 | want: &Resource{ 90 | Name: "aws_db_server_id", 91 | ShortGroup: "db", 92 | Kind: "ServerID", 93 | Version: "v1alpha1", 94 | ExternalName: NameAsIdentifier, 95 | References: map[string]Reference{}, 96 | Sensitive: NopSensitive, 97 | }, 98 | }, 99 | "NameWithMultipleAcronyms": { 100 | reason: "It should return both prefix & suffix acronyms in capital case", 101 | args: args{ 102 | name: "aws_db_sql_server_id", 103 | }, 104 | want: &Resource{ 105 | Name: "aws_db_sql_server_id", 106 | ShortGroup: "db", 107 | Kind: "SQLServerID", 108 | Version: "v1alpha1", 109 | ExternalName: NameAsIdentifier, 110 | References: map[string]Reference{}, 111 | Sensitive: NopSensitive, 112 | }, 113 | }, 114 | } 115 | 116 | // TODO(muvaf): Find a way to compare function pointers. 117 | ignoreUnexported := []cmp.Option{ 118 | cmpopts.IgnoreFields(Sensitive{}, "fieldPaths", "AdditionalConnectionDetailsFn"), 119 | cmpopts.IgnoreFields(LateInitializer{}, "ignoredCanonicalFieldPaths"), 120 | cmpopts.IgnoreFields(ExternalName{}, "SetIdentifierArgumentFn", "GetExternalNameFn", "GetIDFn"), 121 | } 122 | 123 | for name, tc := range cases { 124 | t.Run(name, func(t *testing.T) { 125 | r := DefaultResource(tc.args.name, tc.args.sch, tc.args.opts...) 126 | if diff := cmp.Diff(tc.want, r, ignoreUnexported...); diff != "" { 127 | t.Errorf("\n%s\nDefaultResource(...): -want, +got:\n%s", tc.reason, diff) 128 | } 129 | }) 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /pkg/config/resource_test.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | "sigs.k8s.io/controller-runtime/pkg/client" 10 | 11 | "github.com/crossplane/crossplane-runtime/pkg/errors" 12 | "github.com/crossplane/crossplane-runtime/pkg/fieldpath" 13 | xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 14 | "github.com/crossplane/crossplane-runtime/pkg/resource/fake" 15 | "github.com/crossplane/crossplane-runtime/pkg/test" 16 | ) 17 | 18 | const ( 19 | kind = "ACoolService" 20 | name = "example-service" 21 | provider = "ACoolProvider" 22 | ) 23 | 24 | func TestTagger_Initialize(t *testing.T) { 25 | errBoom := errors.New("boom") 26 | 27 | type args struct { 28 | mg xpresource.Managed 29 | kube client.Client 30 | } 31 | type want struct { 32 | err error 33 | } 34 | cases := map[string]struct { 35 | args 36 | want 37 | }{ 38 | "Successful": { 39 | args: args{ 40 | mg: &fake.Managed{}, 41 | kube: &test.MockClient{MockUpdate: test.NewMockUpdateFn(nil)}, 42 | }, 43 | want: want{ 44 | err: nil, 45 | }, 46 | }, 47 | "Failure": { 48 | args: args{ 49 | mg: &fake.Managed{}, 50 | kube: &test.MockClient{MockUpdate: test.NewMockUpdateFn(errBoom)}, 51 | }, 52 | want: want{ 53 | err: errBoom, 54 | }, 55 | }, 56 | } 57 | for n, tc := range cases { 58 | t.Run(n, func(t *testing.T) { 59 | tagger := NewTagger(tc.kube, "tags") 60 | gotErr := tagger.Initialize(context.TODO(), tc.mg) 61 | if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { 62 | t.Fatalf("generateTypeName(...): -want error, +got error: %s", diff) 63 | } 64 | }) 65 | } 66 | } 67 | 68 | func TestSetExternalTagsWithPaved(t *testing.T) { 69 | type args struct { 70 | externalTags map[string]string 71 | paved *fieldpath.Paved 72 | fieldName string 73 | } 74 | type want struct { 75 | pavedString string 76 | err error 77 | } 78 | cases := map[string]struct { 79 | args 80 | want 81 | }{ 82 | "Successful": { 83 | args: args{ 84 | externalTags: map[string]string{ 85 | xpresource.ExternalResourceTagKeyKind: kind, 86 | xpresource.ExternalResourceTagKeyName: name, 87 | xpresource.ExternalResourceTagKeyProvider: provider, 88 | }, 89 | paved: fieldpath.Pave(map[string]interface{}{}), 90 | fieldName: "tags", 91 | }, 92 | want: want{ 93 | pavedString: fmt.Sprintf(`{"spec":{"forProvider":{"tags":{"%s":"%s","%s":"%s","%s":"%s"}}}}`, 94 | xpresource.ExternalResourceTagKeyKind, kind, 95 | xpresource.ExternalResourceTagKeyName, name, 96 | xpresource.ExternalResourceTagKeyProvider, provider), 97 | }, 98 | }, 99 | } 100 | for n, tc := range cases { 101 | t.Run(n, func(t *testing.T) { 102 | gotByte, gotErr := setExternalTagsWithPaved(tc.externalTags, tc.paved, tc.fieldName) 103 | if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { 104 | t.Fatalf("generateTypeName(...): -want error, +got error: %s", diff) 105 | } 106 | if diff := cmp.Diff(tc.want.pavedString, string(gotByte), test.EquateErrors()); diff != "" { 107 | t.Fatalf("generateTypeName(...): -want gotByte, +got gotByte: %s", diff) 108 | } 109 | }) 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /pkg/controller/api.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 controller 18 | 19 | import ( 20 | "context" 21 | 22 | xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 23 | xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 24 | "github.com/pkg/errors" 25 | v1 "k8s.io/api/core/v1" 26 | "k8s.io/apimachinery/pkg/runtime/schema" 27 | "k8s.io/apimachinery/pkg/types" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | ctrl "sigs.k8s.io/controller-runtime/pkg/manager" 30 | 31 | "github.com/crossplane/terrajet/pkg/resource" 32 | "github.com/crossplane/terrajet/pkg/terraform" 33 | ) 34 | 35 | const ( 36 | errGet = "cannot get resource" 37 | ) 38 | 39 | // APISecretClient is a client for getting k8s secrets 40 | type APISecretClient struct { 41 | kube client.Client 42 | } 43 | 44 | // GetSecretData gets and returns data for the referenced secret 45 | func (a *APISecretClient) GetSecretData(ctx context.Context, ref *xpv1.SecretReference) (map[string][]byte, error) { 46 | secret := &v1.Secret{} 47 | if err := a.kube.Get(ctx, types.NamespacedName{Namespace: ref.Namespace, Name: ref.Name}, secret); err != nil { 48 | return nil, err 49 | } 50 | return secret.Data, nil 51 | } 52 | 53 | // GetSecretValue gets and returns value for key of the referenced secret 54 | func (a *APISecretClient) GetSecretValue(ctx context.Context, sel xpv1.SecretKeySelector) ([]byte, error) { 55 | d, err := a.GetSecretData(ctx, &sel.SecretReference) 56 | if err != nil { 57 | return nil, errors.Wrap(err, "cannot get secret data") 58 | } 59 | return d[sel.Key], err 60 | } 61 | 62 | // NewAPICallbacks returns a new APICallbacks. 63 | func NewAPICallbacks(m ctrl.Manager, of xpresource.ManagedKind) *APICallbacks { 64 | nt := func() resource.Terraformed { 65 | return xpresource.MustCreateObject(schema.GroupVersionKind(of), m.GetScheme()).(resource.Terraformed) 66 | } 67 | return &APICallbacks{ 68 | kube: m.GetClient(), 69 | newTerraformed: nt, 70 | } 71 | } 72 | 73 | // APICallbacks providers callbacks that work on API resources. 74 | type APICallbacks struct { 75 | kube client.Client 76 | newTerraformed func() resource.Terraformed 77 | } 78 | 79 | // Apply makes sure the error is saved in async operation condition. 80 | func (ac *APICallbacks) Apply(name string) terraform.CallbackFn { 81 | return func(err error, ctx context.Context) error { 82 | nn := types.NamespacedName{Name: name} 83 | tr := ac.newTerraformed() 84 | if kErr := ac.kube.Get(ctx, nn, tr); kErr != nil { 85 | return errors.Wrap(kErr, errGet) 86 | } 87 | tr.SetConditions(resource.LastAsyncOperationCondition(err)) 88 | tr.SetConditions(resource.AsyncOperationFinishedCondition()) 89 | return errors.Wrap(ac.kube.Status().Update(ctx, tr), errStatusUpdate) 90 | } 91 | } 92 | 93 | // Destroy makes sure the error is saved in async operation condition. 94 | func (ac *APICallbacks) Destroy(name string) terraform.CallbackFn { 95 | return func(err error, ctx context.Context) error { 96 | nn := types.NamespacedName{Name: name} 97 | tr := ac.newTerraformed() 98 | if kErr := ac.kube.Get(ctx, nn, tr); kErr != nil { 99 | return errors.Wrap(kErr, errGet) 100 | } 101 | tr.SetConditions(resource.LastAsyncOperationCondition(err)) 102 | tr.SetConditions(resource.AsyncOperationFinishedCondition()) 103 | return errors.Wrap(ac.kube.Status().Update(ctx, tr), errStatusUpdate) 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /pkg/controller/api_test.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 controller 18 | 19 | import ( 20 | "context" 21 | "testing" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | "github.com/pkg/errors" 25 | "sigs.k8s.io/controller-runtime/pkg/client" 26 | ctrl "sigs.k8s.io/controller-runtime/pkg/manager" 27 | 28 | xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 29 | xpfake "github.com/crossplane/crossplane-runtime/pkg/resource/fake" 30 | "github.com/crossplane/crossplane-runtime/pkg/test" 31 | 32 | "github.com/crossplane/terrajet/pkg/resource" 33 | "github.com/crossplane/terrajet/pkg/resource/fake" 34 | tjerrors "github.com/crossplane/terrajet/pkg/terraform/errors" 35 | ) 36 | 37 | func TestAPICallbacks_Apply(t *testing.T) { 38 | type args struct { 39 | mgr ctrl.Manager 40 | mg xpresource.ManagedKind 41 | err error 42 | } 43 | type want struct { 44 | err error 45 | } 46 | cases := map[string]struct { 47 | reason string 48 | args 49 | want 50 | }{ 51 | "ApplyOperationFailed": { 52 | reason: "It should update the condition with error if async apply failed", 53 | args: args{ 54 | mg: xpresource.ManagedKind(xpfake.GVK(&fake.Terraformed{})), 55 | mgr: &xpfake.Manager{ 56 | Client: &test.MockClient{ 57 | MockGet: test.NewMockGetFn(nil), 58 | MockStatusUpdate: func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error { 59 | got := obj.(resource.Terraformed).GetCondition(resource.TypeLastAsyncOperation) 60 | if diff := cmp.Diff(resource.LastAsyncOperationCondition(tjerrors.NewApplyFailed(nil)), got); diff != "" { 61 | t.Errorf("\nApply(...): -want error, +got error:\n%s", diff) 62 | } 63 | return nil 64 | }, 65 | }, 66 | Scheme: xpfake.SchemeWith(&fake.Terraformed{}), 67 | }, 68 | err: tjerrors.NewApplyFailed(nil), 69 | }, 70 | }, 71 | "ApplyOperationSucceeded": { 72 | reason: "It should update the condition with success if the apply operation does not report error", 73 | args: args{ 74 | mg: xpresource.ManagedKind(xpfake.GVK(&fake.Terraformed{})), 75 | mgr: &xpfake.Manager{ 76 | Client: &test.MockClient{ 77 | MockGet: test.NewMockGetFn(nil), 78 | MockStatusUpdate: func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error { 79 | got := obj.(resource.Terraformed).GetCondition(resource.TypeLastAsyncOperation) 80 | if diff := cmp.Diff(resource.LastAsyncOperationCondition(nil), got); diff != "" { 81 | t.Errorf("\nApply(...): -want error, +got error:\n%s", diff) 82 | } 83 | return nil 84 | }, 85 | }, 86 | Scheme: xpfake.SchemeWith(&fake.Terraformed{}), 87 | }, 88 | }, 89 | }, 90 | "CannotGet": { 91 | reason: "It should return error if it cannot get the resource to update", 92 | args: args{ 93 | mg: xpresource.ManagedKind(xpfake.GVK(&fake.Terraformed{})), 94 | mgr: &xpfake.Manager{ 95 | Client: &test.MockClient{ 96 | MockGet: func(_ context.Context, _ client.ObjectKey, _ client.Object) error { 97 | return errBoom 98 | }, 99 | }, 100 | Scheme: xpfake.SchemeWith(&fake.Terraformed{}), 101 | }, 102 | }, 103 | want: want{ 104 | err: errors.Wrap(errBoom, errGet), 105 | }, 106 | }, 107 | } 108 | for name, tc := range cases { 109 | t.Run(name, func(t *testing.T) { 110 | e := NewAPICallbacks(tc.args.mgr, tc.args.mg) 111 | err := e.Apply("name")(tc.args.err, context.TODO()) 112 | if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 113 | t.Errorf("\n%s\nApply(...): -want error, +got error:\n%s", tc.reason, diff) 114 | } 115 | }) 116 | } 117 | } 118 | 119 | func TestAPICallbacks_Destroy(t *testing.T) { 120 | type args struct { 121 | mgr ctrl.Manager 122 | mg xpresource.ManagedKind 123 | err error 124 | } 125 | type want struct { 126 | err error 127 | } 128 | cases := map[string]struct { 129 | reason string 130 | args 131 | want 132 | }{ 133 | "DestroyOperationFailed": { 134 | reason: "It should update the condition with error if async destroy failed", 135 | args: args{ 136 | mg: xpresource.ManagedKind(xpfake.GVK(&fake.Terraformed{})), 137 | mgr: &xpfake.Manager{ 138 | Client: &test.MockClient{ 139 | MockGet: test.NewMockGetFn(nil), 140 | MockStatusUpdate: func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error { 141 | got := obj.(resource.Terraformed).GetCondition(resource.TypeLastAsyncOperation) 142 | if diff := cmp.Diff(resource.LastAsyncOperationCondition(tjerrors.NewDestroyFailed(nil)), got); diff != "" { 143 | t.Errorf("\nApply(...): -want error, +got error:\n%s", diff) 144 | } 145 | return nil 146 | }, 147 | }, 148 | Scheme: xpfake.SchemeWith(&fake.Terraformed{}), 149 | }, 150 | err: tjerrors.NewDestroyFailed(nil), 151 | }, 152 | }, 153 | "DestroyOperationSucceeded": { 154 | reason: "It should update the condition with success if the destroy operation does not report error", 155 | args: args{ 156 | mg: xpresource.ManagedKind(xpfake.GVK(&fake.Terraformed{})), 157 | mgr: &xpfake.Manager{ 158 | Client: &test.MockClient{ 159 | MockGet: test.NewMockGetFn(nil), 160 | MockStatusUpdate: func(_ context.Context, obj client.Object, _ ...client.UpdateOption) error { 161 | got := obj.(resource.Terraformed).GetCondition(resource.TypeLastAsyncOperation) 162 | if diff := cmp.Diff(resource.LastAsyncOperationCondition(nil), got); diff != "" { 163 | t.Errorf("\nApply(...): -want error, +got error:\n%s", diff) 164 | } 165 | return nil 166 | }, 167 | }, 168 | Scheme: xpfake.SchemeWith(&fake.Terraformed{}), 169 | }, 170 | }, 171 | }, 172 | "CannotGet": { 173 | reason: "It should return error if it cannot get the resource to update", 174 | args: args{ 175 | mg: xpresource.ManagedKind(xpfake.GVK(&fake.Terraformed{})), 176 | mgr: &xpfake.Manager{ 177 | Client: &test.MockClient{ 178 | MockGet: func(_ context.Context, _ client.ObjectKey, _ client.Object) error { 179 | return errBoom 180 | }, 181 | }, 182 | Scheme: xpfake.SchemeWith(&fake.Terraformed{}), 183 | }, 184 | }, 185 | want: want{ 186 | err: errors.Wrap(errBoom, errGet), 187 | }, 188 | }, 189 | } 190 | for name, tc := range cases { 191 | t.Run(name, func(t *testing.T) { 192 | e := NewAPICallbacks(tc.args.mgr, tc.args.mg) 193 | err := e.Destroy("name")(tc.args.err, context.TODO()) 194 | if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 195 | t.Errorf("\n%s\nDestroy(...): -want error, +got error:\n%s", tc.reason, diff) 196 | } 197 | }) 198 | } 199 | } 200 | -------------------------------------------------------------------------------- /pkg/controller/interfaces.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 controller 18 | 19 | import ( 20 | "context" 21 | 22 | "github.com/crossplane/terrajet/pkg/config" 23 | "github.com/crossplane/terrajet/pkg/resource" 24 | "github.com/crossplane/terrajet/pkg/terraform" 25 | ) 26 | 27 | // TODO(muvaf): It's a bit weird that the functions return the struct of a 28 | // specific implementation of this interface. Maybe a different package for the 29 | // returned result types? 30 | 31 | // Workspace is the set of methods that are needed for the controller to work. 32 | type Workspace interface { 33 | ApplyAsync(terraform.CallbackFn) error 34 | Apply(context.Context) (terraform.ApplyResult, error) 35 | DestroyAsync(terraform.CallbackFn) error 36 | Destroy(context.Context) error 37 | Refresh(context.Context) (terraform.RefreshResult, error) 38 | Plan(context.Context) (terraform.PlanResult, error) 39 | } 40 | 41 | // Store is where we can get access to the Terraform workspace of given resource. 42 | type Store interface { 43 | Workspace(ctx context.Context, c resource.SecretClient, tr resource.Terraformed, ts terraform.Setup, cfg *config.Resource) (*terraform.Workspace, error) 44 | } 45 | 46 | // CallbackProvider provides functions that can be called with the result of 47 | // async operations. 48 | type CallbackProvider interface { 49 | Apply(name string) terraform.CallbackFn 50 | Destroy(name string) terraform.CallbackFn 51 | } 52 | -------------------------------------------------------------------------------- /pkg/controller/options.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 | 17 | package controller 18 | 19 | import ( 20 | "github.com/crossplane/crossplane-runtime/pkg/controller" 21 | "k8s.io/apimachinery/pkg/runtime/schema" 22 | 23 | "github.com/crossplane/terrajet/pkg/config" 24 | "github.com/crossplane/terrajet/pkg/terraform" 25 | ) 26 | 27 | // Options contains incriminating options for a given Terrajet controller instance. 28 | type Options struct { 29 | controller.Options 30 | 31 | // Provider contains all resource configurations of the provider which can 32 | // be used to pick the related one. Since the selection is done in runtime, 33 | // we need to pass everything and generated code will pick the one. 34 | Provider *config.Provider 35 | 36 | // WorkspaceStore will be used to pick/initialize the workspace the specific CR 37 | // instance should use. 38 | WorkspaceStore *terraform.WorkspaceStore 39 | 40 | // SetupFn contains the provider-specific initialization logic, such as 41 | // preparing the auth token for Terraform CLI. 42 | SetupFn terraform.SetupFn 43 | 44 | // SecretStoreConfigGVK is the GroupVersionKind for the Secret StoreConfig 45 | // resource. Setting this enables External Secret Stores for the controller 46 | // by adding connection.DetailsManager as a ConnectionPublisher. 47 | SecretStoreConfigGVK *schema.GroupVersionKind 48 | } 49 | -------------------------------------------------------------------------------- /pkg/generate.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Crossplane Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // +build generate 16 | 17 | package pkg 18 | 19 | // NOTE(muvaf): We import the tools used un go:generate targets so that we can 20 | // track their versions using go.mod and let Go handle its installation. See 21 | // the following link for details: https://github.com/golang/go/wiki/Modules#how-can-i-track-tool-dependencies-for-a-module 22 | 23 | import ( 24 | _ "github.com/golang/mock/mockgen" //nolint:typecheck 25 | ) 26 | -------------------------------------------------------------------------------- /pkg/pipeline/controller.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 pipeline 18 | 19 | import ( 20 | "os" 21 | "path/filepath" 22 | "strings" 23 | 24 | "github.com/muvaf/typewriter/pkg/wrapper" 25 | "github.com/pkg/errors" 26 | 27 | "github.com/crossplane/terrajet/pkg/config" 28 | "github.com/crossplane/terrajet/pkg/pipeline/templates" 29 | ) 30 | 31 | // NewControllerGenerator returns a new ControllerGenerator. 32 | func NewControllerGenerator(rootDir, modulePath, group string) *ControllerGenerator { 33 | return &ControllerGenerator{ 34 | Group: group, 35 | ControllerGroupDir: filepath.Join(rootDir, "internal", "controller", strings.Split(group, ".")[0]), 36 | ModulePath: modulePath, 37 | LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), 38 | } 39 | } 40 | 41 | // ControllerGenerator generates controller setup functions. 42 | type ControllerGenerator struct { 43 | Group string 44 | ControllerGroupDir string 45 | ModulePath string 46 | LicenseHeaderPath string 47 | } 48 | 49 | // Generate writes controller setup functions. 50 | func (cg *ControllerGenerator) Generate(cfg *config.Resource, typesPkgPath string) (pkgPath string, err error) { 51 | controllerPkgPath := filepath.Join(cg.ModulePath, "internal", "controller", strings.ToLower(strings.Split(cg.Group, ".")[0]), strings.ToLower(cfg.Kind)) 52 | ctrlFile := wrapper.NewFile(controllerPkgPath, strings.ToLower(cfg.Kind), templates.ControllerTemplate, 53 | wrapper.WithGenStatement(GenStatement), 54 | wrapper.WithHeaderPath(cg.LicenseHeaderPath), 55 | ) 56 | 57 | vars := map[string]interface{}{ 58 | "Package": strings.ToLower(cfg.Kind), 59 | "CRD": map[string]string{ 60 | "Kind": cfg.Kind, 61 | }, 62 | "DisableNameInitializer": cfg.ExternalName.DisableNameInitializer, 63 | "TypePackageAlias": ctrlFile.Imports.UsePackage(typesPkgPath), 64 | "UseAsync": cfg.UseAsync, 65 | "ResourceType": cfg.Name, 66 | "Initializers": cfg.InitializerFns, 67 | } 68 | 69 | filePath := filepath.Join(cg.ControllerGroupDir, strings.ToLower(cfg.Kind), "zz_controller.go") 70 | return controllerPkgPath, errors.Wrap( 71 | ctrlFile.Write(filePath, vars, os.ModePerm), 72 | "cannot write controller file", 73 | ) 74 | } 75 | -------------------------------------------------------------------------------- /pkg/pipeline/crd.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 pipeline 18 | 19 | import ( 20 | "fmt" 21 | "go/types" 22 | "os" 23 | "path/filepath" 24 | "strings" 25 | 26 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 27 | twtypes "github.com/muvaf/typewriter/pkg/types" 28 | "github.com/muvaf/typewriter/pkg/wrapper" 29 | "github.com/pkg/errors" 30 | 31 | "github.com/crossplane/terrajet/pkg/config" 32 | "github.com/crossplane/terrajet/pkg/pipeline/templates" 33 | tjtypes "github.com/crossplane/terrajet/pkg/types" 34 | ) 35 | 36 | // GenStatement is printed on every generated file. 37 | const GenStatement = "// Code generated by terrajet. DO NOT EDIT." 38 | 39 | // NewCRDGenerator returns a new CRDGenerator. 40 | func NewCRDGenerator(pkg *types.Package, rootDir, providerShortName, group, version string) *CRDGenerator { 41 | return &CRDGenerator{ 42 | LocalDirectoryPath: filepath.Join(rootDir, "apis", strings.ToLower(strings.Split(group, ".")[0]), version), 43 | LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), 44 | Group: group, 45 | ProviderShortName: providerShortName, 46 | pkg: pkg, 47 | } 48 | } 49 | 50 | // CRDGenerator takes certain information referencing Terraform resource definition 51 | // and writes kubebuilder CRD file. 52 | type CRDGenerator struct { 53 | LocalDirectoryPath string 54 | Group string 55 | ProviderShortName string 56 | LicenseHeaderPath string 57 | 58 | pkg *types.Package 59 | } 60 | 61 | // Generate builds and writes a new CRD out of Terraform resource definition. 62 | func (cg *CRDGenerator) Generate(cfg *config.Resource) (string, error) { 63 | file := wrapper.NewFile(cg.pkg.Path(), cg.pkg.Name(), templates.CRDTypesTemplate, 64 | wrapper.WithGenStatement(GenStatement), 65 | wrapper.WithHeaderPath(cg.LicenseHeaderPath), 66 | ) 67 | for _, omit := range cfg.ExternalName.OmittedFields { 68 | delete(cfg.TerraformResource.Schema, omit) 69 | } 70 | 71 | cfg.TerraformResource.Schema["id"] = &schema.Schema{ 72 | Type: schema.TypeString, 73 | Computed: true, 74 | } 75 | 76 | gen, err := tjtypes.NewBuilder(cg.pkg).Build(cfg) 77 | if err != nil { 78 | return "", errors.Wrapf(err, "cannot build types for %s", cfg.Kind) 79 | } 80 | // TODO(muvaf): TypePrinter uses the given scope to see if the type exists 81 | // before printing. We should ideally load the package in file system but 82 | // loading the local package will result in error if there is 83 | // any compilation errors, which is the case before running kubebuilder 84 | // generators. For now, we act like the target package is empty. 85 | pkg := types.NewPackage(cg.pkg.Path(), cg.pkg.Name()) 86 | typePrinter := twtypes.NewPrinter(file.Imports, pkg.Scope(), twtypes.WithComments(gen.Comments)) 87 | typesStr, err := typePrinter.Print(gen.Types) 88 | if err != nil { 89 | return "", errors.Wrap(err, "cannot print the type list") 90 | } 91 | vars := map[string]interface{}{ 92 | "Types": typesStr, 93 | "CRD": map[string]string{ 94 | "APIVersion": cfg.Version, 95 | "Group": cg.Group, 96 | "Kind": cfg.Kind, 97 | "ForProviderType": gen.ForProviderType.Obj().Name(), 98 | "AtProviderType": gen.AtProviderType.Obj().Name(), 99 | }, 100 | "Provider": map[string]string{ 101 | "ShortName": cg.ProviderShortName, 102 | }, 103 | "XPCommonAPIsPackageAlias": file.Imports.UsePackage(tjtypes.PackagePathXPCommonAPIs), 104 | } 105 | filePath := filepath.Join(cg.LocalDirectoryPath, fmt.Sprintf("zz_%s_types.go", strings.ToLower(cfg.Kind))) 106 | return gen.ForProviderType.Obj().Name(), errors.Wrap(file.Write(filePath, vars, os.ModePerm), "cannot write crd file") 107 | } 108 | -------------------------------------------------------------------------------- /pkg/pipeline/register.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 pipeline 18 | 19 | import ( 20 | "os" 21 | "path/filepath" 22 | "sort" 23 | 24 | "github.com/muvaf/typewriter/pkg/wrapper" 25 | "github.com/pkg/errors" 26 | 27 | "github.com/crossplane/terrajet/pkg/pipeline/templates" 28 | ) 29 | 30 | // NewRegisterGenerator returns a new RegisterGenerator. 31 | func NewRegisterGenerator(rootDir, modulePath string) *RegisterGenerator { 32 | return &RegisterGenerator{ 33 | LocalDirectoryPath: filepath.Join(rootDir, "apis"), 34 | LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), 35 | ModulePath: modulePath, 36 | } 37 | } 38 | 39 | // RegisterGenerator generates scheme registration file. 40 | type RegisterGenerator struct { 41 | LocalDirectoryPath string 42 | ModulePath string 43 | LicenseHeaderPath string 44 | } 45 | 46 | // Generate writes the register file with the content produced using given 47 | // list of version packages. 48 | func (rg *RegisterGenerator) Generate(versionPkgList []string) error { 49 | registerFile := wrapper.NewFile(filepath.Join(rg.ModulePath, "apis"), "apis", templates.RegisterTemplate, 50 | wrapper.WithGenStatement(GenStatement), 51 | wrapper.WithHeaderPath(rg.LicenseHeaderPath), 52 | ) 53 | sort.Strings(versionPkgList) 54 | aliases := make([]string, len(versionPkgList)) 55 | for i, pkgPath := range versionPkgList { 56 | aliases[i] = registerFile.Imports.UsePackage(pkgPath) 57 | } 58 | vars := map[string]interface{}{ 59 | "Aliases": aliases, 60 | } 61 | filePath := filepath.Join(rg.LocalDirectoryPath, "zz_register.go") 62 | return errors.Wrap(registerFile.Write(filePath, vars, os.ModePerm), "cannot write register file") 63 | } 64 | -------------------------------------------------------------------------------- /pkg/pipeline/run.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 pipeline 18 | 19 | import ( 20 | "fmt" 21 | "os/exec" 22 | "path/filepath" 23 | "sort" 24 | "strings" 25 | 26 | "github.com/crossplane/terrajet/pkg/config" 27 | 28 | "github.com/crossplane/crossplane-runtime/pkg/errors" 29 | ) 30 | 31 | type terraformedInput struct { 32 | *config.Resource 33 | ParametersTypeName string 34 | } 35 | 36 | // Run runs the Terrajet code generation pipelines. 37 | func Run(pc *config.Provider, rootDir string) { // nolint:gocyclo 38 | // Note(turkenh): nolint reasoning - this is the main function of the code 39 | // generation pipeline. We didn't want to split it into multiple functions 40 | // for better readability considering the straightforward logic here. 41 | 42 | // Group resources based on their Group and API Versions. 43 | // An example entry in the tree would be: 44 | // ec2.awsjet.crossplane.io -> v1alpha1 -> aws_vpc 45 | resourcesGroups := map[string]map[string]map[string]*config.Resource{} 46 | for name, resource := range pc.Resources { 47 | group := pc.RootGroup 48 | if resource.ShortGroup != "" { 49 | group = strings.ToLower(resource.ShortGroup) + "." + pc.RootGroup 50 | } 51 | if len(resourcesGroups[group]) == 0 { 52 | resourcesGroups[group] = map[string]map[string]*config.Resource{} 53 | } 54 | if len(resourcesGroups[group][resource.Version]) == 0 { 55 | resourcesGroups[group][resource.Version] = map[string]*config.Resource{} 56 | } 57 | resourcesGroups[group][resource.Version][name] = resource 58 | } 59 | 60 | // Add ProviderConfig API package to the list of API version packages. 61 | apiVersionPkgList := make([]string, 0) 62 | for _, p := range pc.BasePackages.APIVersion { 63 | apiVersionPkgList = append(apiVersionPkgList, filepath.Join(pc.ModulePath, p)) 64 | } 65 | // Add ProviderConfig controller package to the list of controller packages. 66 | controllerPkgList := make([]string, 0) 67 | for _, p := range pc.BasePackages.Controller { 68 | controllerPkgList = append(controllerPkgList, filepath.Join(pc.ModulePath, p)) 69 | } 70 | count := 0 71 | for group, versions := range resourcesGroups { 72 | for version, resources := range versions { 73 | var tfResources []*terraformedInput 74 | versionGen := NewVersionGenerator(rootDir, pc.ModulePath, group, version) 75 | crdGen := NewCRDGenerator(versionGen.Package(), rootDir, pc.ShortName, group, version) 76 | tfGen := NewTerraformedGenerator(versionGen.Package(), rootDir, group, version) 77 | ctrlGen := NewControllerGenerator(rootDir, pc.ModulePath, group) 78 | 79 | for _, name := range sortedResources(resources) { 80 | paramTypeName, err := crdGen.Generate(resources[name]) 81 | if err != nil { 82 | panic(errors.Wrapf(err, "cannot generate crd for resource %s", name)) 83 | } 84 | tfResources = append(tfResources, &terraformedInput{ 85 | Resource: resources[name], 86 | ParametersTypeName: paramTypeName, 87 | }) 88 | ctrlPkgPath, err := ctrlGen.Generate(resources[name], versionGen.Package().Path()) 89 | if err != nil { 90 | panic(errors.Wrapf(err, "cannot generate controller for resource %s", name)) 91 | } 92 | controllerPkgList = append(controllerPkgList, ctrlPkgPath) 93 | count++ 94 | } 95 | 96 | if err := tfGen.Generate(tfResources, version); err != nil { 97 | panic(errors.Wrapf(err, "cannot generate terraformed for resource %s", group)) 98 | } 99 | 100 | if err := versionGen.Generate(); err != nil { 101 | panic(errors.Wrap(err, "cannot generate version files")) 102 | } 103 | apiVersionPkgList = append(apiVersionPkgList, versionGen.Package().Path()) 104 | } 105 | } 106 | 107 | if err := NewRegisterGenerator(rootDir, pc.ModulePath).Generate(apiVersionPkgList); err != nil { 108 | panic(errors.Wrap(err, "cannot generate register file")) 109 | } 110 | if err := NewSetupGenerator(rootDir, pc.ModulePath).Generate(controllerPkgList); err != nil { 111 | panic(errors.Wrap(err, "cannot generate setup file")) 112 | } 113 | 114 | // NOTE(muvaf): gosec linter requires that the whole command is hard-coded. 115 | // So, we set the directory of the command instead of passing in the directory 116 | // as an argument to "find". 117 | apisCmd := exec.Command("bash", "-c", "goimports -w $(find . -iname 'zz_*')") 118 | apisCmd.Dir = filepath.Clean(filepath.Join(rootDir, "apis")) 119 | if out, err := apisCmd.CombinedOutput(); err != nil { 120 | panic(errors.Wrap(err, "cannot run goimports for apis folder: "+string(out))) 121 | } 122 | 123 | internalCmd := exec.Command("bash", "-c", "goimports -w $(find . -iname 'zz_*')") 124 | internalCmd.Dir = filepath.Clean(filepath.Join(rootDir, "internal")) 125 | if out, err := internalCmd.CombinedOutput(); err != nil { 126 | panic(errors.Wrap(err, "cannot run goimports for internal folder: "+string(out))) 127 | } 128 | 129 | fmt.Printf("\nGenerated %d resources!\n", count) 130 | } 131 | 132 | func sortedResources(m map[string]*config.Resource) []string { 133 | result := make([]string, len(m)) 134 | i := 0 135 | for g := range m { 136 | result[i] = g 137 | i++ 138 | } 139 | sort.Strings(result) 140 | return result 141 | } 142 | -------------------------------------------------------------------------------- /pkg/pipeline/setup.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 pipeline 18 | 19 | import ( 20 | "os" 21 | "path/filepath" 22 | "sort" 23 | 24 | "github.com/muvaf/typewriter/pkg/wrapper" 25 | "github.com/pkg/errors" 26 | 27 | "github.com/crossplane/terrajet/pkg/pipeline/templates" 28 | ) 29 | 30 | // NewSetupGenerator returns a new SetupGenerator. 31 | func NewSetupGenerator(rootDir, modulePath string) *SetupGenerator { 32 | return &SetupGenerator{ 33 | LocalDirectoryPath: filepath.Join(rootDir, "internal", "controller"), 34 | LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), 35 | ModulePath: modulePath, 36 | } 37 | } 38 | 39 | // SetupGenerator generates controller setup file. 40 | type SetupGenerator struct { 41 | LocalDirectoryPath string 42 | LicenseHeaderPath string 43 | ModulePath string 44 | } 45 | 46 | // Generate writes the setup file with the content produced using given 47 | // list of version packages. 48 | func (sg *SetupGenerator) Generate(versionPkgList []string) error { 49 | setupFile := wrapper.NewFile(filepath.Join(sg.ModulePath, "apis"), "apis", templates.SetupTemplate, 50 | wrapper.WithGenStatement(GenStatement), 51 | wrapper.WithHeaderPath(sg.LicenseHeaderPath), 52 | ) 53 | sort.Strings(versionPkgList) 54 | aliases := make([]string, len(versionPkgList)) 55 | for i, pkgPath := range versionPkgList { 56 | aliases[i] = setupFile.Imports.UsePackage(pkgPath) 57 | } 58 | vars := map[string]interface{}{ 59 | "Aliases": aliases, 60 | } 61 | filePath := filepath.Join(sg.LocalDirectoryPath, "zz_setup.go") 62 | return errors.Wrap(setupFile.Write(filePath, vars, os.ModePerm), "cannot write setup file") 63 | } 64 | -------------------------------------------------------------------------------- /pkg/pipeline/templates/controller.go.tmpl: -------------------------------------------------------------------------------- 1 | {{ .Header }} 2 | 3 | {{ .GenStatement }} 4 | 5 | package {{ .Package }} 6 | 7 | import ( 8 | "time" 9 | 10 | "github.com/crossplane/crossplane-runtime/pkg/connection" 11 | "github.com/crossplane/crossplane-runtime/pkg/event" 12 | "github.com/crossplane/crossplane-runtime/pkg/ratelimiter" 13 | "github.com/crossplane/crossplane-runtime/pkg/reconciler/managed" 14 | xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 15 | tjcontroller "github.com/crossplane/terrajet/pkg/controller" 16 | "github.com/crossplane/terrajet/pkg/terraform" 17 | ctrl "sigs.k8s.io/controller-runtime" 18 | 19 | {{ .Imports }} 20 | ) 21 | 22 | // Setup adds a controller that reconciles {{ .CRD.Kind }} managed resources. 23 | func Setup(mgr ctrl.Manager, o tjcontroller.Options) error { 24 | name := managed.ControllerName({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind.String()) 25 | var initializers managed.InitializerChain 26 | {{- if .Initializers }} 27 | for _, i := range o.Provider.Resources["{{ .ResourceType }}"].InitializerFns { 28 | initializers = append(initializers,i(mgr.GetClient())) 29 | } 30 | {{- end}} 31 | {{- if not .DisableNameInitializer }} 32 | initializers = append(initializers, managed.NewNameAsExternalName(mgr.GetClient())) 33 | {{- end}} 34 | cps := []managed.ConnectionPublisher{managed.NewAPISecretPublisher(mgr.GetClient(), mgr.GetScheme())} 35 | if o.SecretStoreConfigGVK != nil { 36 | cps = append(cps, connection.NewDetailsManager(mgr.GetClient(), *o.SecretStoreConfigGVK)) 37 | } 38 | r := managed.NewReconciler(mgr, 39 | xpresource.ManagedKind({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind), 40 | managed.WithExternalConnecter(tjcontroller.NewConnector(mgr.GetClient(), o.WorkspaceStore, o.SetupFn, o.Provider.Resources["{{ .ResourceType }}"], 41 | {{- if .UseAsync }} 42 | tjcontroller.WithCallbackProvider(tjcontroller.NewAPICallbacks(mgr, xpresource.ManagedKind({{ .TypePackageAlias }}{{ .CRD.Kind }}_GroupVersionKind))), 43 | {{- end}} 44 | )), 45 | managed.WithLogger(o.Logger.WithValues("controller", name)), 46 | managed.WithRecorder(event.NewAPIRecorder(mgr.GetEventRecorderFor(name))), 47 | managed.WithFinalizer(terraform.NewWorkspaceFinalizer(o.WorkspaceStore, xpresource.NewAPIFinalizer(mgr.GetClient(), managed.FinalizerName))), 48 | managed.WithTimeout(3*time.Minute), 49 | managed.WithInitializers(initializers), 50 | managed.WithConnectionPublishers(cps...), 51 | managed.WithPollInterval(o.PollInterval), 52 | ) 53 | 54 | return ctrl.NewControllerManagedBy(mgr). 55 | Named(name). 56 | WithOptions(o.ForControllerRuntime()). 57 | For(&{{ .TypePackageAlias }}{{ .CRD.Kind }}{}). 58 | Complete(ratelimiter.NewReconciler(name, r, o.GlobalRateLimiter)) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/pipeline/templates/crd_types.go.tmpl: -------------------------------------------------------------------------------- 1 | {{ .Header }} 2 | 3 | {{ .GenStatement }} 4 | 5 | package {{ .CRD.APIVersion }} 6 | 7 | import ( 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/runtime/schema" 10 | 11 | {{ .Imports }} 12 | ) 13 | 14 | {{ .Types }} 15 | 16 | // {{ .CRD.Kind }}Spec defines the desired state of {{ .CRD.Kind }} 17 | type {{ .CRD.Kind }}Spec struct { 18 | {{ .XPCommonAPIsPackageAlias }}ResourceSpec `json:",inline"` 19 | ForProvider {{ .CRD.ForProviderType }} `json:"forProvider"` 20 | } 21 | 22 | // {{ .CRD.Kind }}Status defines the observed state of {{ .CRD.Kind }}. 23 | type {{ .CRD.Kind }}Status struct { 24 | {{ .XPCommonAPIsPackageAlias }}ResourceStatus `json:",inline"` 25 | AtProvider {{ .CRD.AtProviderType }} `json:"atProvider,omitempty"` 26 | } 27 | 28 | // +kubebuilder:object:root=true 29 | 30 | // {{ .CRD.Kind }} is the Schema for the {{ .CRD.Kind }}s API 31 | // +kubebuilder:printcolumn:name="READY",type="string",JSONPath=".status.conditions[?(@.type=='Ready')].status" 32 | // +kubebuilder:printcolumn:name="SYNCED",type="string",JSONPath=".status.conditions[?(@.type=='Synced')].status" 33 | // +kubebuilder:printcolumn:name="EXTERNAL-NAME",type="string",JSONPath=".metadata.annotations.crossplane\\.io/external-name" 34 | // +kubebuilder:printcolumn:name="AGE",type="date",JSONPath=".metadata.creationTimestamp" 35 | // +kubebuilder:subresource:status 36 | // +kubebuilder:resource:scope=Cluster,categories={crossplane,managed,{{ .Provider.ShortName }}} 37 | type {{ .CRD.Kind }} struct { 38 | metav1.TypeMeta `json:",inline"` 39 | metav1.ObjectMeta `json:"metadata,omitempty"` 40 | Spec {{ .CRD.Kind }}Spec `json:"spec"` 41 | Status {{ .CRD.Kind }}Status `json:"status,omitempty"` 42 | } 43 | 44 | // +kubebuilder:object:root=true 45 | 46 | // {{ .CRD.Kind }}List contains a list of {{ .CRD.Kind }}s 47 | type {{ .CRD.Kind }}List struct { 48 | metav1.TypeMeta `json:",inline"` 49 | metav1.ListMeta `json:"metadata,omitempty"` 50 | Items []{{ .CRD.Kind }} `json:"items"` 51 | } 52 | 53 | // Repository type metadata. 54 | var ( 55 | {{ .CRD.Kind }}_Kind = "{{ .CRD.Kind }}" 56 | {{ .CRD.Kind }}_GroupKind = schema.GroupKind{Group: CRDGroup, Kind: {{ .CRD.Kind }}_Kind}.String() 57 | {{ .CRD.Kind }}_KindAPIVersion = {{ .CRD.Kind }}_Kind + "." + CRDGroupVersion.String() 58 | {{ .CRD.Kind }}_GroupVersionKind = CRDGroupVersion.WithKind({{ .CRD.Kind }}_Kind) 59 | ) 60 | 61 | func init() { 62 | SchemeBuilder.Register(&{{ .CRD.Kind }}{}, &{{ .CRD.Kind }}List{}) 63 | } 64 | -------------------------------------------------------------------------------- /pkg/pipeline/templates/embed.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 templates 18 | 19 | import _ "embed" // nolint:golint 20 | 21 | // CRDTypesTemplate is populated with CRD and type information. 22 | //go:embed crd_types.go.tmpl 23 | var CRDTypesTemplate string 24 | 25 | // GroupVersionInfoTemplate is populated with group and version information. 26 | //go:embed groupversion_info.go.tmpl 27 | var GroupVersionInfoTemplate string 28 | 29 | // TerraformedTemplate is populated with conversion methods implementing 30 | // Terraformed interface on CRD structs. 31 | //go:embed terraformed.go.tmpl 32 | var TerraformedTemplate string 33 | 34 | // ControllerTemplate is populated with controller setup functions. 35 | //go:embed controller.go.tmpl 36 | var ControllerTemplate string 37 | 38 | // RegisterTemplate is populated with scheme registration calls. 39 | //go:embed register.go.tmpl 40 | var RegisterTemplate string 41 | 42 | // SetupTemplate is populated with controller setup calls. 43 | //go:embed setup.go.tmpl 44 | var SetupTemplate string 45 | -------------------------------------------------------------------------------- /pkg/pipeline/templates/groupversion_info.go.tmpl: -------------------------------------------------------------------------------- 1 | {{ .Header }} 2 | 3 | {{ .GenStatement }} 4 | 5 | // +kubebuilder:object:generate=true 6 | // +groupName={{ .CRD.Group }} 7 | // +versionName={{ .CRD.Version }} 8 | package {{ .CRD.Version }} 9 | 10 | import ( 11 | "k8s.io/apimachinery/pkg/runtime/schema" 12 | "sigs.k8s.io/controller-runtime/pkg/scheme" 13 | ) 14 | 15 | // Package type metadata. 16 | const ( 17 | CRDGroup = "{{ .CRD.Group }}" 18 | CRDVersion = "{{ .CRD.Version }}" 19 | ) 20 | 21 | var ( 22 | // CRDGroupVersion is the API Group Version used to register the objects 23 | CRDGroupVersion = schema.GroupVersion{Group: CRDGroup, Version: CRDVersion} 24 | 25 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 26 | SchemeBuilder = &scheme.Builder{GroupVersion: CRDGroupVersion} 27 | 28 | // AddToScheme adds the types in this group-version to the given scheme. 29 | AddToScheme = SchemeBuilder.AddToScheme 30 | ) 31 | -------------------------------------------------------------------------------- /pkg/pipeline/templates/register.go.tmpl: -------------------------------------------------------------------------------- 1 | {{ .Header }} 2 | 3 | {{ .GenStatement }} 4 | 5 | // Package apis contains Kubernetes API for the provider. 6 | package apis 7 | 8 | import ( 9 | "k8s.io/apimachinery/pkg/runtime" 10 | 11 | {{ .Imports }} 12 | ) 13 | 14 | func init() { 15 | // Register the types with the Scheme so the components can map objects to GroupVersionKinds and back 16 | AddToSchemes = append(AddToSchemes, 17 | {{- range $alias := .Aliases }} 18 | {{ $alias }}SchemeBuilder.AddToScheme, 19 | {{- end }} 20 | ) 21 | } 22 | 23 | // AddToSchemes may be used to add all resources defined in the project to a Scheme 24 | var AddToSchemes runtime.SchemeBuilder 25 | 26 | // AddToScheme adds all Resources to the Scheme 27 | func AddToScheme(s *runtime.Scheme) error { 28 | return AddToSchemes.AddToScheme(s) 29 | } 30 | -------------------------------------------------------------------------------- /pkg/pipeline/templates/setup.go.tmpl: -------------------------------------------------------------------------------- 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 controller 18 | 19 | import ( 20 | ctrl "sigs.k8s.io/controller-runtime" 21 | 22 | "github.com/crossplane/terrajet/pkg/controller" 23 | 24 | {{ .Imports }} 25 | ) 26 | 27 | // Setup creates all controllers with the supplied logger and adds them to 28 | // the supplied manager. 29 | func Setup(mgr ctrl.Manager, o controller.Options) error { 30 | for _, setup := range []func(ctrl.Manager, controller.Options) error{ 31 | {{- range $alias := .Aliases }} 32 | {{ $alias }}Setup, 33 | {{- end }} 34 | } { 35 | if err := setup(mgr, o); err != nil { 36 | return err 37 | } 38 | } 39 | return nil 40 | } 41 | -------------------------------------------------------------------------------- /pkg/pipeline/templates/terraformed.go.tmpl: -------------------------------------------------------------------------------- 1 | {{ .Header }} 2 | 3 | {{ .GenStatement }} 4 | 5 | package {{ .APIVersion }} 6 | 7 | import ( 8 | "github.com/pkg/errors" 9 | 10 | "github.com/crossplane/terrajet/pkg/resource" 11 | "github.com/crossplane/terrajet/pkg/resource/json" 12 | {{ .Imports }} 13 | ) 14 | {{ range .Resources }} 15 | // GetTerraformResourceType returns Terraform resource type for this {{ .CRD.Kind }} 16 | func (mg *{{ .CRD.Kind }}) GetTerraformResourceType() string { 17 | return "{{ .Terraform.ResourceType }}" 18 | } 19 | 20 | // GetConnectionDetailsMapping for this {{ .CRD.Kind }} 21 | func (tr *{{ .CRD.Kind }}) GetConnectionDetailsMapping() map[string]string { 22 | {{- if .Sensitive.Fields }} 23 | return map[string]string{ {{range $k, $v := .Sensitive.Fields}}"{{ $k }}": "{{ $v}}", {{end}} } 24 | {{- else }} 25 | return nil 26 | {{- end }} 27 | } 28 | 29 | // GetObservation of this {{ .CRD.Kind }} 30 | func (tr *{{ .CRD.Kind }}) GetObservation() (map[string]interface{}, error) { 31 | o, err := json.TFParser.Marshal(tr.Status.AtProvider) 32 | if err != nil { 33 | return nil, err 34 | } 35 | base := map[string]interface{}{} 36 | return base, json.TFParser.Unmarshal(o, &base) 37 | } 38 | 39 | // SetObservation for this {{ .CRD.Kind }} 40 | func (tr *{{ .CRD.Kind }}) SetObservation(obs map[string]interface{}) error { 41 | p, err := json.TFParser.Marshal(obs) 42 | if err != nil { 43 | return err 44 | } 45 | return json.TFParser.Unmarshal(p, &tr.Status.AtProvider) 46 | } 47 | 48 | // GetID returns ID of underlying Terraform resource of this {{ .CRD.Kind }} 49 | func (tr *{{ .CRD.Kind }}) GetID() string { 50 | if tr.Status.AtProvider.ID == nil { 51 | return "" 52 | } 53 | return *tr.Status.AtProvider.ID 54 | } 55 | 56 | // GetParameters of this {{ .CRD.Kind }} 57 | func (tr *{{ .CRD.Kind }}) GetParameters() (map[string]interface{}, error) { 58 | p, err := json.TFParser.Marshal(tr.Spec.ForProvider) 59 | if err != nil { 60 | return nil, err 61 | } 62 | base := map[string]interface{}{} 63 | return base, json.TFParser.Unmarshal(p, &base) 64 | } 65 | 66 | // SetParameters for this {{ .CRD.Kind }} 67 | func (tr *{{ .CRD.Kind }}) SetParameters(params map[string]interface{}) error { 68 | p, err := json.TFParser.Marshal(params) 69 | if err != nil { 70 | return err 71 | } 72 | return json.TFParser.Unmarshal(p, &tr.Spec.ForProvider) 73 | } 74 | 75 | // LateInitialize this {{ .CRD.Kind }} using its observed tfState. 76 | // returns True if there are any spec changes for the resource. 77 | func (tr *{{ .CRD.Kind }}) LateInitialize(attrs []byte) (bool, error) { 78 | params := &{{ .CRD.ParametersTypeName }}{} 79 | if err := json.TFParser.Unmarshal(attrs, params); err != nil { 80 | return false, errors.Wrap(err, "failed to unmarshal Terraform state parameters for late-initialization") 81 | } 82 | opts := []resource.GenericLateInitializerOption{resource.WithZeroValueJSONOmitEmptyFilter(resource.CNameWildcard)} 83 | {{ range .LateInitializer.IgnoredFields -}} 84 | opts = append(opts, resource.WithNameFilter("{{ . }}")) 85 | {{ end }} 86 | 87 | li := resource.NewGenericLateInitializer(opts...) 88 | return li.LateInitialize(&tr.Spec.ForProvider, params) 89 | } 90 | 91 | // GetTerraformSchemaVersion returns the associated Terraform schema version 92 | func (tr *{{ .CRD.Kind }}) GetTerraformSchemaVersion() int { 93 | return {{ .Terraform.SchemaVersion }} 94 | } 95 | {{ end }} 96 | -------------------------------------------------------------------------------- /pkg/pipeline/terraformed.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 pipeline 18 | 19 | import ( 20 | "go/types" 21 | "os" 22 | "path/filepath" 23 | "strings" 24 | 25 | "github.com/muvaf/typewriter/pkg/wrapper" 26 | "github.com/pkg/errors" 27 | 28 | "github.com/crossplane/terrajet/pkg/pipeline/templates" 29 | ) 30 | 31 | // NewTerraformedGenerator returns a new TerraformedGenerator. 32 | func NewTerraformedGenerator(pkg *types.Package, rootDir, group, version string) *TerraformedGenerator { 33 | return &TerraformedGenerator{ 34 | LocalDirectoryPath: filepath.Join(rootDir, "apis", strings.ToLower(strings.Split(group, ".")[0]), version), 35 | LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), 36 | pkg: pkg, 37 | } 38 | } 39 | 40 | // TerraformedGenerator generates conversion methods implementing Terraformed 41 | // interface on CRD structs. 42 | type TerraformedGenerator struct { 43 | LocalDirectoryPath string 44 | LicenseHeaderPath string 45 | 46 | pkg *types.Package 47 | } 48 | 49 | // Generate writes generated Terraformed interface functions 50 | func (tg *TerraformedGenerator) Generate(cfgs []*terraformedInput, apiVersion string) error { 51 | trFile := wrapper.NewFile(tg.pkg.Path(), tg.pkg.Name(), templates.TerraformedTemplate, 52 | wrapper.WithGenStatement(GenStatement), 53 | wrapper.WithHeaderPath(tg.LicenseHeaderPath), 54 | ) 55 | filePath := filepath.Join(tg.LocalDirectoryPath, "zz_generated_terraformed.go") 56 | vars := map[string]interface{}{ 57 | "APIVersion": apiVersion, 58 | } 59 | resources := make([]map[string]interface{}, len(cfgs)) 60 | index := 0 61 | for _, cfg := range cfgs { 62 | resources[index] = map[string]interface{}{ 63 | "CRD": map[string]string{ 64 | "Kind": cfg.Kind, 65 | "ParametersTypeName": cfg.ParametersTypeName, 66 | }, 67 | "Terraform": map[string]interface{}{ 68 | "ResourceType": cfg.Name, 69 | "SchemaVersion": cfg.TerraformResource.SchemaVersion, 70 | }, 71 | "Sensitive": map[string]interface{}{ 72 | "Fields": cfg.Sensitive.GetFieldPaths(), 73 | }, 74 | "LateInitializer": map[string]interface{}{ 75 | "IgnoredFields": cfg.LateInitializer.GetIgnoredCanonicalFields(), 76 | }, 77 | } 78 | index++ 79 | } 80 | vars["Resources"] = resources 81 | return errors.Wrap( 82 | trFile.Write(filePath, vars, os.ModePerm), 83 | "cannot write terraformed conversion methods file", 84 | ) 85 | } 86 | -------------------------------------------------------------------------------- /pkg/pipeline/version.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 pipeline 18 | 19 | import ( 20 | "go/types" 21 | "os" 22 | "path/filepath" 23 | "strings" 24 | 25 | "github.com/muvaf/typewriter/pkg/wrapper" 26 | "github.com/pkg/errors" 27 | 28 | "github.com/crossplane/terrajet/pkg/pipeline/templates" 29 | ) 30 | 31 | // NewVersionGenerator returns a new VersionGenerator. 32 | func NewVersionGenerator(rootDir, modulePath, group, version string) *VersionGenerator { 33 | pkgPath := filepath.Join(modulePath, "apis", strings.ToLower(strings.Split(group, ".")[0]), version) 34 | return &VersionGenerator{ 35 | Group: group, 36 | Version: version, 37 | DirectoryPath: filepath.Join(rootDir, "apis", strings.ToLower(strings.Split(group, ".")[0]), version), 38 | LicenseHeaderPath: filepath.Join(rootDir, "hack", "boilerplate.go.txt"), 39 | pkg: types.NewPackage(pkgPath, version), 40 | } 41 | } 42 | 43 | // VersionGenerator generates files for a version of a specific group. 44 | type VersionGenerator struct { 45 | Group string 46 | Version string 47 | DirectoryPath string 48 | LicenseHeaderPath string 49 | 50 | pkg *types.Package 51 | } 52 | 53 | // Generate writes doc and group version info files to the disk. 54 | func (vg *VersionGenerator) Generate() error { 55 | vars := map[string]interface{}{ 56 | "CRD": map[string]string{ 57 | "Version": vg.Version, 58 | "Group": vg.Group, 59 | }, 60 | } 61 | gviFile := wrapper.NewFile(vg.pkg.Path(), vg.Version, templates.GroupVersionInfoTemplate, 62 | wrapper.WithGenStatement(GenStatement), 63 | wrapper.WithHeaderPath(vg.LicenseHeaderPath), 64 | ) 65 | return errors.Wrap( 66 | gviFile.Write(filepath.Join(vg.DirectoryPath, "zz_groupversion_info.go"), vars, os.ModePerm), 67 | "cannot write group version info file", 68 | ) 69 | } 70 | 71 | // Package returns the package of the version that will be generated. 72 | func (vg *VersionGenerator) Package() *types.Package { 73 | return vg.pkg 74 | } 75 | -------------------------------------------------------------------------------- /pkg/resource/conditions.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 18 | 19 | import ( 20 | xpv1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 21 | corev1 "k8s.io/api/core/v1" 22 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 23 | 24 | tferrors "github.com/crossplane/terrajet/pkg/terraform/errors" 25 | ) 26 | 27 | // Condition constants. 28 | const ( 29 | TypeLastAsyncOperation = "LastAsyncOperation" 30 | TypeAsyncOperation = "AsyncOperation" 31 | 32 | ReasonApplyFailure xpv1.ConditionReason = "ApplyFailure" 33 | ReasonDestroyFailure xpv1.ConditionReason = "DestroyFailure" 34 | ReasonSuccess xpv1.ConditionReason = "Success" 35 | ReasonOngoing xpv1.ConditionReason = "Ongoing" 36 | ReasonFinished xpv1.ConditionReason = "Finished" 37 | ) 38 | 39 | // LastAsyncOperationCondition returns the condition depending on the content 40 | // of the error. 41 | func LastAsyncOperationCondition(err error) xpv1.Condition { 42 | switch { 43 | case err == nil: 44 | return xpv1.Condition{ 45 | Type: TypeLastAsyncOperation, 46 | Status: corev1.ConditionTrue, 47 | LastTransitionTime: metav1.Now(), 48 | Reason: ReasonSuccess, 49 | } 50 | case tferrors.IsApplyFailed(err): 51 | return xpv1.Condition{ 52 | Type: TypeLastAsyncOperation, 53 | Status: corev1.ConditionFalse, 54 | LastTransitionTime: metav1.Now(), 55 | Reason: ReasonApplyFailure, 56 | Message: err.Error(), 57 | } 58 | case tferrors.IsDestroyFailed(err): 59 | return xpv1.Condition{ 60 | Type: TypeLastAsyncOperation, 61 | Status: corev1.ConditionFalse, 62 | LastTransitionTime: metav1.Now(), 63 | Reason: ReasonDestroyFailure, 64 | Message: err.Error(), 65 | } 66 | default: 67 | return xpv1.Condition{ 68 | Type: "Unknown", 69 | Status: corev1.ConditionFalse, 70 | LastTransitionTime: metav1.Now(), 71 | Reason: "Unknown", 72 | Message: err.Error(), 73 | } 74 | } 75 | } 76 | 77 | // AsyncOperationFinishedCondition returns the condition TypeAsyncOperation Finished 78 | // if the operation was finished 79 | func AsyncOperationFinishedCondition() xpv1.Condition { 80 | return xpv1.Condition{ 81 | Type: TypeAsyncOperation, 82 | Status: corev1.ConditionTrue, 83 | LastTransitionTime: metav1.Now(), 84 | Reason: ReasonFinished, 85 | } 86 | } 87 | 88 | // AsyncOperationOngoingCondition returns the condition TypeAsyncOperation Ongoing 89 | // if the operation is still running 90 | func AsyncOperationOngoingCondition() xpv1.Condition { 91 | return xpv1.Condition{ 92 | Type: TypeAsyncOperation, 93 | Status: corev1.ConditionFalse, 94 | LastTransitionTime: metav1.Now(), 95 | Reason: ReasonOngoing, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/resource/fake/mocks/mock.go: -------------------------------------------------------------------------------- 1 | // Copyright 2021 The Crossplane Authors. 2 | // 3 | // Licensed under the Apache License, Version 2.0 (the "License"); 4 | // you may not use this file except in compliance with the License. 5 | // You may obtain a copy of the License at 6 | // 7 | // http://www.apache.org/licenses/LICENSE-2.0 8 | // 9 | // Unless required by applicable law or agreed to in writing, software 10 | // distributed under the License is distributed on an "AS IS" BASIS, 11 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | // See the License for the specific language governing permissions and 13 | // limitations under the License. 14 | 15 | // Code generated by MockGen. DO NOT EDIT. 16 | // Source: github.com/crossplane/terrajet/pkg/resource (interfaces: SecretClient) 17 | 18 | // Package mocks is a generated GoMock package. 19 | package mocks 20 | 21 | import ( 22 | context "context" 23 | reflect "reflect" 24 | 25 | v1 "github.com/crossplane/crossplane-runtime/apis/common/v1" 26 | gomock "github.com/golang/mock/gomock" 27 | ) 28 | 29 | // MockSecretClient is a mock of SecretClient interface. 30 | type MockSecretClient struct { 31 | ctrl *gomock.Controller 32 | recorder *MockSecretClientMockRecorder 33 | } 34 | 35 | // MockSecretClientMockRecorder is the mock recorder for MockSecretClient. 36 | type MockSecretClientMockRecorder struct { 37 | mock *MockSecretClient 38 | } 39 | 40 | // NewMockSecretClient creates a new mock instance. 41 | func NewMockSecretClient(ctrl *gomock.Controller) *MockSecretClient { 42 | mock := &MockSecretClient{ctrl: ctrl} 43 | mock.recorder = &MockSecretClientMockRecorder{mock} 44 | return mock 45 | } 46 | 47 | // EXPECT returns an object that allows the caller to indicate expected use. 48 | func (m *MockSecretClient) EXPECT() *MockSecretClientMockRecorder { 49 | return m.recorder 50 | } 51 | 52 | // GetSecretData mocks base method. 53 | func (m *MockSecretClient) GetSecretData(arg0 context.Context, arg1 *v1.SecretReference) (map[string][]byte, error) { 54 | m.ctrl.T.Helper() 55 | ret := m.ctrl.Call(m, "GetSecretData", arg0, arg1) 56 | ret0, _ := ret[0].(map[string][]byte) 57 | ret1, _ := ret[1].(error) 58 | return ret0, ret1 59 | } 60 | 61 | // GetSecretData indicates an expected call of GetSecretData. 62 | func (mr *MockSecretClientMockRecorder) GetSecretData(arg0, arg1 interface{}) *gomock.Call { 63 | mr.mock.ctrl.T.Helper() 64 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecretData", reflect.TypeOf((*MockSecretClient)(nil).GetSecretData), arg0, arg1) 65 | } 66 | 67 | // GetSecretValue mocks base method. 68 | func (m *MockSecretClient) GetSecretValue(arg0 context.Context, arg1 v1.SecretKeySelector) ([]byte, error) { 69 | m.ctrl.T.Helper() 70 | ret := m.ctrl.Call(m, "GetSecretValue", arg0, arg1) 71 | ret0, _ := ret[0].([]byte) 72 | ret1, _ := ret[1].(error) 73 | return ret0, ret1 74 | } 75 | 76 | // GetSecretValue indicates an expected call of GetSecretValue. 77 | func (mr *MockSecretClientMockRecorder) GetSecretValue(arg0, arg1 interface{}) *gomock.Call { 78 | mr.mock.ctrl.T.Helper() 79 | return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetSecretValue", reflect.TypeOf((*MockSecretClient)(nil).GetSecretValue), arg0, arg1) 80 | } 81 | -------------------------------------------------------------------------------- /pkg/resource/fake/terraformed.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 fake 18 | 19 | import ( 20 | "k8s.io/apimachinery/pkg/runtime" 21 | "k8s.io/apimachinery/pkg/runtime/schema" 22 | "k8s.io/apimachinery/pkg/util/json" 23 | 24 | "github.com/crossplane/crossplane-runtime/pkg/resource/fake" 25 | ) 26 | 27 | // Observable is mock Observable. 28 | type Observable struct { 29 | Observation map[string]interface{} 30 | AdditionalConnectionDetails map[string][]byte 31 | ID string 32 | } 33 | 34 | // GetObservation is a mock. 35 | func (o *Observable) GetObservation() (map[string]interface{}, error) { 36 | return o.Observation, nil 37 | } 38 | 39 | // SetObservation is a mock. 40 | func (o *Observable) SetObservation(data map[string]interface{}) error { 41 | o.Observation = data 42 | return nil 43 | } 44 | 45 | // GetID is a mock. 46 | func (o *Observable) GetID() string { 47 | return o.ID 48 | } 49 | 50 | // GetAdditionalConnectionDetails is a mock 51 | func (o *Observable) GetAdditionalConnectionDetails(_ map[string]interface{}) (map[string][]byte, error) { 52 | return o.AdditionalConnectionDetails, nil 53 | } 54 | 55 | // Parameterizable is mock Parameterizable. 56 | type Parameterizable struct { 57 | Parameters map[string]interface{} 58 | } 59 | 60 | // GetParameters is a mock. 61 | func (p *Parameterizable) GetParameters() (map[string]interface{}, error) { 62 | return p.Parameters, nil 63 | } 64 | 65 | // SetParameters is a mock. 66 | func (p *Parameterizable) SetParameters(data map[string]interface{}) error { 67 | p.Parameters = data 68 | return nil 69 | } 70 | 71 | // MetadataProvider is mock MetadataProvider. 72 | type MetadataProvider struct { 73 | Type string 74 | SchemaVersion int 75 | ConnectionDetailsMapping map[string]string 76 | } 77 | 78 | // GetTerraformResourceType is a mock. 79 | func (mp *MetadataProvider) GetTerraformResourceType() string { 80 | return mp.Type 81 | } 82 | 83 | // GetTerraformSchemaVersion is a mock. 84 | func (mp *MetadataProvider) GetTerraformSchemaVersion() int { 85 | return mp.SchemaVersion 86 | } 87 | 88 | // GetConnectionDetailsMapping is a mock. 89 | func (mp *MetadataProvider) GetConnectionDetailsMapping() map[string]string { 90 | return mp.ConnectionDetailsMapping 91 | } 92 | 93 | // LateInitializer is mock LateInitializer. 94 | type LateInitializer struct { 95 | Result bool 96 | Err error 97 | } 98 | 99 | // LateInitialize is a mock. 100 | func (li *LateInitializer) LateInitialize(_ []byte) (bool, error) { 101 | return li.Result, li.Err 102 | } 103 | 104 | // Terraformed is a mock that implements Terraformed interface. 105 | type Terraformed struct { 106 | fake.Managed 107 | Observable 108 | Parameterizable 109 | MetadataProvider 110 | LateInitializer 111 | } 112 | 113 | // GetObjectKind returns schema.ObjectKind. 114 | func (t *Terraformed) GetObjectKind() schema.ObjectKind { 115 | return schema.EmptyObjectKind 116 | } 117 | 118 | // DeepCopyObject returns a copy of the object as runtime.Object 119 | func (t *Terraformed) DeepCopyObject() runtime.Object { 120 | out := &Terraformed{} 121 | j, err := json.Marshal(t) 122 | if err != nil { 123 | panic(err) 124 | } 125 | _ = json.Unmarshal(j, out) 126 | return out 127 | } 128 | -------------------------------------------------------------------------------- /pkg/resource/interfaces.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 18 | 19 | import ( 20 | "github.com/crossplane/crossplane-runtime/pkg/resource" 21 | ) 22 | 23 | // Observable structs can get and set observations in the form of Terraform JSON. 24 | type Observable interface { 25 | GetObservation() (map[string]interface{}, error) 26 | SetObservation(map[string]interface{}) error 27 | GetID() string 28 | } 29 | 30 | // Parameterizable structs can get and set parameters of the managed resource 31 | // using map form of Terraform JSON. 32 | type Parameterizable interface { 33 | GetParameters() (map[string]interface{}, error) 34 | SetParameters(map[string]interface{}) error 35 | } 36 | 37 | // MetadataProvider provides Terraform metadata for the Terraform managed 38 | // resource. 39 | type MetadataProvider interface { 40 | GetTerraformResourceType() string 41 | GetTerraformSchemaVersion() int 42 | GetConnectionDetailsMapping() map[string]string 43 | } 44 | 45 | // LateInitializer late-initializes the managed resource from observed Terraform 46 | // state. 47 | type LateInitializer interface { 48 | // LateInitialize this Terraformed resource using its observed tfState. 49 | // returns True if the there are any spec changes for the resource. 50 | LateInitialize(attrs []byte) (bool, error) 51 | } 52 | 53 | // Terraformed is a Kubernetes object representing a concrete terraform managed 54 | // resource. 55 | type Terraformed interface { 56 | resource.Managed 57 | 58 | MetadataProvider 59 | Observable 60 | Parameterizable 61 | LateInitializer 62 | } 63 | -------------------------------------------------------------------------------- /pkg/resource/json/json.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 json 18 | 19 | import jsoniter "github.com/json-iterator/go" 20 | 21 | // TFParser is a json parser to marshal/unmarshal using "tf" tag. 22 | var TFParser = jsoniter.Config{TagKey: "tf"}.Froze() 23 | 24 | // JSParser is a json parser to marshal/unmarshal using "json" tag. 25 | var JSParser = jsoniter.Config{ 26 | TagKey: "json", 27 | // We need to sort the map keys to get consistent output in tests. 28 | SortMapKeys: true, 29 | }.Froze() 30 | -------------------------------------------------------------------------------- /pkg/resource/json/statev4.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 json 18 | 19 | import ( 20 | jsoniter "github.com/json-iterator/go" 21 | ) 22 | 23 | // NewStateV4 returns a new base StateV4 object. 24 | func NewStateV4() *StateV4 { 25 | return &StateV4{ 26 | Version: 4, 27 | Serial: 1, 28 | } 29 | } 30 | 31 | // State file schema from https://github.com/hashicorp/terraform/blob/d9dfd451ea572219871bb9c5503a471418258e40/internal/states/statefile/version4.go 32 | 33 | // StateV4 represents a version 4 terraform state 34 | type StateV4 struct { 35 | Version uint64 `json:"version"` 36 | TerraformVersion string `json:"terraform_version"` 37 | Serial uint64 `json:"serial"` 38 | Lineage string `json:"lineage"` 39 | RootOutputs map[string]OutputStateV4 `json:"outputs"` 40 | Resources []ResourceStateV4 `json:"resources"` 41 | } 42 | 43 | // OutputStateV4 represents a version 4 output state 44 | type OutputStateV4 struct { 45 | ValueRaw jsoniter.RawMessage `json:"value"` 46 | ValueTypeRaw jsoniter.RawMessage `json:"type"` 47 | Sensitive bool `json:"sensitive,omitempty"` 48 | } 49 | 50 | // ResourceStateV4 represents a version 4 resource state 51 | type ResourceStateV4 struct { 52 | Module string `json:"module,omitempty"` 53 | Mode string `json:"mode"` 54 | Type string `json:"type"` 55 | Name string `json:"name"` 56 | EachMode string `json:"each,omitempty"` 57 | ProviderConfig string `json:"provider"` 58 | Instances []InstanceObjectStateV4 `json:"instances"` 59 | } 60 | 61 | // InstanceObjectStateV4 represents a version 4 instance object state 62 | type InstanceObjectStateV4 struct { 63 | IndexKey interface{} `json:"index_key,omitempty"` 64 | Status string `json:"status,omitempty"` 65 | Deposed string `json:"deposed,omitempty"` 66 | 67 | SchemaVersion uint64 `json:"schema_version"` 68 | AttributesRaw jsoniter.RawMessage `json:"attributes,omitempty"` 69 | AttributesFlat map[string]string `json:"attributes_flat,omitempty"` 70 | AttributeSensitivePaths jsoniter.RawMessage `json:"sensitive_attributes,omitempty"` 71 | 72 | PrivateRaw []byte `json:"private,omitempty"` 73 | 74 | Dependencies []string `json:"dependencies,omitempty"` 75 | 76 | CreateBeforeDestroy bool `json:"create_before_destroy,omitempty"` 77 | } 78 | 79 | // GetAttributes returns attributes of the Terraform managed resource (i.e. first instance of first resource) 80 | func (st *StateV4) GetAttributes() jsoniter.RawMessage { 81 | if st == nil || len(st.Resources) == 0 || len(st.Resources[0].Instances) == 0 { 82 | return nil 83 | } 84 | return st.Resources[0].Instances[0].AttributesRaw 85 | } 86 | 87 | // GetSensitiveAttributes returns sensitive attributes of the Terraform managed resource (i.e. first instance of first resource) 88 | func (st *StateV4) GetSensitiveAttributes() jsoniter.RawMessage { 89 | if st == nil || len(st.Resources) == 0 || len(st.Resources[0].Instances) == 0 { 90 | return nil 91 | } 92 | return st.Resources[0].Instances[0].AttributeSensitivePaths 93 | } 94 | 95 | // GetPrivateRaw returns private attribute of the Terraform managed resource 96 | // that is used as metadata by the Terraform provider 97 | func (st *StateV4) GetPrivateRaw() []byte { 98 | if st == nil || len(st.Resources) == 0 || len(st.Resources[0].Instances) == 0 { 99 | return nil 100 | } 101 | return st.Resources[0].Instances[0].PrivateRaw 102 | } 103 | -------------------------------------------------------------------------------- /pkg/terraform/errors/errors.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 errors 18 | 19 | import ( 20 | "fmt" 21 | "strings" 22 | 23 | jsoniter "github.com/json-iterator/go" 24 | "github.com/pkg/errors" 25 | ) 26 | 27 | const ( 28 | levelError = "error" 29 | ) 30 | 31 | type tfError struct { 32 | message string 33 | } 34 | 35 | type applyFailed struct { 36 | *tfError 37 | } 38 | 39 | // TerraformLog represents relevant fields of a Terraform CLI JSON-formatted log line 40 | type TerraformLog struct { 41 | Level string `json:"@level"` 42 | Message string `json:"@message"` 43 | Diagnostic LogDiagnostic `json:"diagnostic"` 44 | } 45 | 46 | // LogDiagnostic represents relevant fields of a Terraform CLI JSON-formatted 47 | // log line diagnostic info 48 | type LogDiagnostic struct { 49 | Severity string `json:"severity"` 50 | Summary string `json:"summary"` 51 | Detail string `json:"detail"` 52 | Range Range `json:"range"` 53 | } 54 | 55 | // Range represents a line range in a Terraform workspace file 56 | type Range struct { 57 | FileName string `json:"filename"` 58 | } 59 | 60 | func (t *tfError) Error() string { 61 | return t.message 62 | } 63 | 64 | func newTFError(message string, logs []byte) (string, *tfError) { 65 | tfError := &tfError{ 66 | message: message, 67 | } 68 | 69 | tfLogs, err := parseTerraformLogs(logs) 70 | if err != nil { 71 | return err.Error(), tfError 72 | } 73 | 74 | messages := make([]string, 0, len(tfLogs)) 75 | for _, l := range tfLogs { 76 | // only use error logs 77 | if l == nil || l.Level != levelError { 78 | continue 79 | } 80 | m := l.Message 81 | if l.Diagnostic.Severity == levelError && l.Diagnostic.Summary != "" { 82 | m = fmt.Sprintf("%s: %s", l.Diagnostic.Summary, l.Diagnostic.Detail) 83 | if len(l.Diagnostic.Range.FileName) != 0 { 84 | m = m + ": File name: " + l.Diagnostic.Range.FileName 85 | } 86 | } 87 | messages = append(messages, m) 88 | } 89 | tfError.message = fmt.Sprintf("%s: %s", message, strings.Join(messages, "\n")) 90 | return "", tfError 91 | } 92 | 93 | func parseTerraformLogs(logs []byte) ([]*TerraformLog, error) { 94 | logLines := strings.Split(string(logs), "\n") 95 | tfLogs := make([]*TerraformLog, 0, len(logLines)) 96 | for _, l := range logLines { 97 | log := &TerraformLog{} 98 | l := strings.TrimSpace(l) 99 | if l == "" { 100 | continue 101 | } 102 | if err := jsoniter.ConfigCompatibleWithStandardLibrary.UnmarshalFromString(l, log); err != nil { 103 | return nil, err 104 | } 105 | tfLogs = append(tfLogs, log) 106 | } 107 | return tfLogs, nil 108 | } 109 | 110 | // NewApplyFailed returns a new apply failure error with given logs. 111 | func NewApplyFailed(logs []byte) error { 112 | parseError, tfError := newTFError("apply failed", logs) 113 | result := &applyFailed{tfError: tfError} 114 | if parseError == "" { 115 | return result 116 | } 117 | return errors.WithMessage(result, parseError) 118 | } 119 | 120 | // IsApplyFailed returns whether error is due to failure of an apply operation. 121 | func IsApplyFailed(err error) bool { 122 | r := &applyFailed{} 123 | return errors.As(err, &r) 124 | } 125 | 126 | type destroyFailed struct { 127 | *tfError 128 | } 129 | 130 | // NewDestroyFailed returns a new destroy failure error with given logs. 131 | func NewDestroyFailed(logs []byte) error { 132 | parseError, tfError := newTFError("destroy failed", logs) 133 | result := &destroyFailed{tfError: tfError} 134 | if parseError == "" { 135 | return result 136 | } 137 | return errors.WithMessage(result, parseError) 138 | } 139 | 140 | // IsDestroyFailed returns whether error is due to failure of a destroy operation. 141 | func IsDestroyFailed(err error) bool { 142 | r := &destroyFailed{} 143 | return errors.As(err, &r) 144 | } 145 | 146 | type refreshFailed struct { 147 | *tfError 148 | } 149 | 150 | // NewRefreshFailed returns a new destroy failure error with given logs. 151 | func NewRefreshFailed(logs []byte) error { 152 | parseError, tfError := newTFError("refresh failed", logs) 153 | result := &refreshFailed{tfError: tfError} 154 | if parseError == "" { 155 | return result 156 | } 157 | return errors.WithMessage(result, parseError) 158 | } 159 | 160 | // IsRefreshFailed returns whether error is due to failure of a destroy operation. 161 | func IsRefreshFailed(err error) bool { 162 | r := &refreshFailed{} 163 | return errors.As(err, &r) 164 | } 165 | 166 | type planFailed struct { 167 | *tfError 168 | } 169 | 170 | // NewPlanFailed returns a new destroy failure error with given logs. 171 | func NewPlanFailed(logs []byte) error { 172 | parseError, tfError := newTFError("plan failed", logs) 173 | result := &planFailed{tfError: tfError} 174 | if parseError == "" { 175 | return result 176 | } 177 | return errors.WithMessage(result, parseError) 178 | } 179 | 180 | // IsPlanFailed returns whether error is due to failure of a destroy operation. 181 | func IsPlanFailed(err error) bool { 182 | r := &planFailed{} 183 | return errors.As(err, &r) 184 | } 185 | -------------------------------------------------------------------------------- /pkg/terraform/files.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 terraform 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "path/filepath" 23 | "strings" 24 | 25 | "github.com/crossplane/crossplane-runtime/pkg/meta" 26 | "github.com/pkg/errors" 27 | "github.com/spf13/afero" 28 | 29 | "github.com/crossplane/terrajet/pkg/config" 30 | "github.com/crossplane/terrajet/pkg/resource" 31 | "github.com/crossplane/terrajet/pkg/resource/json" 32 | ) 33 | 34 | // FileProducerOption allows you to configure FileProducer 35 | type FileProducerOption func(*FileProducer) 36 | 37 | // WithFileSystem configures the filesystem to use. Used mostly for testing. 38 | func WithFileSystem(fs afero.Fs) FileProducerOption { 39 | return func(fp *FileProducer) { 40 | fp.fs = afero.Afero{Fs: fs} 41 | } 42 | } 43 | 44 | // NewFileProducer returns a new FileProducer. 45 | func NewFileProducer(ctx context.Context, client resource.SecretClient, dir string, tr resource.Terraformed, ts Setup, cfg *config.Resource, opts ...FileProducerOption) (*FileProducer, error) { 46 | fp := &FileProducer{ 47 | Resource: tr, 48 | Setup: ts, 49 | Dir: dir, 50 | Config: cfg, 51 | fs: afero.Afero{Fs: afero.NewOsFs()}, 52 | } 53 | for _, f := range opts { 54 | f(fp) 55 | } 56 | params, err := tr.GetParameters() 57 | if err != nil { 58 | return nil, errors.Wrap(err, "cannot get parameters") 59 | } 60 | if err = resource.GetSensitiveParameters(ctx, client, tr, params, tr.GetConnectionDetailsMapping()); err != nil { 61 | return nil, errors.Wrap(err, "cannot get sensitive parameters") 62 | } 63 | fp.Config.ExternalName.SetIdentifierArgumentFn(params, meta.GetExternalName(tr)) 64 | fp.parameters = params 65 | 66 | obs, err := tr.GetObservation() 67 | if err != nil { 68 | return nil, errors.Wrap(err, "cannot get observation") 69 | } 70 | if err = resource.GetSensitiveObservation(ctx, client, tr.GetWriteConnectionSecretToReference(), obs); err != nil { 71 | return nil, errors.Wrap(err, "cannot get sensitive observation") 72 | } 73 | fp.observation = obs 74 | 75 | return fp, nil 76 | } 77 | 78 | // FileProducer exist to serve as cache for the data that is costly to produce 79 | // every time like parameters and observation maps. 80 | type FileProducer struct { 81 | Resource resource.Terraformed 82 | Setup Setup 83 | Dir string 84 | Config *config.Resource 85 | 86 | parameters map[string]interface{} 87 | observation map[string]interface{} 88 | fs afero.Afero 89 | } 90 | 91 | // WriteTFState writes the Terraform state that should exist in the filesystem to 92 | // start any Terraform operation. 93 | func (fp *FileProducer) WriteTFState(ctx context.Context) error { 94 | base := make(map[string]interface{}) 95 | // NOTE(muvaf): Since we try to produce the current state, observation 96 | // takes precedence over parameters. 97 | for k, v := range fp.parameters { 98 | base[k] = v 99 | } 100 | for k, v := range fp.observation { 101 | base[k] = v 102 | } 103 | id, err := fp.Config.ExternalName.GetIDFn(ctx, meta.GetExternalName(fp.Resource), fp.parameters, fp.Setup.Configuration) 104 | if err != nil { 105 | return errors.Wrap(err, "cannot get id") 106 | } 107 | base["id"] = id 108 | attr, err := json.JSParser.Marshal(base) 109 | if err != nil { 110 | return errors.Wrap(err, "cannot marshal produced state attributes") 111 | } 112 | var privateRaw []byte 113 | if pr, ok := fp.Resource.GetAnnotations()[resource.AnnotationKeyPrivateRawAttribute]; ok { 114 | privateRaw = []byte(pr) 115 | } 116 | if privateRaw, err = insertTimeoutsMeta(privateRaw, timeouts(fp.Config.OperationTimeouts)); err != nil { 117 | return errors.Wrap(err, "cannot insert timeouts metadata to private raw") 118 | } 119 | s := json.NewStateV4() 120 | s.TerraformVersion = fp.Setup.Version 121 | s.Lineage = string(fp.Resource.GetUID()) 122 | s.Resources = []json.ResourceStateV4{ 123 | { 124 | Mode: "managed", 125 | Type: fp.Resource.GetTerraformResourceType(), 126 | Name: fp.Resource.GetName(), 127 | // TODO(muvaf): we should get the full URL from Dockerfile since 128 | // providers don't have to be hosted in registry.terraform.io 129 | ProviderConfig: fmt.Sprintf(`provider["registry.terraform.io/%s"]`, fp.Setup.Requirement.Source), 130 | Instances: []json.InstanceObjectStateV4{ 131 | { 132 | SchemaVersion: uint64(fp.Resource.GetTerraformSchemaVersion()), 133 | PrivateRaw: privateRaw, 134 | AttributesRaw: attr, 135 | }, 136 | }, 137 | }, 138 | } 139 | 140 | rawState, err := json.JSParser.Marshal(s) 141 | if err != nil { 142 | return errors.Wrap(err, "cannot marshal state object") 143 | } 144 | return errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "terraform.tfstate"), rawState, 0600), "cannot write tfstate file") 145 | } 146 | 147 | // WriteMainTF writes the content main configuration file that has the desired 148 | // state configuration for Terraform. 149 | func (fp *FileProducer) WriteMainTF() error { 150 | // If the resource is in a deletion process, we need to remove the deletion 151 | // protection. 152 | fp.parameters["lifecycle"] = map[string]bool{ 153 | "prevent_destroy": !meta.WasDeleted(fp.Resource), 154 | } 155 | 156 | // Add operation timeouts if any timeout configured for the resource 157 | if tp := timeouts(fp.Config.OperationTimeouts).asParameter(); len(tp) != 0 { 158 | fp.parameters["timeouts"] = tp 159 | } 160 | 161 | // Note(turkenh): To use third party providers, we need to configure 162 | // provider name in required_providers. 163 | providerSource := strings.Split(fp.Setup.Requirement.Source, "/") 164 | m := map[string]interface{}{ 165 | "terraform": map[string]interface{}{ 166 | "required_providers": map[string]interface{}{ 167 | providerSource[len(providerSource)-1]: map[string]string{ 168 | "source": fp.Setup.Requirement.Source, 169 | "version": fp.Setup.Requirement.Version, 170 | }, 171 | }, 172 | }, 173 | "provider": map[string]interface{}{ 174 | providerSource[len(providerSource)-1]: fp.Setup.Configuration, 175 | }, 176 | "resource": map[string]interface{}{ 177 | fp.Resource.GetTerraformResourceType(): map[string]interface{}{ 178 | fp.Resource.GetName(): fp.parameters, 179 | }, 180 | }, 181 | } 182 | rawMainTF, err := json.JSParser.Marshal(m) 183 | if err != nil { 184 | return errors.Wrap(err, "cannot marshal main hcl object") 185 | } 186 | return errors.Wrap(fp.fs.WriteFile(filepath.Join(fp.Dir, "main.tf.json"), rawMainTF, 0600), "cannot write maintf file") 187 | } 188 | -------------------------------------------------------------------------------- /pkg/terraform/finalizer.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 terraform 18 | 19 | import ( 20 | "context" 21 | 22 | xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 23 | "github.com/pkg/errors" 24 | ) 25 | 26 | const ( 27 | errRemoveWorkspace = "cannot remove workspace from the store" 28 | ) 29 | 30 | // StoreCleaner is the interface that the workspace finalizer needs to work with. 31 | type StoreCleaner interface { 32 | Remove(obj xpresource.Object) error 33 | } 34 | 35 | // TODO(muvaf): A FinalizerChain in crossplane-runtime? 36 | 37 | // NewWorkspaceFinalizer returns a new WorkspaceFinalizer. 38 | func NewWorkspaceFinalizer(ws StoreCleaner, af xpresource.Finalizer) *WorkspaceFinalizer { 39 | return &WorkspaceFinalizer{ 40 | Finalizer: af, 41 | Store: ws, 42 | } 43 | } 44 | 45 | // WorkspaceFinalizer removes the workspace from the workspace store and only 46 | // then calls RemoveFinalizer of the underlying Finalizer. 47 | type WorkspaceFinalizer struct { 48 | xpresource.Finalizer 49 | Store StoreCleaner 50 | } 51 | 52 | // AddFinalizer to the supplied Managed resource. 53 | func (wf *WorkspaceFinalizer) AddFinalizer(ctx context.Context, obj xpresource.Object) error { 54 | return wf.Finalizer.AddFinalizer(ctx, obj) 55 | } 56 | 57 | // RemoveFinalizer removes the workspace from workspace store before removing 58 | // the finalizer. 59 | func (wf *WorkspaceFinalizer) RemoveFinalizer(ctx context.Context, obj xpresource.Object) error { 60 | if err := wf.Store.Remove(obj); err != nil { 61 | return errors.Wrap(err, errRemoveWorkspace) 62 | } 63 | return wf.Finalizer.RemoveFinalizer(ctx, obj) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/terraform/finalizer_test.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 terraform 18 | 19 | import ( 20 | "context" 21 | "testing" 22 | 23 | "github.com/crossplane/crossplane-runtime/pkg/logging" 24 | xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 25 | "github.com/crossplane/crossplane-runtime/pkg/test" 26 | "github.com/google/go-cmp/cmp" 27 | "github.com/pkg/errors" 28 | 29 | "github.com/crossplane/terrajet/pkg/resource" 30 | ) 31 | 32 | var ( 33 | errBoom = errors.New("errboom") 34 | ) 35 | 36 | type StoreFns struct { 37 | WorkspaceFn func(ctx context.Context, tr resource.Terraformed, ts Setup, l logging.Logger) (*Workspace, error) 38 | RemoveFn func(obj xpresource.Object) error 39 | } 40 | 41 | func (sf *StoreFns) Workspace(ctx context.Context, tr resource.Terraformed, ts Setup, l logging.Logger) (*Workspace, error) { 42 | return sf.WorkspaceFn(ctx, tr, ts, l) 43 | } 44 | 45 | func (sf *StoreFns) Remove(obj xpresource.Object) error { 46 | return sf.RemoveFn(obj) 47 | } 48 | 49 | func TestAddFinalizer(t *testing.T) { 50 | type args struct { 51 | finalizer xpresource.Finalizer 52 | store StoreCleaner 53 | obj xpresource.Object 54 | } 55 | type want struct { 56 | err error 57 | } 58 | cases := map[string]struct { 59 | reason string 60 | args 61 | want 62 | }{ 63 | "Success": { 64 | args: args{ 65 | finalizer: xpresource.FinalizerFns{ 66 | AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { 67 | return nil 68 | }, 69 | }, 70 | }, 71 | }, 72 | "Failure": { 73 | args: args{ 74 | finalizer: xpresource.FinalizerFns{ 75 | AddFinalizerFn: func(_ context.Context, _ xpresource.Object) error { 76 | return errBoom 77 | }, 78 | }, 79 | }, 80 | want: want{ 81 | err: errBoom, 82 | }, 83 | }, 84 | } 85 | for name, tc := range cases { 86 | t.Run(name, func(t *testing.T) { 87 | f := NewWorkspaceFinalizer(tc.args.store, tc.args.finalizer) 88 | err := f.AddFinalizer(context.TODO(), tc.args.obj) 89 | if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 90 | t.Errorf("\n%s\nAddFinalizer(...): -want error, +got error:\n%s", tc.reason, diff) 91 | } 92 | }) 93 | } 94 | } 95 | 96 | func TestRemoveFinalizer(t *testing.T) { 97 | type args struct { 98 | finalizer xpresource.Finalizer 99 | store StoreCleaner 100 | obj xpresource.Object 101 | } 102 | type want struct { 103 | err error 104 | } 105 | cases := map[string]struct { 106 | reason string 107 | args 108 | want 109 | }{ 110 | "Success": { 111 | args: args{ 112 | store: &StoreFns{ 113 | RemoveFn: func(_ xpresource.Object) error { 114 | return nil 115 | }, 116 | }, 117 | finalizer: xpresource.FinalizerFns{ 118 | RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { 119 | return nil 120 | }, 121 | }, 122 | }, 123 | }, 124 | "StoreRemovalFails": { 125 | args: args{ 126 | store: &StoreFns{ 127 | RemoveFn: func(_ xpresource.Object) error { 128 | return errBoom 129 | }, 130 | }, 131 | }, 132 | want: want{ 133 | err: errors.Wrap(errBoom, errRemoveWorkspace), 134 | }, 135 | }, 136 | "FinalizerFails": { 137 | args: args{ 138 | store: &StoreFns{ 139 | RemoveFn: func(_ xpresource.Object) error { 140 | return nil 141 | }, 142 | }, 143 | finalizer: xpresource.FinalizerFns{ 144 | RemoveFinalizerFn: func(_ context.Context, _ xpresource.Object) error { 145 | return errBoom 146 | }, 147 | }, 148 | }, 149 | want: want{ 150 | err: errBoom, 151 | }, 152 | }, 153 | } 154 | for name, tc := range cases { 155 | t.Run(name, func(t *testing.T) { 156 | f := NewWorkspaceFinalizer(tc.args.store, tc.args.finalizer) 157 | err := f.RemoveFinalizer(context.TODO(), tc.args.obj) 158 | if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 159 | t.Errorf("\n%s\nRemoveFinalizer(...): -want error, +got error:\n%s", tc.reason, diff) 160 | } 161 | }) 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /pkg/terraform/operation.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 terraform 18 | 19 | import ( 20 | "sync" 21 | "time" 22 | ) 23 | 24 | // Operation is the representation of a single Terraform CLI operation. 25 | type Operation struct { 26 | Type string 27 | 28 | startTime *time.Time 29 | endTime *time.Time 30 | mu sync.RWMutex 31 | } 32 | 33 | // MarkStart marks the operation as started. 34 | func (o *Operation) MarkStart(t string) { 35 | o.mu.Lock() 36 | defer o.mu.Unlock() 37 | now := time.Now() 38 | o.Type = t 39 | o.startTime = &now 40 | o.endTime = nil 41 | } 42 | 43 | // MarkEnd marks the operation as ended. 44 | func (o *Operation) MarkEnd() { 45 | o.mu.Lock() 46 | defer o.mu.Unlock() 47 | now := time.Now() 48 | o.endTime = &now 49 | } 50 | 51 | // Flush cleans the operation information. 52 | func (o *Operation) Flush() { 53 | o.mu.Lock() 54 | defer o.mu.Unlock() 55 | o.Type = "" 56 | o.startTime = nil 57 | o.endTime = nil 58 | } 59 | 60 | // IsEnded returns whether the operation has ended, regardless of its result. 61 | func (o *Operation) IsEnded() bool { 62 | o.mu.RLock() 63 | defer o.mu.RUnlock() 64 | return o.endTime != nil 65 | } 66 | 67 | // IsRunning returns whether there is an ongoing operation. 68 | func (o *Operation) IsRunning() bool { 69 | o.mu.RLock() 70 | defer o.mu.RUnlock() 71 | return o.startTime != nil && o.endTime == nil 72 | } 73 | 74 | // StartTime returns the start time of the current operation. 75 | func (o *Operation) StartTime() *time.Time { 76 | o.mu.RLock() 77 | defer o.mu.RUnlock() 78 | return o.startTime 79 | } 80 | 81 | // EndTime returns the end time of the current operation. 82 | func (o *Operation) EndTime() *time.Time { 83 | o.mu.RLock() 84 | defer o.mu.RUnlock() 85 | return o.endTime 86 | } 87 | -------------------------------------------------------------------------------- /pkg/terraform/operation_test.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 terraform 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | ) 24 | 25 | func TestOperation(t *testing.T) { 26 | type args struct { 27 | calls func(o *Operation) 28 | } 29 | type want struct { 30 | checks func(o *Operation) bool 31 | result bool 32 | } 33 | 34 | cases := map[string]struct { 35 | args 36 | want 37 | }{ 38 | "Running": { 39 | args: args{ 40 | calls: func(o *Operation) { 41 | o.MarkStart("type") 42 | }, 43 | }, 44 | want: want{ 45 | checks: func(o *Operation) bool { 46 | return o.IsRunning() && !o.IsEnded() 47 | }, 48 | result: true, 49 | }, 50 | }, 51 | "Ended": { 52 | args: args{ 53 | calls: func(o *Operation) { 54 | o.MarkStart("type") 55 | o.MarkEnd() 56 | }, 57 | }, 58 | want: want{ 59 | checks: func(o *Operation) bool { 60 | return !o.IsRunning() && o.IsEnded() 61 | }, 62 | result: true, 63 | }, 64 | }, 65 | "Flushed": { 66 | args: args{ 67 | calls: func(o *Operation) { 68 | o.MarkStart("type") 69 | o.MarkEnd() 70 | o.Flush() 71 | }, 72 | }, 73 | want: want{ 74 | checks: func(o *Operation) bool { 75 | return o.Type == "" && o.StartTime() == nil && o.EndTime() == nil 76 | }, 77 | result: true, 78 | }, 79 | }, 80 | } 81 | for name, tc := range cases { 82 | t.Run(name, func(t *testing.T) { 83 | o := &Operation{} 84 | tc.args.calls(o) 85 | if diff := cmp.Diff(tc.want.result, tc.checks(o)); diff != "" { 86 | t.Errorf("\n%s\nOperation(...): -want error, +got error:\n%s", name, diff) 87 | } 88 | }) 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /pkg/terraform/provider_runner.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 | 17 | package terraform 18 | 19 | import ( 20 | "bufio" 21 | "fmt" 22 | "os" 23 | "regexp" 24 | "sync" 25 | "time" 26 | 27 | "github.com/crossplane/crossplane-runtime/pkg/logging" 28 | "github.com/pkg/errors" 29 | "k8s.io/apimachinery/pkg/util/clock" 30 | "k8s.io/utils/exec" 31 | ) 32 | 33 | const ( 34 | // error messages 35 | errFmtTimeout = "timed out after %v while waiting for the reattach configuration string" 36 | 37 | // an example value would be: '{"registry.terraform.io/hashicorp/aws": {"Protocol": "grpc", "ProtocolVersion":5, "Pid":... "Addr":{"Network": "unix","String": "..."}}}' 38 | fmtReattachEnv = `{"%s":{"Protocol":"grpc","ProtocolVersion":%d,"Pid":%d,"Test": true,"Addr":{"Network": "unix","String": "%s"}}}` 39 | fmtSetEnv = "%s=%s" 40 | envReattachConfig = "TF_REATTACH_PROVIDERS" 41 | envMagicCookie = "TF_PLUGIN_MAGIC_COOKIE" 42 | // Terraform provider plugin expects this magic cookie in its environment 43 | // (as the value of key TF_PLUGIN_MAGIC_COOKIE): 44 | // https://github.com/hashicorp/terraform/blob/d35bc0531255b496beb5d932f185cbcdb2d61a99/internal/plugin/serve.go#L33 45 | valMagicCookie = "d602bf8f470bc67ca7faa0386276bbdd4330efaf76d1a219cb4d6991ca9872b2" 46 | defaultProtocolVersion = 5 47 | regexReattachLine = `.*unix\|(.*)\|grpc.*` 48 | reattachTimeout = 1 * time.Minute 49 | ) 50 | 51 | // ProviderRunner is the interface for running 52 | // Terraform native provider processes in the shared 53 | // gRPC server mode 54 | type ProviderRunner interface { 55 | Start() (string, error) 56 | } 57 | 58 | // NoOpProviderRunner is a no-op ProviderRunner 59 | type NoOpProviderRunner struct{} 60 | 61 | // NewNoOpProviderRunner constructs a new NoOpProviderRunner 62 | func NewNoOpProviderRunner() NoOpProviderRunner { 63 | return NoOpProviderRunner{} 64 | } 65 | 66 | // Start takes no action 67 | func (NoOpProviderRunner) Start() (string, error) { 68 | return "", nil 69 | } 70 | 71 | // SharedProvider runs the configured native provider plugin 72 | // using the supplied command-line args 73 | type SharedProvider struct { 74 | nativeProviderPath string 75 | nativeProviderArgs []string 76 | reattachConfig string 77 | nativeProviderName string 78 | protocolVersion int 79 | logger logging.Logger 80 | executor exec.Interface 81 | clock clock.Clock 82 | mu *sync.Mutex 83 | } 84 | 85 | // SharedGRPCRunnerOption lets you configure the shared gRPC runner. 86 | type SharedGRPCRunnerOption func(runner *SharedProvider) 87 | 88 | // WithNativeProviderArgs are the arguments to be passed to the native provider 89 | func WithNativeProviderArgs(args ...string) SharedGRPCRunnerOption { 90 | return func(sr *SharedProvider) { 91 | sr.nativeProviderArgs = args 92 | } 93 | } 94 | 95 | // WithNativeProviderExecutor sets the process executor to be used 96 | func WithNativeProviderExecutor(e exec.Interface) SharedGRPCRunnerOption { 97 | return func(sr *SharedProvider) { 98 | sr.executor = e 99 | } 100 | } 101 | 102 | // WithProtocolVersion sets the gRPC protocol version in use between 103 | // the Terraform CLI and the native provider. 104 | func WithProtocolVersion(protocolVersion int) SharedGRPCRunnerOption { 105 | return func(sr *SharedProvider) { 106 | sr.protocolVersion = protocolVersion 107 | } 108 | } 109 | 110 | // NewSharedProvider instantiates a SharedProvider with an 111 | // OS executor using the supplied logger 112 | func NewSharedProvider(l logging.Logger, nativeProviderPath, nativeProviderName string, opts ...SharedGRPCRunnerOption) *SharedProvider { 113 | sr := &SharedProvider{ 114 | logger: l, 115 | nativeProviderPath: nativeProviderPath, 116 | nativeProviderName: nativeProviderName, 117 | protocolVersion: defaultProtocolVersion, 118 | executor: exec.New(), 119 | clock: clock.RealClock{}, 120 | mu: &sync.Mutex{}, 121 | } 122 | for _, o := range opts { 123 | o(sr) 124 | } 125 | return sr 126 | } 127 | 128 | // Start starts a shared gRPC server if not already running 129 | // A logger, native provider's path and command-line arguments to be 130 | // passed to it must have been properly configured. 131 | // Returns any errors encountered and the reattachment configuration for 132 | // the native provider. 133 | func (sr *SharedProvider) Start() (string, error) { //nolint:gocyclo 134 | sr.mu.Lock() 135 | defer sr.mu.Unlock() 136 | log := sr.logger.WithValues("nativeProviderPath", sr.nativeProviderPath, "nativeProviderArgs", sr.nativeProviderArgs) 137 | if sr.reattachConfig != "" { 138 | log.Debug("Shared gRPC server is running...", "reattachConfig", sr.reattachConfig) 139 | return sr.reattachConfig, nil 140 | } 141 | errCh := make(chan error, 1) 142 | reattachCh := make(chan string, 1) 143 | re, err := regexp.Compile(regexReattachLine) 144 | if err != nil { 145 | return "", errors.Wrap(err, "failed to compile regexp") 146 | } 147 | 148 | go func() { 149 | defer close(errCh) 150 | defer close(reattachCh) 151 | defer func() { 152 | sr.mu.Lock() 153 | sr.reattachConfig = "" 154 | sr.mu.Unlock() 155 | }() 156 | //#nosec G204 no user input 157 | cmd := sr.executor.Command(sr.nativeProviderPath, sr.nativeProviderArgs...) 158 | cmd.SetEnv(append(os.Environ(), fmt.Sprintf(fmtSetEnv, envMagicCookie, valMagicCookie))) 159 | stdout, err := cmd.StdoutPipe() 160 | if err != nil { 161 | errCh <- err 162 | return 163 | } 164 | if err := cmd.Start(); err != nil { 165 | errCh <- err 166 | return 167 | } 168 | scanner := bufio.NewScanner(stdout) 169 | for scanner.Scan() { 170 | t := scanner.Text() 171 | matches := re.FindStringSubmatch(t) 172 | if matches == nil { 173 | continue 174 | } 175 | reattachCh <- fmt.Sprintf(fmtReattachEnv, sr.nativeProviderName, sr.protocolVersion, os.Getpid(), matches[1]) 176 | break 177 | } 178 | if err := cmd.Wait(); err != nil { 179 | log.Info("Native Terraform provider process error", "error", err) 180 | errCh <- err 181 | } 182 | }() 183 | 184 | select { 185 | case reattachConfig := <-reattachCh: 186 | sr.reattachConfig = reattachConfig 187 | return sr.reattachConfig, nil 188 | case err := <-errCh: 189 | return "", err 190 | case <-sr.clock.After(reattachTimeout): 191 | return "", errors.Errorf(errFmtTimeout, reattachTimeout) 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /pkg/terraform/provider_runner_test.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 | 17 | package terraform 18 | 19 | import ( 20 | "fmt" 21 | "io" 22 | "os" 23 | "reflect" 24 | "strings" 25 | "sync" 26 | "testing" 27 | "time" 28 | 29 | "github.com/crossplane/crossplane-runtime/pkg/logging" 30 | "github.com/crossplane/crossplane-runtime/pkg/test" 31 | "github.com/google/go-cmp/cmp" 32 | "github.com/pkg/errors" 33 | "k8s.io/apimachinery/pkg/util/clock" 34 | "k8s.io/utils/exec" 35 | testingexec "k8s.io/utils/exec/testing" 36 | ) 37 | 38 | func TestStartSharedServer(t *testing.T) { 39 | testPath := "path" 40 | testName := "provider-test" 41 | testArgs := []string{"arg1", "arg2"} 42 | testReattachConfig1 := `1|5|unix|test1|grpc|` 43 | testReattachConfig2 := `1|5|unix|test2|grpc|` 44 | testErr := errors.New("boom") 45 | type args struct { 46 | runner ProviderRunner 47 | } 48 | type want struct { 49 | reattachConfig string 50 | err error 51 | } 52 | tests := map[string]struct { 53 | args args 54 | want want 55 | }{ 56 | "NotConfiguredNoOp": { 57 | args: args{ 58 | runner: NewNoOpProviderRunner(), 59 | }, 60 | }, 61 | "SuccessfullyStarted": { 62 | args: args{ 63 | runner: NewSharedProvider(logging.NewNopLogger(), testPath, testName, WithNativeProviderArgs(testArgs...), 64 | WithNativeProviderExecutor(newExecutorWithStoutPipe(testReattachConfig1, nil))), 65 | }, 66 | want: want{ 67 | reattachConfig: fmt.Sprintf(`{"provider-test":{"Protocol":"grpc","ProtocolVersion":5,"Pid":%d,"Test": true,"Addr":{"Network": "unix","String": "test1"}}}`, os.Getpid()), 68 | }, 69 | }, 70 | "AlreadyRunning": { 71 | args: args{ 72 | runner: &SharedProvider{ 73 | nativeProviderPath: testPath, 74 | reattachConfig: "test1", 75 | logger: logging.NewNopLogger(), 76 | executor: newExecutorWithStoutPipe(testReattachConfig2, nil), 77 | mu: &sync.Mutex{}, 78 | }, 79 | }, 80 | want: want{ 81 | reattachConfig: "test1", 82 | }, 83 | }, 84 | "NativeProviderError": { 85 | args: args{ 86 | runner: NewSharedProvider(logging.NewNopLogger(), testPath, testName, 87 | WithNativeProviderExecutor(newExecutorWithStoutPipe(testReattachConfig1, testErr))), 88 | }, 89 | want: want{ 90 | err: testErr, 91 | }, 92 | }, 93 | "NativeProviderTimeout": { 94 | args: args{ 95 | runner: &SharedProvider{ 96 | nativeProviderPath: testPath, 97 | logger: logging.NewNopLogger(), 98 | executor: newExecutorWithStoutPipe("invalid", nil), 99 | mu: &sync.Mutex{}, 100 | clock: &fakeClock{}, 101 | }, 102 | }, 103 | want: want{ 104 | err: errors.Errorf(errFmtTimeout, reattachTimeout), 105 | }, 106 | }, 107 | } 108 | for name, tt := range tests { 109 | t.Run(name, func(t *testing.T) { 110 | reattachConfig, err := tt.args.runner.Start() 111 | if diff := cmp.Diff(tt.want.err, err, test.EquateErrors()); diff != "" { 112 | t.Errorf("\n%s\nStartSharedServer(): -want error, +got error:\n%s", name, diff) 113 | } 114 | if err != nil { 115 | return 116 | } 117 | if diff := cmp.Diff(reattachConfig, tt.want.reattachConfig); diff != "" { 118 | t.Errorf("\n%s\nStartSharedServer(): -want reattachConfig, +got reattachConfig:\n%s", name, diff) 119 | } 120 | }) 121 | } 122 | } 123 | 124 | type fakeClock struct { 125 | clock.FakeClock 126 | } 127 | 128 | func (f *fakeClock) After(d time.Duration) <-chan time.Time { 129 | defer func() { 130 | f.Step(reattachTimeout) 131 | }() 132 | return f.FakeClock.After(d) 133 | } 134 | 135 | func newExecutorWithStoutPipe(reattachConfig string, err error) exec.Interface { 136 | return &testingexec.FakeExec{ 137 | CommandScript: []testingexec.FakeCommandAction{ 138 | func(cmd string, args ...string) exec.Cmd { 139 | return &testingexec.FakeCmd{ 140 | StdoutPipeResponse: testingexec.FakeStdIOPipeResponse{ 141 | ReadCloser: io.NopCloser(strings.NewReader(reattachConfig)), 142 | Error: err, 143 | }, 144 | } 145 | }, 146 | }, 147 | } 148 | } 149 | 150 | func TestWithNativeProviderArgs(t *testing.T) { 151 | tests := map[string]struct { 152 | args []string 153 | want []string 154 | }{ 155 | "NotConfigured": {}, 156 | "Configured": { 157 | args: []string{"a", "b", "c"}, 158 | want: []string{"a", "b", "c"}, 159 | }, 160 | } 161 | for name, tt := range tests { 162 | t.Run(name, func(t *testing.T) { 163 | sr := &SharedProvider{} 164 | WithNativeProviderArgs(tt.args...)(sr) 165 | if !reflect.DeepEqual(sr.nativeProviderArgs, tt.want) { 166 | t.Errorf("WithNativeProviderArgs(tt.args) = %v, want %v", sr.nativeProviderArgs, tt.want) 167 | } 168 | }) 169 | } 170 | } 171 | 172 | func TestWithNativeProviderExecutor(t *testing.T) { 173 | tests := map[string]struct { 174 | executor exec.Interface 175 | want exec.Interface 176 | }{ 177 | "NotConfigured": {}, 178 | "Configured": { 179 | executor: &testingexec.FakeExec{}, 180 | want: &testingexec.FakeExec{}, 181 | }, 182 | } 183 | for name, tt := range tests { 184 | t.Run(name, func(t *testing.T) { 185 | sr := &SharedProvider{} 186 | WithNativeProviderExecutor(tt.executor)(sr) 187 | if !reflect.DeepEqual(sr.executor, tt.want) { 188 | t.Errorf("WithNativeProviderExecutor(tt.executor) = %v, want %v", sr.executor, tt.want) 189 | } 190 | }) 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /pkg/terraform/store.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 terraform 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | "path/filepath" 24 | "sync" 25 | 26 | "github.com/crossplane/crossplane-runtime/pkg/logging" 27 | xpresource "github.com/crossplane/crossplane-runtime/pkg/resource" 28 | "github.com/pkg/errors" 29 | "github.com/spf13/afero" 30 | "k8s.io/apimachinery/pkg/types" 31 | "k8s.io/utils/exec" 32 | "sigs.k8s.io/controller-runtime/pkg/client" 33 | 34 | "github.com/crossplane/terrajet/pkg/config" 35 | "github.com/crossplane/terrajet/pkg/resource" 36 | ) 37 | 38 | const ( 39 | fmtEnv = "%s=%s" 40 | ) 41 | 42 | // SetupFn is a function that returns Terraform setup which contains 43 | // provider requirement, configuration and Terraform version. 44 | type SetupFn func(ctx context.Context, client client.Client, mg xpresource.Managed) (Setup, error) 45 | 46 | // ProviderRequirement holds values for the Terraform HCL setup requirements 47 | type ProviderRequirement struct { 48 | Source string 49 | Version string 50 | } 51 | 52 | // ProviderConfiguration holds the setup configuration body 53 | type ProviderConfiguration map[string]interface{} 54 | 55 | // Setup holds values for the Terraform version and setup 56 | // requirements and configuration body 57 | type Setup struct { 58 | Version string 59 | Requirement ProviderRequirement 60 | Configuration ProviderConfiguration 61 | Env []string 62 | } 63 | 64 | // WorkspaceStoreOption lets you configure the workspace store. 65 | type WorkspaceStoreOption func(*WorkspaceStore) 66 | 67 | // WithFs lets you set the fs of WorkspaceStore. Used mostly for testing. 68 | func WithFs(fs afero.Fs) WorkspaceStoreOption { 69 | return func(ws *WorkspaceStore) { 70 | ws.fs = afero.Afero{Fs: fs} 71 | } 72 | } 73 | 74 | // WithProviderRunner sets the ProviderRunner to be used. 75 | func WithProviderRunner(pr ProviderRunner) WorkspaceStoreOption { 76 | return func(ws *WorkspaceStore) { 77 | ws.providerRunner = pr 78 | } 79 | } 80 | 81 | // NewWorkspaceStore returns a new WorkspaceStore. 82 | func NewWorkspaceStore(l logging.Logger, opts ...WorkspaceStoreOption) *WorkspaceStore { 83 | ws := &WorkspaceStore{ 84 | store: map[types.UID]*Workspace{}, 85 | logger: l, 86 | mu: sync.Mutex{}, 87 | fs: afero.Afero{Fs: afero.NewOsFs()}, 88 | executor: exec.New(), 89 | providerRunner: NewNoOpProviderRunner(), 90 | } 91 | for _, f := range opts { 92 | f(ws) 93 | } 94 | return ws 95 | } 96 | 97 | // WorkspaceStore allows you to manage multiple Terraform workspaces. 98 | type WorkspaceStore struct { 99 | // store holds information about ongoing operations of given resource. 100 | // Since there can be multiple calls that add/remove values from the map at 101 | // the same time, it has to be safe for concurrency since those operations 102 | // cause rehashing in some cases. 103 | store map[types.UID]*Workspace 104 | logger logging.Logger 105 | providerRunner ProviderRunner 106 | mu sync.Mutex 107 | 108 | fs afero.Afero 109 | executor exec.Interface 110 | } 111 | 112 | // Workspace makes sure the Terraform workspace for the given resource is ready 113 | // to be used and returns the Workspace object configured to work in that 114 | // workspace folder in the filesystem. 115 | func (ws *WorkspaceStore) Workspace(ctx context.Context, c resource.SecretClient, tr resource.Terraformed, ts Setup, cfg *config.Resource) (*Workspace, error) { //nolint:gocyclo 116 | dir := filepath.Join(ws.fs.GetTempDir(""), string(tr.GetUID())) 117 | if err := ws.fs.MkdirAll(dir, os.ModePerm); err != nil { 118 | return nil, errors.Wrap(err, "cannot create directory for workspace") 119 | } 120 | fp, err := NewFileProducer(ctx, c, dir, tr, ts, cfg) 121 | if err != nil { 122 | return nil, errors.Wrap(err, "cannot create a new file producer") 123 | } 124 | _, err = ws.fs.Stat(filepath.Join(fp.Dir, "terraform.tfstate")) 125 | if xpresource.Ignore(os.IsNotExist, err) != nil { 126 | return nil, errors.Wrap(err, "cannot stat terraform.tfstate file") 127 | } 128 | if os.IsNotExist(err) { 129 | if err := fp.WriteTFState(ctx); err != nil { 130 | return nil, errors.Wrap(err, "cannot reproduce tfstate file") 131 | } 132 | } 133 | if err := fp.WriteMainTF(); err != nil { 134 | return nil, errors.Wrap(err, "cannot write main tf file") 135 | } 136 | l := ws.logger.WithValues("workspace", dir) 137 | attachmentConfig, err := ws.providerRunner.Start() 138 | if err != nil { 139 | return nil, err 140 | } 141 | ws.mu.Lock() 142 | w, ok := ws.store[tr.GetUID()] 143 | if !ok { 144 | ws.store[tr.GetUID()] = NewWorkspace(dir, WithLogger(l), WithExecutor(ws.executor)) 145 | w = ws.store[tr.GetUID()] 146 | } 147 | ws.mu.Unlock() 148 | _, err = ws.fs.Stat(filepath.Join(dir, ".terraform.lock.hcl")) 149 | if xpresource.Ignore(os.IsNotExist, err) != nil { 150 | return nil, errors.Wrap(err, "cannot stat init lock file") 151 | } 152 | w.env = ts.Env 153 | w.env = append(w.env, fmt.Sprintf(fmtEnv, envReattachConfig, attachmentConfig)) 154 | // We need to initialize only if the workspace hasn't been initialized yet. 155 | if !os.IsNotExist(err) { 156 | return w, nil 157 | } 158 | cmd := w.executor.CommandContext(ctx, "terraform", "init", "-input=false") 159 | cmd.SetDir(w.dir) 160 | out, err := cmd.CombinedOutput() 161 | l.Debug("init ended", "out", string(out)) 162 | return w, errors.Wrapf(err, "cannot init workspace: %s", string(out)) 163 | } 164 | 165 | // Remove deletes the workspace directory from the filesystem and erases its 166 | // record from the store. 167 | func (ws *WorkspaceStore) Remove(obj xpresource.Object) error { 168 | ws.mu.Lock() 169 | defer ws.mu.Unlock() 170 | w, ok := ws.store[obj.GetUID()] 171 | if !ok { 172 | return nil 173 | } 174 | if err := ws.fs.RemoveAll(w.dir); err != nil { 175 | return errors.Wrap(err, "cannot remove workspace folder") 176 | } 177 | delete(ws.store, obj.GetUID()) 178 | return nil 179 | } 180 | -------------------------------------------------------------------------------- /pkg/terraform/timeouts.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 | 17 | package terraform 18 | 19 | import ( 20 | "github.com/crossplane/crossplane-runtime/pkg/errors" 21 | 22 | "github.com/crossplane/terrajet/pkg/config" 23 | "github.com/crossplane/terrajet/pkg/resource/json" 24 | ) 25 | 26 | // "e2bfb730-ecaa-11e6-8f88-34363bc7c4c0" is a hardcoded string for Terraform 27 | // timeout key in private raw, i.e. provider specific metadata: 28 | // https://github.com/hashicorp/terraform-plugin-sdk/blob/112e2164c381d80e8ada3170dac9a8a5db01079a/helper/schema/resource_timeout.go#L14 29 | const tfMetaTimeoutKey = "e2bfb730-ecaa-11e6-8f88-34363bc7c4c0" 30 | 31 | type timeouts config.OperationTimeouts 32 | 33 | func (ts timeouts) asParameter() map[string]string { 34 | param := make(map[string]string) 35 | if t := ts.Read.String(); t != "0s" { 36 | param["read"] = t 37 | } 38 | if t := ts.Create.String(); t != "0s" { 39 | param["create"] = t 40 | } 41 | if t := ts.Update.String(); t != "0s" { 42 | param["update"] = t 43 | } 44 | if t := ts.Delete.String(); t != "0s" { 45 | param["delete"] = t 46 | } 47 | return param 48 | } 49 | 50 | func (ts timeouts) asMetadata() map[string]interface{} { 51 | // See how timeouts encoded as metadata on Terraform side: 52 | // https://github.com/hashicorp/terraform-plugin-sdk/blob/112e2164c381d80e8ada3170dac9a8a5db01079a/helper/schema/resource_timeout.go#L170 53 | meta := make(map[string]interface{}) 54 | if t := ts.Read.String(); t != "0s" { 55 | meta["read"] = ts.Read.Nanoseconds() 56 | } 57 | if t := ts.Create.String(); t != "0s" { 58 | meta["create"] = ts.Create.Nanoseconds() 59 | } 60 | if t := ts.Update.String(); t != "0s" { 61 | meta["update"] = ts.Update.Nanoseconds() 62 | } 63 | if t := ts.Delete.String(); t != "0s" { 64 | meta["delete"] = ts.Delete.Nanoseconds() 65 | } 66 | return meta 67 | } 68 | 69 | func insertTimeoutsMeta(existingMeta []byte, to timeouts) ([]byte, error) { 70 | customTimeouts := to.asMetadata() 71 | if len(customTimeouts) == 0 { 72 | // No custom timeout configured, nothing to do. 73 | return existingMeta, nil 74 | } 75 | meta := make(map[string]interface{}) 76 | if len(existingMeta) == 0 { 77 | // No existing data, just initialize a new meta with custom timeouts. 78 | meta[tfMetaTimeoutKey] = customTimeouts 79 | return json.JSParser.Marshal(meta) 80 | } 81 | // There are some existing metadata, let's parse it to insert custom 82 | // timeouts properly. 83 | if err := json.JSParser.Unmarshal(existingMeta, &meta); err != nil { 84 | return nil, errors.Wrap(err, "cannot parse existing metadata") 85 | } 86 | if existingTimeouts, ok := meta[tfMetaTimeoutKey].(map[string]interface{}); ok { 87 | // There are some timeout configuration exists in existing metadata. 88 | // Only override custom timeouts. 89 | for k, v := range customTimeouts { 90 | existingTimeouts[k] = v 91 | } 92 | return json.JSParser.Marshal(meta) 93 | } 94 | // No existing timeout configuration, initialize it with custom timeouts. 95 | meta[tfMetaTimeoutKey] = customTimeouts 96 | return json.JSParser.Marshal(meta) 97 | } 98 | -------------------------------------------------------------------------------- /pkg/terraform/timeouts_test.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 | 17 | package terraform 18 | 19 | import ( 20 | "testing" 21 | "time" 22 | 23 | "github.com/google/go-cmp/cmp" 24 | 25 | "github.com/crossplane/crossplane-runtime/pkg/errors" 26 | "github.com/crossplane/crossplane-runtime/pkg/test" 27 | ) 28 | 29 | func TestTimeoutsAsParameter(t *testing.T) { 30 | type args struct { 31 | to timeouts 32 | } 33 | type want struct { 34 | out map[string]string 35 | } 36 | cases := map[string]struct { 37 | args 38 | want 39 | }{ 40 | "NoTimeouts": { 41 | want: want{ 42 | out: map[string]string{}, 43 | }, 44 | }, 45 | "SomeTimeout": { 46 | args: args{ 47 | to: timeouts{ 48 | Read: 3 * time.Minute, 49 | }, 50 | }, 51 | want: want{ 52 | out: map[string]string{ 53 | "read": "3m0s", 54 | }, 55 | }, 56 | }, 57 | "AllTimeouts": { 58 | args: args{ 59 | to: timeouts{ 60 | Create: time.Minute, 61 | Update: 2 * time.Minute, 62 | Read: 3 * time.Minute, 63 | Delete: 4 * time.Minute, 64 | }, 65 | }, 66 | want: want{ 67 | out: map[string]string{ 68 | "create": "1m0s", 69 | "update": "2m0s", 70 | "read": "3m0s", 71 | "delete": "4m0s", 72 | }, 73 | }, 74 | }, 75 | } 76 | for name, tc := range cases { 77 | t.Run(name, func(t *testing.T) { 78 | got := tc.args.to.asParameter() 79 | if diff := cmp.Diff(tc.want.out, got); diff != "" { 80 | t.Errorf("\n%s\nasParameter(...): -want out, +got out:\n%s", name, diff) 81 | } 82 | }) 83 | } 84 | } 85 | func TestTimeoutsAsMetadata(t *testing.T) { 86 | type args struct { 87 | to timeouts 88 | } 89 | type want struct { 90 | out map[string]interface{} 91 | } 92 | cases := map[string]struct { 93 | args 94 | want 95 | }{ 96 | "NoTimeouts": { 97 | want: want{ 98 | out: map[string]interface{}{}, 99 | }, 100 | }, 101 | "SomeTimeout": { 102 | args: args{ 103 | to: timeouts{ 104 | Read: 3 * time.Minute, 105 | }, 106 | }, 107 | want: want{ 108 | out: map[string]interface{}{ 109 | "read": int64(180000000000), 110 | }, 111 | }, 112 | }, 113 | "AllTimeouts": { 114 | args: args{ 115 | to: timeouts{ 116 | Create: time.Minute, 117 | Update: 2 * time.Minute, 118 | Read: 3 * time.Minute, 119 | Delete: 4 * time.Minute, 120 | }, 121 | }, 122 | want: want{ 123 | out: map[string]interface{}{ 124 | "create": int64(60000000000), 125 | "update": int64(120000000000), 126 | "read": int64(180000000000), 127 | "delete": int64(240000000000), 128 | }, 129 | }, 130 | }, 131 | } 132 | for name, tc := range cases { 133 | t.Run(name, func(t *testing.T) { 134 | got := tc.args.to.asMetadata() 135 | if diff := cmp.Diff(tc.want.out, got); diff != "" { 136 | t.Errorf("\n%s\nasParameter(...): -want out, +got out:\n%s", name, diff) 137 | } 138 | }) 139 | } 140 | } 141 | func TestInsertTimeoutsMeta(t *testing.T) { 142 | type args struct { 143 | rawMeta []byte 144 | to timeouts 145 | } 146 | type want struct { 147 | out []byte 148 | err error 149 | } 150 | cases := map[string]struct { 151 | args 152 | want 153 | }{ 154 | "NoTimeoutNoMeta": {}, 155 | "NoMetaButTimeout": { 156 | args: args{ 157 | to: timeouts{ 158 | Read: 2 * time.Minute, 159 | }, 160 | }, 161 | want: want{ 162 | out: []byte(`{"e2bfb730-ecaa-11e6-8f88-34363bc7c4c0":{"read":120000000000}}`), 163 | }, 164 | }, 165 | "NonNilMetaButTimeout": { 166 | args: args{ 167 | rawMeta: []byte(`{}`), 168 | to: timeouts{ 169 | Read: 2 * time.Minute, 170 | }, 171 | }, 172 | want: want{ 173 | out: []byte(`{"e2bfb730-ecaa-11e6-8f88-34363bc7c4c0":{"read":120000000000}}`), 174 | }, 175 | }, 176 | "CannotParseExistingMeta": { 177 | args: args{ 178 | rawMeta: []byte(`{malformed}`), 179 | to: timeouts{ 180 | Read: 2 * time.Minute, 181 | }, 182 | }, 183 | want: want{ 184 | err: errors.Wrap(errors.New(`ReadString: expects " or n, but found m, error found in #2 byte of ...|{malformed}|..., bigger context ...|{malformed}|...`), `cannot parse existing metadata`), // nolint: golint 185 | }, 186 | }, 187 | "ExistingMetaAndTimeout": { 188 | args: args{ 189 | rawMeta: []byte(`{"some-key":"some-value"}`), 190 | to: timeouts{ 191 | Read: 2 * time.Minute, 192 | }, 193 | }, 194 | want: want{ 195 | out: []byte(`{"e2bfb730-ecaa-11e6-8f88-34363bc7c4c0":{"read":120000000000},"some-key":"some-value"}`), 196 | }, 197 | }, 198 | "ExistingMetaNoTimeout": { 199 | args: args{ 200 | rawMeta: []byte(`{"some-key":"some-value"}`), 201 | }, 202 | want: want{ 203 | out: []byte(`{"some-key":"some-value"}`), 204 | }, 205 | }, 206 | "ExistingMetaOverridesSomeTimeout": { 207 | args: args{ 208 | rawMeta: []byte(`{"e2bfb730-ecaa-11e6-8f88-34363bc7c4c0":{"create":240000000000,"read":120000000000},"some-key":"some-value"}`), 209 | to: timeouts{ 210 | Read: 1 * time.Minute, 211 | }, 212 | }, 213 | want: want{ 214 | out: []byte(`{"e2bfb730-ecaa-11e6-8f88-34363bc7c4c0":{"create":240000000000,"read":60000000000},"some-key":"some-value"}`), 215 | }, 216 | }, 217 | } 218 | for name, tc := range cases { 219 | t.Run(name, func(t *testing.T) { 220 | got, err := insertTimeoutsMeta(tc.args.rawMeta, tc.args.to) 221 | if diff := cmp.Diff(tc.want.err, err, test.EquateErrors()); diff != "" { 222 | t.Errorf("\n%s\ninsertTimeoutsMeta(...): -want error, +got error:\n%s", name, diff) 223 | } 224 | if diff := cmp.Diff(tc.want.out, got); diff != "" { 225 | t.Errorf("\n%s\ninsertTimeoutsMeta(...): -want out, +got out:\n%s", name, diff) 226 | } 227 | }) 228 | } 229 | } 230 | -------------------------------------------------------------------------------- /pkg/types/comments/comment.go: -------------------------------------------------------------------------------- 1 | package comments 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/crossplane/terrajet/pkg/config" 7 | "github.com/crossplane/terrajet/pkg/types/markers" 8 | ) 9 | 10 | // Option is a comment option 11 | type Option func(*Comment) 12 | 13 | // WithReferenceConfig returns a comment options with the given reference config 14 | func WithReferenceConfig(cfg config.Reference) Option { 15 | return func(c *Comment) { 16 | c.Reference = cfg 17 | } 18 | } 19 | 20 | // WithTFTag returns a comment options with input tf tag 21 | func WithTFTag(s string) Option { 22 | return func(c *Comment) { 23 | c.FieldTFTag = &s 24 | } 25 | } 26 | 27 | // New returns a Comment by parsing Terrajet markers as Options 28 | func New(text string, opts ...Option) (*Comment, error) { 29 | to := markers.TerrajetOptions{} 30 | co := markers.CrossplaneOptions{} 31 | 32 | rawLines := strings.Split(strings.TrimSpace(text), "\n") 33 | lines := make([]string, 0, len(rawLines)) 34 | for _, rl := range rawLines { 35 | rl = strings.TrimSpace(rl) 36 | if rl == "" { 37 | lines = append(lines, rl) 38 | continue 39 | } 40 | // Only add raw marker line if not processed as an option (e.g. if it is 41 | // not a known marker.) Known markers will still be printed as 42 | // comments while building from options. 43 | parsed, err := markers.ParseAsTerrajetOption(&to, rl) 44 | if err != nil { 45 | return nil, err 46 | } 47 | if parsed { 48 | continue 49 | } 50 | 51 | lines = append(lines, rl) 52 | } 53 | 54 | c := &Comment{ 55 | Text: strings.Join(lines, "\n"), 56 | Options: markers.Options{ 57 | TerrajetOptions: to, 58 | CrossplaneOptions: co, 59 | }, 60 | } 61 | 62 | for _, o := range opts { 63 | o(c) 64 | } 65 | 66 | return c, nil 67 | } 68 | 69 | // Comment represents a comment with text and supported marker options. 70 | type Comment struct { 71 | Text string 72 | markers.Options 73 | } 74 | 75 | // String returns a string representation of this Comment (no "// " prefix) 76 | func (c *Comment) String() string { 77 | if c.Text == "" { 78 | return c.Options.String() 79 | } 80 | return c.Text + "\n" + c.Options.String() 81 | } 82 | 83 | // Build builds comments by adding comment prefix ("// ") to each line of 84 | // the string representation of this Comment. 85 | func (c *Comment) Build() string { 86 | all := strings.ReplaceAll("// "+c.String(), "\n", "\n// ") 87 | return strings.TrimSuffix(all, "// ") 88 | } 89 | -------------------------------------------------------------------------------- /pkg/types/comments/comment_test.go: -------------------------------------------------------------------------------- 1 | package comments 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/crossplane/crossplane-runtime/pkg/test" 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/pkg/errors" 10 | 11 | "github.com/crossplane/terrajet/pkg/config" 12 | "github.com/crossplane/terrajet/pkg/types/markers" 13 | ) 14 | 15 | func TestComment_Build(t *testing.T) { 16 | tftag := "-" 17 | type args struct { 18 | text string 19 | opts []Option 20 | } 21 | type want struct { 22 | out string 23 | mopts markers.Options 24 | err error 25 | } 26 | 27 | cases := map[string]struct { 28 | args 29 | want 30 | }{ 31 | "OnlyTextNoMarker": { 32 | args: args{ 33 | text: "hello world!", 34 | }, 35 | want: want{ 36 | out: "// hello world!\n", 37 | mopts: markers.Options{}, 38 | }, 39 | }, 40 | "MultilineTextNoMarker": { 41 | args: args{ 42 | text: `hello world! 43 | this is a test 44 | yes, this is a test`, 45 | }, 46 | want: want{ 47 | out: `// hello world! 48 | // this is a test 49 | // yes, this is a test 50 | `, 51 | mopts: markers.Options{}, 52 | }, 53 | }, 54 | "TextWithTerrajetMarker": { 55 | args: args{ 56 | text: `hello world! 57 | +terrajet:crd:field:TFTag=- 58 | `, 59 | }, 60 | want: want{ 61 | out: `// hello world! 62 | // +terrajet:crd:field:TFTag=- 63 | `, 64 | mopts: markers.Options{ 65 | TerrajetOptions: markers.TerrajetOptions{ 66 | FieldTFTag: &tftag, 67 | }, 68 | }, 69 | }, 70 | }, 71 | "TextWithOtherMarker": { 72 | args: args{ 73 | text: `hello world! 74 | +kubebuilder:validation:Required 75 | `, 76 | }, 77 | want: want{ 78 | out: `// hello world! 79 | // +kubebuilder:validation:Required 80 | `, 81 | mopts: markers.Options{}, 82 | }, 83 | }, 84 | "CommentWithTerrajetOptions": { 85 | args: args{ 86 | text: `hello world!`, 87 | opts: []Option{ 88 | WithTFTag("-"), 89 | }, 90 | }, 91 | want: want{ 92 | out: `// hello world! 93 | // +terrajet:crd:field:TFTag=- 94 | `, 95 | mopts: markers.Options{ 96 | TerrajetOptions: markers.TerrajetOptions{ 97 | FieldTFTag: &tftag, 98 | }, 99 | }, 100 | }, 101 | }, 102 | "CommentWithMixedOptions": { 103 | args: args{ 104 | text: `hello world!`, 105 | opts: []Option{ 106 | WithTFTag("-"), 107 | WithReferenceConfig(config.Reference{ 108 | Type: reflect.TypeOf(Comment{}).String(), 109 | }), 110 | }, 111 | }, 112 | want: want{ 113 | out: `// hello world! 114 | // +terrajet:crd:field:TFTag=- 115 | // +crossplane:generate:reference:type=comments.Comment 116 | `, 117 | mopts: markers.Options{ 118 | TerrajetOptions: markers.TerrajetOptions{ 119 | FieldTFTag: &tftag, 120 | }, 121 | CrossplaneOptions: markers.CrossplaneOptions{ 122 | Reference: config.Reference{ 123 | Type: "comments.Comment", 124 | }, 125 | }, 126 | }, 127 | }, 128 | }, 129 | "CommentWithUnsupportedTerrajetMarker": { 130 | args: args{ 131 | text: `hello world! 132 | +terrajet:crd:field:TFTag=- 133 | +terrajet:unsupported:key=value 134 | `, 135 | }, 136 | want: want{ 137 | err: errors.New("cannot parse as a terrajet prefix: +terrajet:unsupported:key=value"), 138 | }, 139 | }, 140 | } 141 | for name, tc := range cases { 142 | t.Run(name, func(t *testing.T) { 143 | c, gotErr := New(tc.text, tc.opts...) 144 | if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { 145 | t.Fatalf("comment.New(...): -want error, +got error: %s", diff) 146 | } 147 | if gotErr != nil { 148 | return 149 | } 150 | if diff := cmp.Diff(tc.want.mopts, c.Options); diff != "" { 151 | t.Errorf("comment.New(...) opts = %v, want %v", c.Options, tc.want.mopts) 152 | } 153 | got := c.Build() 154 | if diff := cmp.Diff(tc.want.out, got); diff != "" { 155 | t.Errorf("Build() out = %v, want %v", got, tc.want.out) 156 | } 157 | }) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /pkg/types/conversion/sdkv1/sdkv1.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 | 17 | package sdkv1 18 | 19 | import ( 20 | "github.com/hashicorp/terraform-plugin-sdk/helper/schema" 21 | "github.com/hashicorp/terraform-plugin-sdk/terraform" 22 | schemav2 "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 23 | "github.com/pkg/errors" 24 | ) 25 | 26 | // GetV2ResourceMap returns a Terraform provider SDK v2 resource map for an 27 | // input SDK v1 Terraform Resource Provider. 28 | // Deprecated: This functionality will be removed soon, please use 29 | // tfjson.GetV2ResourceMap instead. 30 | func GetV2ResourceMap(p terraform.ResourceProvider) map[string]*schemav2.Resource { 31 | v1map := p.(*schema.Provider).ResourcesMap 32 | v2map := make(map[string]*schemav2.Resource, len(v1map)) 33 | for k, v := range v1map { 34 | v2map[k] = toV2Resource(v) 35 | } 36 | return v2map 37 | } 38 | 39 | func toV2Resource(v1res *schema.Resource) *schemav2.Resource { 40 | v1SchemaMap := v1res.Schema 41 | v2SchemaMap := make(map[string]*schemav2.Schema, len(v1SchemaMap)) 42 | for k, v := range v1SchemaMap { 43 | v2SchemaMap[k] = toV2Schema(v) 44 | } 45 | v2Res := &schemav2.Resource{ 46 | Schema: v2SchemaMap, 47 | SchemaVersion: v1res.SchemaVersion, 48 | DeprecationMessage: v1res.DeprecationMessage, 49 | Timeouts: (*schemav2.ResourceTimeout)(v1res.Timeouts), 50 | } 51 | return v2Res 52 | } 53 | 54 | func toV2Schema(v1sch *schema.Schema) *schemav2.Schema { 55 | v2sch := &schemav2.Schema{ 56 | Type: schemav2.ValueType(v1sch.Type), 57 | ConfigMode: schemav2.SchemaConfigMode(v1sch.ConfigMode), 58 | Optional: v1sch.Optional, 59 | Required: v1sch.Required, 60 | Default: v1sch.Default, 61 | DefaultFunc: schemav2.SchemaDefaultFunc(v1sch.DefaultFunc), 62 | Description: v1sch.Description, 63 | InputDefault: v1sch.InputDefault, 64 | Computed: v1sch.Computed, 65 | ForceNew: v1sch.ForceNew, 66 | StateFunc: schemav2.SchemaStateFunc(v1sch.StateFunc), 67 | MaxItems: v1sch.MaxItems, 68 | MinItems: v1sch.MinItems, 69 | Set: schemav2.SchemaSetFunc(v1sch.Set), 70 | ComputedWhen: v1sch.ComputedWhen, 71 | ConflictsWith: v1sch.ConflictsWith, 72 | ExactlyOneOf: v1sch.ExactlyOneOf, 73 | AtLeastOneOf: v1sch.AtLeastOneOf, 74 | Deprecated: v1sch.Deprecated, 75 | Sensitive: v1sch.Sensitive, 76 | } 77 | v2sch.Type = schemav2.ValueType(v1sch.Type) 78 | switch v1sch.Type { 79 | case schema.TypeBool, schema.TypeInt, schema.TypeString, schema.TypeFloat: 80 | // no action required 81 | case schema.TypeMap, schema.TypeList, schema.TypeSet: 82 | switch et := v1sch.Elem.(type) { 83 | case schema.ValueType: 84 | v2sch.Elem = v1sch.Elem.(schemav2.ValueType) 85 | case *schema.Schema: 86 | v2sch.Elem = toV2Schema(et) 87 | case *schema.Resource: 88 | v2sch.Elem = toV2Resource(et) 89 | default: 90 | // Note(turkenh): We are defaulting to "String" as element type when 91 | // it is not explicitly provided as element type of a collection. 92 | v2sch.Elem = schemav2.TypeString 93 | } 94 | case schema.TypeInvalid: 95 | panic(errors.Errorf("invalid schema type %s", v1sch.Type.String())) 96 | default: 97 | panic(errors.Errorf("unexpected schema type %s", v1sch.Type.String())) 98 | } 99 | 100 | return v2sch 101 | } 102 | -------------------------------------------------------------------------------- /pkg/types/field.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "go/token" 6 | "go/types" 7 | "strings" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 10 | "github.com/pkg/errors" 11 | 12 | "github.com/crossplane/terrajet/pkg/config" 13 | "github.com/crossplane/terrajet/pkg/types/comments" 14 | "github.com/crossplane/terrajet/pkg/types/name" 15 | ) 16 | 17 | // Field represents a field that is built from the Terraform schema. 18 | // It contains the go field related information such as tags, field type, comment. 19 | type Field struct { 20 | Schema *schema.Schema 21 | Name name.Name 22 | Comment *comments.Comment 23 | TFTag, JSONTag, FieldNameCamel string 24 | TerraformPaths, CRDPaths, CanonicalPaths []string 25 | FieldType types.Type 26 | AsBlocksMode bool 27 | Reference *config.Reference 28 | } 29 | 30 | // NewField returns a constructed Field object. 31 | func NewField(g *Builder, cfg *config.Resource, r *resource, sch *schema.Schema, snakeFieldName string, tfPath, xpPath, names []string, asBlocksMode bool) (*Field, error) { 32 | f := &Field{ 33 | Schema: sch, 34 | Name: name.NewFromSnake(snakeFieldName), 35 | FieldNameCamel: name.NewFromSnake(snakeFieldName).Camel, 36 | AsBlocksMode: asBlocksMode, 37 | } 38 | 39 | comment, err := comments.New(f.Schema.Description) 40 | if err != nil { 41 | return nil, errors.Wrapf(err, "cannot build comment for description: %s", f.Schema.Description) 42 | } 43 | f.Comment = comment 44 | f.TFTag = fmt.Sprintf("%s,omitempty", f.Name.Snake) 45 | f.JSONTag = fmt.Sprintf("%s,omitempty", f.Name.LowerCamelComputed) 46 | 47 | // Terraform paths, e.g. { "lifecycle_rule", "*", "transition", "*", "days" } for https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/s3_bucket#lifecycle_rule 48 | f.TerraformPaths = append(tfPath, f.Name.Snake) // nolint:gocritic 49 | // Crossplane paths, e.g. {"lifecycleRule", "*", "transition", "*", "days"} 50 | f.CRDPaths = append(xpPath, f.Name.LowerCamelComputed) // nolint:gocritic 51 | // Canonical paths, e.g. {"LifecycleRule", "Transition", "Days"} 52 | f.CanonicalPaths = append(names[1:], f.Name.Camel) // nolint:gocritic 53 | 54 | for _, ignoreField := range cfg.LateInitializer.IgnoredFields { 55 | // Convert configuration input from Terraform path to canonical path 56 | // Todo(turkenh/muvaf): Replace with a simple string conversion 57 | // like GetIgnoredCanonicalFields where we just make each word 58 | // between points camel case using names.go utilities. If the path 59 | // doesn't match anything, it's no-op in late-init logic anyway. 60 | if ignoreField == fieldPath(f.TerraformPaths) { 61 | cfg.LateInitializer.AddIgnoredCanonicalFields(fieldPath(f.CanonicalPaths)) 62 | } 63 | } 64 | 65 | fieldType, err := g.buildSchema(f, cfg, names, r) 66 | if err != nil { 67 | return nil, errors.Wrapf(err, "cannot infer type from schema of field %s", f.Name.Snake) 68 | } 69 | f.FieldType = fieldType 70 | 71 | return f, nil 72 | } 73 | 74 | // NewSensitiveField returns a constructed sensitive Field object. 75 | func NewSensitiveField(g *Builder, cfg *config.Resource, r *resource, sch *schema.Schema, snakeFieldName string, tfPath, xpPath, names []string, asBlocksMode bool) (*Field, bool, error) { //nolint:gocyclo 76 | f, err := NewField(g, cfg, r, sch, snakeFieldName, tfPath, xpPath, names, asBlocksMode) 77 | if err != nil { 78 | return nil, false, err 79 | } 80 | 81 | if isObservation(f.Schema) { 82 | cfg.Sensitive.AddFieldPath(fieldPathWithWildcard(f.TerraformPaths), "status.atProvider."+fieldPathWithWildcard(f.CRDPaths)) 83 | // Drop an observation field from schema if it is sensitive. 84 | // Data will be stored in connection details secret 85 | return nil, true, nil 86 | } 87 | sfx := "SecretRef" 88 | cfg.Sensitive.AddFieldPath(fieldPathWithWildcard(f.TerraformPaths), "spec.forProvider."+fieldPathWithWildcard(f.CRDPaths)+sfx) 89 | // todo(turkenh): do we need to support other field types as sensitive? 90 | if f.FieldType.String() != "string" && f.FieldType.String() != "*string" && f.FieldType.String() != "[]string" && 91 | f.FieldType.String() != "[]*string" && f.FieldType.String() != "map[string]string" && f.FieldType.String() != "map[string]*string" { 92 | return nil, false, fmt.Errorf(`got type %q for field %q, only types "string", "*string", []string, []*string, "map[string]string" and "map[string]*string" supported as sensitive`, f.FieldType.String(), f.FieldNameCamel) 93 | } 94 | // Replace a parameter field with secretKeyRef if it is sensitive. 95 | // If it is an observation field, it will be dropped. 96 | // Data will be loaded from the referenced secret key. 97 | f.FieldNameCamel += sfx 98 | 99 | f.TFTag = "-" 100 | switch f.FieldType.String() { 101 | case "string", "*string": 102 | f.FieldType = typeSecretKeySelector 103 | case "[]string", "[]*string": 104 | f.FieldType = types.NewSlice(typeSecretKeySelector) 105 | case "map[string]string", "map[string]*string": 106 | f.FieldType = types.NewMap(types.Universe.Lookup("string").Type(), typeSecretKeySelector) 107 | } 108 | f.JSONTag = name.NewFromCamel(f.FieldNameCamel).LowerCamelComputed 109 | if f.Schema.Optional { 110 | f.FieldType = types.NewPointer(f.FieldType) 111 | f.JSONTag += ",omitempty" 112 | } 113 | return f, false, nil 114 | } 115 | 116 | // NewReferenceField returns a constructed reference Field object. 117 | func NewReferenceField(g *Builder, cfg *config.Resource, r *resource, sch *schema.Schema, ref *config.Reference, snakeFieldName string, tfPath, xpPath, names []string, asBlocksMode bool) (*Field, error) { 118 | f, err := NewField(g, cfg, r, sch, snakeFieldName, tfPath, xpPath, names, asBlocksMode) 119 | if err != nil { 120 | return nil, err 121 | } 122 | f.Reference = ref 123 | 124 | f.Comment.Reference = *ref 125 | f.Schema.Optional = true 126 | 127 | return f, nil 128 | } 129 | 130 | // AddToResource adds built field to the resource. 131 | func (f *Field) AddToResource(g *Builder, r *resource, typeNames *TypeNames) { 132 | if f.Comment.TerrajetOptions.FieldTFTag != nil { 133 | f.TFTag = *f.Comment.TerrajetOptions.FieldTFTag 134 | } 135 | if f.Comment.TerrajetOptions.FieldJSONTag != nil { 136 | f.JSONTag = *f.Comment.TerrajetOptions.FieldJSONTag 137 | } 138 | 139 | field := types.NewField(token.NoPos, g.Package, f.FieldNameCamel, f.FieldType, false) 140 | switch { 141 | case isObservation(f.Schema): 142 | r.addObservationField(f, field) 143 | default: 144 | if f.AsBlocksMode { 145 | f.TFTag = strings.TrimSuffix(f.TFTag, ",omitempty") 146 | } 147 | r.addParameterField(f, field) 148 | } 149 | 150 | if f.Reference != nil { 151 | r.addReferenceFields(g, typeNames.ParameterTypeName, field, *f.Reference) 152 | } 153 | 154 | g.comments.AddFieldComment(typeNames.ParameterTypeName, f.FieldNameCamel, f.Comment.Build()) 155 | } 156 | -------------------------------------------------------------------------------- /pkg/types/markers/crossplane.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/crossplane/terrajet/pkg/config" 7 | ) 8 | 9 | const ( 10 | markerPrefixCrossplane = "+crossplane:" 11 | ) 12 | 13 | var ( 14 | markerPrefixRefType = fmt.Sprintf("%sgenerate:reference:type=", markerPrefixCrossplane) 15 | markerPrefixRefExtractor = fmt.Sprintf("%sgenerate:reference:extractor=", markerPrefixCrossplane) 16 | markerPrefixRefFieldName = fmt.Sprintf("%sgenerate:reference:refFieldName=", markerPrefixCrossplane) 17 | markerPrefixRefSelectorName = fmt.Sprintf("%sgenerate:reference:selectorFieldName=", markerPrefixCrossplane) 18 | ) 19 | 20 | // CrossplaneOptions represents the Crossplane marker options that terrajet 21 | // would need to interact 22 | type CrossplaneOptions struct { 23 | config.Reference 24 | } 25 | 26 | func (o CrossplaneOptions) String() string { 27 | m := "" 28 | 29 | if o.Type != "" { 30 | m += fmt.Sprintf("%s%s\n", markerPrefixRefType, o.Type) 31 | } 32 | if o.Extractor != "" { 33 | m += fmt.Sprintf("%s%s\n", markerPrefixRefExtractor, o.Extractor) 34 | } 35 | if o.RefFieldName != "" { 36 | m += fmt.Sprintf("%s%s\n", markerPrefixRefFieldName, o.RefFieldName) 37 | } 38 | if o.SelectorFieldName != "" { 39 | m += fmt.Sprintf("%s%s\n", markerPrefixRefSelectorName, o.SelectorFieldName) 40 | } 41 | 42 | return m 43 | } 44 | -------------------------------------------------------------------------------- /pkg/types/markers/crossplane_test.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | 8 | "github.com/crossplane/terrajet/pkg/config" 9 | ) 10 | 11 | func TestCrossplaneOptions_String(t *testing.T) { 12 | type args struct { 13 | referenceToType string 14 | referenceExtractor string 15 | referenceFieldName string 16 | referenceSelectorFieldName string 17 | } 18 | type want struct { 19 | out string 20 | } 21 | cases := map[string]struct { 22 | args 23 | want 24 | }{ 25 | "NoOption": { 26 | args: args{ 27 | referenceToType: "", 28 | }, 29 | want: want{ 30 | out: "", 31 | }, 32 | }, 33 | "WithType": { 34 | args: args{ 35 | referenceToType: "SecurityGroup", 36 | }, 37 | want: want{ 38 | out: "+crossplane:generate:reference:type=SecurityGroup\n", 39 | }, 40 | }, 41 | "WithAll": { 42 | args: args{ 43 | referenceToType: "github.com/crossplane/provider-aws/apis/ec2/v1beta1.Subnet", 44 | referenceExtractor: "github.com/crossplane/provider-aws/apis/ec2/v1beta1.SubnetARN()", 45 | referenceFieldName: "SubnetIDRefs", 46 | referenceSelectorFieldName: "SubnetIDSelector", 47 | }, 48 | want: want{ 49 | out: `+crossplane:generate:reference:type=github.com/crossplane/provider-aws/apis/ec2/v1beta1.Subnet 50 | +crossplane:generate:reference:extractor=github.com/crossplane/provider-aws/apis/ec2/v1beta1.SubnetARN() 51 | +crossplane:generate:reference:refFieldName=SubnetIDRefs 52 | +crossplane:generate:reference:selectorFieldName=SubnetIDSelector 53 | `, 54 | }, 55 | }, 56 | } 57 | for name, tc := range cases { 58 | t.Run(name, func(t *testing.T) { 59 | o := CrossplaneOptions{ 60 | Reference: config.Reference{ 61 | Type: tc.referenceToType, 62 | Extractor: tc.referenceExtractor, 63 | RefFieldName: tc.referenceFieldName, 64 | SelectorFieldName: tc.referenceSelectorFieldName, 65 | }, 66 | } 67 | got := o.String() 68 | if diff := cmp.Diff(tc.want.out, got); diff != "" { 69 | t.Errorf("CrossplaneOptions.String(): -want result, +got result: %s", diff) 70 | } 71 | }) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /pkg/types/markers/kubebuilder.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import "fmt" 4 | 5 | // KubebuilderOptions represents the kubebuilder options that terrajet would 6 | // need to control 7 | type KubebuilderOptions struct { 8 | Required *bool 9 | Minimum *int 10 | Maximum *int 11 | } 12 | 13 | func (o KubebuilderOptions) String() string { 14 | m := "" 15 | 16 | if o.Required != nil { 17 | if *o.Required { 18 | m += "+kubebuilder:validation:Required\n" 19 | } else { 20 | m += "+kubebuilder:validation:Optional\n" 21 | } 22 | } 23 | if o.Minimum != nil { 24 | m += fmt.Sprintf("+kubebuilder:validation:Minimum=%d\n", *o.Minimum) 25 | } 26 | if o.Maximum != nil { 27 | m += fmt.Sprintf("+kubebuilder:validation:Maximum=%d\n", *o.Maximum) 28 | } 29 | 30 | return m 31 | } 32 | -------------------------------------------------------------------------------- /pkg/types/markers/kubebuilder_test.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/google/go-cmp/cmp" 7 | ) 8 | 9 | func TestKubebuilderOptions_String(t *testing.T) { 10 | required := true 11 | optional := false 12 | min := 1 13 | max := 3 14 | 15 | type args struct { 16 | required *bool 17 | minimum *int 18 | maximum *int 19 | } 20 | type want struct { 21 | out string 22 | } 23 | cases := map[string]struct { 24 | args 25 | want 26 | }{ 27 | "NoOption": { 28 | args: args{}, 29 | want: want{ 30 | out: "", 31 | }, 32 | }, 33 | "OnlyRequired": { 34 | args: args{ 35 | required: &required, 36 | }, 37 | want: want{ 38 | out: "+kubebuilder:validation:Required\n", 39 | }, 40 | }, 41 | "OptionalWithMinMax": { 42 | args: args{ 43 | required: &optional, 44 | minimum: &min, 45 | maximum: &max, 46 | }, 47 | want: want{ 48 | out: `+kubebuilder:validation:Optional 49 | +kubebuilder:validation:Minimum=1 50 | +kubebuilder:validation:Maximum=3 51 | `, 52 | }, 53 | }, 54 | } 55 | for name, tc := range cases { 56 | t.Run(name, func(t *testing.T) { 57 | o := KubebuilderOptions{ 58 | Required: tc.required, 59 | Minimum: tc.minimum, 60 | Maximum: tc.maximum, 61 | } 62 | got := o.String() 63 | if diff := cmp.Diff(tc.want.out, got); diff != "" { 64 | t.Errorf("KubebuilderOptions.String(): -want result, +got result: %s", diff) 65 | } 66 | }) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /pkg/types/markers/options.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | // Options represents marker options that Terrajet need to parse or set. 4 | type Options struct { 5 | TerrajetOptions 6 | CrossplaneOptions 7 | KubebuilderOptions 8 | } 9 | 10 | // String returns a string representation of this Options object. 11 | func (o Options) String() string { 12 | return o.TerrajetOptions.String() + 13 | o.CrossplaneOptions.String() + 14 | o.KubebuilderOptions.String() 15 | } 16 | -------------------------------------------------------------------------------- /pkg/types/markers/terrajet.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/pkg/errors" 8 | ) 9 | 10 | const ( 11 | markerPrefixTerrajet = "+terrajet:" 12 | 13 | errFmtCannotParseAsTerrajet = "cannot parse as a terrajet prefix: %s" 14 | ) 15 | 16 | var ( 17 | markerPrefixCRDTFTag = fmt.Sprintf("%scrd:field:TFTag=", markerPrefixTerrajet) 18 | markerPrefixCRDJSONTag = fmt.Sprintf("%scrd:field:JSONTag=", markerPrefixTerrajet) 19 | ) 20 | 21 | // TerrajetOptions represents the whole terrajet options that could be 22 | // controlled with markers. 23 | type TerrajetOptions struct { 24 | FieldTFTag *string 25 | FieldJSONTag *string 26 | } 27 | 28 | func (o TerrajetOptions) String() string { 29 | m := "" 30 | 31 | if o.FieldTFTag != nil { 32 | m += fmt.Sprintf("%s%s\n", markerPrefixCRDTFTag, *o.FieldTFTag) 33 | } 34 | if o.FieldJSONTag != nil { 35 | m += fmt.Sprintf("%s%s\n", markerPrefixCRDJSONTag, *o.FieldJSONTag) 36 | } 37 | 38 | return m 39 | } 40 | 41 | // ParseAsTerrajetOption parses input line as a terrajet option, if it is a 42 | // valid Terrajet Option. Returns whether it is parsed or not. 43 | func ParseAsTerrajetOption(opts *TerrajetOptions, line string) (bool, error) { 44 | if !strings.HasPrefix(line, markerPrefixTerrajet) { 45 | return false, nil 46 | } 47 | ln := strings.TrimSpace(line) 48 | if strings.HasPrefix(ln, markerPrefixCRDTFTag) { 49 | t := strings.TrimPrefix(ln, markerPrefixCRDTFTag) 50 | opts.FieldTFTag = &t 51 | return true, nil 52 | } 53 | if strings.HasPrefix(ln, markerPrefixCRDJSONTag) { 54 | t := strings.TrimPrefix(ln, markerPrefixCRDJSONTag) 55 | opts.FieldJSONTag = &t 56 | return true, nil 57 | } 58 | return false, errors.Errorf(errFmtCannotParseAsTerrajet, line) 59 | } 60 | -------------------------------------------------------------------------------- /pkg/types/markers/terrajet_test.go: -------------------------------------------------------------------------------- 1 | package markers 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/crossplane/crossplane-runtime/pkg/test" 8 | "github.com/google/go-cmp/cmp" 9 | "github.com/pkg/errors" 10 | ) 11 | 12 | func Test_parseAsTerrajetOption(t *testing.T) { 13 | customTF := "custom-tf" 14 | customJSON := "custom-json" 15 | 16 | type args struct { 17 | opts *TerrajetOptions 18 | line string 19 | } 20 | type want struct { 21 | opts *TerrajetOptions 22 | parsed bool 23 | err error 24 | } 25 | cases := map[string]struct { 26 | args 27 | want 28 | }{ 29 | "CRDTagTFOnly": { 30 | args: args{ 31 | opts: &TerrajetOptions{}, 32 | line: fmt.Sprintf("%s%s", markerPrefixCRDTFTag, customTF), 33 | }, 34 | want: want{ 35 | opts: &TerrajetOptions{ 36 | FieldTFTag: &customTF, 37 | }, 38 | parsed: true, 39 | }, 40 | }, 41 | "CRDBothTags": { 42 | args: args{ 43 | opts: &TerrajetOptions{ 44 | FieldTFTag: &customTF, 45 | }, 46 | line: fmt.Sprintf("%s%s\n", markerPrefixCRDJSONTag, customJSON), 47 | }, 48 | want: want{ 49 | opts: &TerrajetOptions{ 50 | FieldTFTag: &customTF, 51 | FieldJSONTag: &customJSON, 52 | }, 53 | parsed: true, 54 | }, 55 | }, 56 | "UnknownMarker": { 57 | args: args{ 58 | opts: &TerrajetOptions{}, 59 | line: "+some:other:marker:key=value", 60 | }, 61 | want: want{ 62 | opts: &TerrajetOptions{}, 63 | parsed: false, 64 | err: nil, 65 | }, 66 | }, 67 | "CannotParse": { 68 | args: args{ 69 | opts: &TerrajetOptions{}, 70 | line: "+terrajet:unknownmarker:key=value", 71 | }, 72 | want: want{ 73 | opts: &TerrajetOptions{}, 74 | parsed: false, 75 | err: errors.Errorf(errFmtCannotParseAsTerrajet, "+terrajet:unknownmarker:key=value"), 76 | }, 77 | }, 78 | } 79 | for name, tc := range cases { 80 | t.Run(name, func(t *testing.T) { 81 | opts := tc.args.opts 82 | gotParsed, gotErr := ParseAsTerrajetOption(opts, tc.args.line) 83 | 84 | if diff := cmp.Diff(tc.want.err, gotErr, test.EquateErrors()); diff != "" { 85 | t.Fatalf("ParseAsTerrajetOption(...): -want error, +got error: %s", diff) 86 | } 87 | 88 | if diff := cmp.Diff(tc.want.parsed, gotParsed); diff != "" { 89 | t.Errorf("ParseAsTerrajetOption() parsed = %v, wantParsed %v", gotParsed, tc.want.parsed) 90 | } 91 | 92 | if diff := cmp.Diff(tc.want.opts, opts); diff != "" { 93 | t.Errorf("ParseAsTerrajetOption() opts = %v, wantOpts %v", opts, tc.want.opts) 94 | } 95 | }) 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /pkg/types/name/name.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 name 18 | 19 | import ( 20 | "strings" 21 | 22 | "github.com/fatih/camelcase" 23 | "github.com/iancoleman/strcase" 24 | ) 25 | 26 | // NewFromSnake produces a Name, using given snake case string as source of 27 | // truth. 28 | func NewFromSnake(s string) Name { 29 | originals := strings.Split(s, "_") 30 | camels := make([]string, len(originals)) 31 | computedCamels := make([]string, len(originals)) 32 | for i, org := range originals { 33 | computedCamels[i] = strcase.ToCamel(org) 34 | if known, ok := lowerToCamelAcronyms[strings.ToLower(org)]; ok { 35 | camels[i] = known 36 | continue 37 | } 38 | camels[i] = computedCamels[i] 39 | } 40 | return Name{ 41 | Snake: s, 42 | Camel: strings.Join(camels, ""), 43 | CamelComputed: strings.Join(computedCamels, ""), 44 | LowerCamel: strings.Join(append([]string{strings.ToLower(camels[0])}, camels[1:]...), ""), 45 | LowerCamelComputed: strings.Join(append([]string{strings.ToLower(computedCamels[0])}, computedCamels[1:]...), ""), 46 | } 47 | } 48 | 49 | // NewFromCamel produces a Name, using given camel case string as source of 50 | // truth. 51 | func NewFromCamel(s string) Name { 52 | originals := camelcase.Split(s) 53 | snakes := make([]string, len(originals)) 54 | for i, org := range originals { 55 | snakes[i] = strings.ToLower(org) 56 | } 57 | return NewFromSnake(strings.Join(snakes, "_")) 58 | } 59 | 60 | // Name holds different variants of a name. 61 | type Name struct { 62 | // Snake is the snake case version of the string: rds_instance 63 | Snake string 64 | 65 | // Camel is the camel case version of the string where known acronyms are 66 | // are uppercase: RDSInstance 67 | Camel string 68 | 69 | // LowerCamel is the camel case version with the first word being lower case 70 | // and the known acronyms are uppercase if they are not the first word: rdsInstance 71 | LowerCamel string 72 | 73 | // CamelComputed is the camel case version without any acronym changes: RdsInstance 74 | CamelComputed string 75 | 76 | // LowerCamelComputed is the camel case version without any acronym changes 77 | // and the first word is lower case: rdsInstance 78 | LowerCamelComputed string 79 | } 80 | 81 | // Add acronyms that can be safely assumed to be common for any kind of provider. 82 | // For provider-specific ones, like ARN for Amazon Web Services, provider 83 | // authors need to configure them in their provider repository. 84 | 85 | // NOTE(muvaf): We can have more maps like camel -> lower for reverse conversion, 86 | // but it's not necessary for now. 87 | 88 | var ( 89 | // Used to hold lower -> camel known exceptions. 90 | lowerToCamelAcronyms = map[string]string{ 91 | "id": "ID", 92 | } 93 | ) 94 | 95 | // AddAcronym is used to add exception words that will be used to intervene 96 | // the conversion from lower case to camel case. 97 | // It is suggested that users of this package make all AddAcronym calls before 98 | // any usage (like init()) so that the conversions are consistent across the 99 | // board. 100 | func AddAcronym(lower, camel string) { 101 | lowerToCamelAcronyms[lower] = camel 102 | } 103 | 104 | func init() { 105 | // Written manually 106 | AddAcronym("ipv6", "IPv6") 107 | AddAcronym("ipv4", "IPv4") 108 | 109 | // Taken from golangci-lint staticcheck 110 | // https://github.com/dominikh/go-tools/blob/4049766cbbeee505b10996f03cd3f504aa238734/config/example.conf#L2 111 | AddAcronym("acl", "ACL") 112 | AddAcronym("adm", "ADM") 113 | AddAcronym("ai", "AI") 114 | AddAcronym("alb", "ALB") 115 | AddAcronym("ami", "AMI") 116 | AddAcronym("api", "API") 117 | AddAcronym("apns", "APNS") 118 | AddAcronym("ascii", "ASCII") 119 | AddAcronym("bgp", "BGP") 120 | AddAcronym("ca", "CA") 121 | AddAcronym("cloudformation", "CloudFormation") 122 | AddAcronym("cpu", "CPU") 123 | AddAcronym("css", "CSS") 124 | AddAcronym("dhcp", "DHCP") 125 | AddAcronym("dicom", "DICOM") 126 | AddAcronym("dkim", "DKIM") 127 | AddAcronym("dns", "DNS") 128 | AddAcronym("dnssec", "DNSSEC") 129 | AddAcronym("ebs", "EBS") 130 | AddAcronym("ec2", "EC2") 131 | AddAcronym("efs", "EFS") 132 | AddAcronym("eip", "EIP") 133 | AddAcronym("elb", "ELB") 134 | AddAcronym("eof", "EOF") 135 | AddAcronym("fhir", "FHIR") 136 | AddAcronym("fsx", "FSX") 137 | AddAcronym("gcp", "GCP") 138 | AddAcronym("gcm", "GCM") 139 | AddAcronym("graphql", "GraphQL") 140 | AddAcronym("grpc", "GRPC") 141 | AddAcronym("guid", "GUID") 142 | AddAcronym("haproxy", "HAProxy") 143 | AddAcronym("hsm", "HSM") 144 | AddAcronym("ipset", "IPSet") 145 | AddAcronym("iscsi", "ISCSI") 146 | AddAcronym("hl7", "HL7") 147 | AddAcronym("html", "HTML") 148 | AddAcronym("http", "HTTP") 149 | AddAcronym("https", "HTTPS") 150 | AddAcronym("hmac", "HMAC") 151 | AddAcronym("iam", "IAM") 152 | AddAcronym("id", "ID") 153 | AddAcronym("ip", "IP") 154 | AddAcronym("json", "JSON") 155 | AddAcronym("kms", "KMS") 156 | AddAcronym("lb", "LB") 157 | AddAcronym("ml", "ML") 158 | AddAcronym("mysql", "MySQL") 159 | AddAcronym("nat", "NAT") 160 | AddAcronym("nfs", "NFS") 161 | AddAcronym("nodejs", "NodeJS") 162 | AddAcronym("openid", "OpenID") 163 | AddAcronym("php", "PHP") 164 | AddAcronym("qps", "QPS") 165 | AddAcronym("ram", "RAM") 166 | AddAcronym("rds", "RDS") 167 | AddAcronym("rpc", "RPC") 168 | AddAcronym("saml", "SAML") 169 | AddAcronym("sla", "SLA") 170 | AddAcronym("slo", "SLO") 171 | AddAcronym("smb", "SMB") 172 | AddAcronym("sms", "SMS") 173 | AddAcronym("smtp", "SMTP") 174 | AddAcronym("sql", "SQL") 175 | AddAcronym("ssh", "SSH") 176 | AddAcronym("ssl", "SSL") 177 | AddAcronym("tcp", "TCP") 178 | AddAcronym("tls", "TLS") 179 | AddAcronym("ttl", "TTL") 180 | AddAcronym("udp", "UDP") 181 | AddAcronym("ui", "UI") 182 | AddAcronym("gid", "GID") 183 | AddAcronym("uid", "UID") 184 | AddAcronym("uuid", "UUID") 185 | AddAcronym("uri", "URI") 186 | AddAcronym("url", "URL") 187 | AddAcronym("utf8", "UTF8") 188 | AddAcronym("vm", "VM") 189 | AddAcronym("voip", "VoIP") 190 | AddAcronym("vpc", "VPC") 191 | AddAcronym("vpn", "VPN") 192 | AddAcronym("xml", "XML") 193 | AddAcronym("xmpp", "XMPP") 194 | AddAcronym("xsrf", "XSRF") 195 | AddAcronym("xss", "XSS") 196 | AddAcronym("sip", "SIP") 197 | AddAcronym("rtp", "RTP") 198 | AddAcronym("amqp", "AMQP") 199 | AddAcronym("db", "DB") 200 | AddAcronym("ts", "TS") 201 | 202 | } 203 | -------------------------------------------------------------------------------- /pkg/types/name/name_test.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 name 18 | 19 | import ( 20 | "testing" 21 | 22 | "github.com/google/go-cmp/cmp" 23 | ) 24 | 25 | func TestNewNameFromSnake(t *testing.T) { 26 | cases := map[string]struct { 27 | in string 28 | want Name 29 | }{ 30 | "Normal": { 31 | in: "some_snake", 32 | want: Name{ 33 | Camel: "SomeSnake", 34 | CamelComputed: "SomeSnake", 35 | LowerCamel: "someSnake", 36 | LowerCamelComputed: "someSnake", 37 | Snake: "some_snake", 38 | }, 39 | }, 40 | "AcronymInBeginning": { 41 | in: "id_setting", 42 | want: Name{ 43 | Camel: "IDSetting", 44 | CamelComputed: "IdSetting", 45 | LowerCamel: "idSetting", 46 | LowerCamelComputed: "idSetting", 47 | Snake: "id_setting", 48 | }, 49 | }, 50 | "AcronymInMiddle": { 51 | in: "some_api_param", 52 | want: Name{ 53 | Camel: "SomeAPIParam", 54 | CamelComputed: "SomeApiParam", 55 | LowerCamel: "someAPIParam", 56 | LowerCamelComputed: "someApiParam", 57 | Snake: "some_api_param", 58 | }, 59 | }, 60 | "AcronymInEnd": { 61 | in: "subnet_id", 62 | want: Name{ 63 | Camel: "SubnetID", 64 | CamelComputed: "SubnetId", 65 | LowerCamel: "subnetID", 66 | LowerCamelComputed: "subnetId", 67 | Snake: "subnet_id", 68 | }, 69 | }, 70 | "OnlyAcronym": { 71 | in: "ip", 72 | want: Name{ 73 | Camel: "IP", 74 | CamelComputed: "Ip", 75 | LowerCamel: "ip", 76 | LowerCamelComputed: "ip", 77 | Snake: "ip", 78 | }, 79 | }, 80 | } 81 | 82 | for name, tc := range cases { 83 | t.Run(name, func(t *testing.T) { 84 | got := NewFromSnake(tc.in) 85 | 86 | if diff := cmp.Diff(tc.want, got); diff != "" { 87 | t.Errorf("\nNewNameFromSnake(...): -want, +got:\n%s", diff) 88 | } 89 | }) 90 | } 91 | } 92 | 93 | func TestNewNameFromCamel(t *testing.T) { 94 | cases := map[string]struct { 95 | in string 96 | want Name 97 | }{ 98 | "Normal": { 99 | in: "SomeCamel", 100 | want: Name{ 101 | Camel: "SomeCamel", 102 | CamelComputed: "SomeCamel", 103 | LowerCamel: "someCamel", 104 | LowerCamelComputed: "someCamel", 105 | Snake: "some_camel", 106 | }, 107 | }, 108 | "AcronymInBeginning": { 109 | in: "IDSetting", 110 | want: Name{ 111 | Camel: "IDSetting", 112 | CamelComputed: "IdSetting", 113 | LowerCamel: "idSetting", 114 | LowerCamelComputed: "idSetting", 115 | Snake: "id_setting", 116 | }, 117 | }, 118 | "AcronymInMiddle": { 119 | in: "SomeAPIParam", 120 | want: Name{ 121 | Camel: "SomeAPIParam", 122 | CamelComputed: "SomeApiParam", 123 | LowerCamel: "someAPIParam", 124 | LowerCamelComputed: "someApiParam", 125 | Snake: "some_api_param", 126 | }, 127 | }, 128 | "AcronymInEnd": { 129 | in: "SubnetID", 130 | want: Name{ 131 | Camel: "SubnetID", 132 | CamelComputed: "SubnetId", 133 | LowerCamel: "subnetID", 134 | LowerCamelComputed: "subnetId", 135 | Snake: "subnet_id", 136 | }, 137 | }, 138 | "OnlyAcronym": { 139 | in: "IP", 140 | want: Name{ 141 | Camel: "IP", 142 | CamelComputed: "Ip", 143 | LowerCamel: "ip", 144 | LowerCamelComputed: "ip", 145 | Snake: "ip", 146 | }, 147 | }, 148 | } 149 | 150 | for name, tc := range cases { 151 | t.Run(name, func(t *testing.T) { 152 | got := NewFromCamel(tc.in) 153 | 154 | if diff := cmp.Diff(tc.want, got); diff != "" { 155 | t.Errorf("\nNewNameFromSnake(...): -want, +got:\n%s", diff) 156 | } 157 | }) 158 | } 159 | } 160 | -------------------------------------------------------------------------------- /pkg/types/reference.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "fmt" 5 | "go/token" 6 | "go/types" 7 | "reflect" 8 | 9 | "github.com/pkg/errors" 10 | "golang.org/x/tools/go/packages" 11 | 12 | "github.com/crossplane/terrajet/pkg/config" 13 | "github.com/crossplane/terrajet/pkg/types/comments" 14 | "github.com/crossplane/terrajet/pkg/types/name" 15 | ) 16 | 17 | const ( 18 | // PackagePathXPCommonAPIs is the go path for the Crossplane Runtime package 19 | // with common APIs 20 | PackagePathXPCommonAPIs = "github.com/crossplane/crossplane-runtime/apis/common/v1" 21 | 22 | loadMode = packages.NeedName | packages.NeedImports | packages.NeedTypes | packages.NeedDeps 23 | ) 24 | 25 | var typeReferenceField types.Type 26 | var typeSelectorField types.Type 27 | var typeSecretKeySelector types.Type 28 | var commentOptional *comments.Comment 29 | 30 | func (g *Builder) generateReferenceFields(t *types.TypeName, f *types.Var, r config.Reference) (fields []*types.Var, tags []string) { 31 | _, isSlice := f.Type().(*types.Slice) 32 | 33 | rfn := r.RefFieldName 34 | if rfn == "" { 35 | rfn = f.Name() + "Ref" 36 | if isSlice { 37 | rfn += "s" 38 | } 39 | } 40 | 41 | sfn := r.SelectorFieldName 42 | if sfn == "" { 43 | sfn = f.Name() + "Selector" 44 | } 45 | 46 | rn := name.NewFromCamel(rfn) 47 | sn := name.NewFromCamel(sfn) 48 | refTag := fmt.Sprintf(`json:"%s,omitempty" tf:"-"`, rn.LowerCamelComputed) 49 | selTag := fmt.Sprintf(`json:"%s,omitempty" tf:"-"`, sn.LowerCamelComputed) 50 | 51 | var tr types.Type 52 | tr = types.NewPointer(typeReferenceField) 53 | if isSlice { 54 | tr = types.NewSlice(typeReferenceField) 55 | } 56 | ref := types.NewField(token.NoPos, g.Package, rfn, tr, false) 57 | sel := types.NewField(token.NoPos, g.Package, sfn, types.NewPointer(typeSelectorField), false) 58 | 59 | g.comments.AddFieldComment(t, rfn, commentOptional.Build()) 60 | g.comments.AddFieldComment(t, sfn, commentOptional.Build()) 61 | 62 | return []*types.Var{ref, sel}, []string{refTag, selTag} 63 | } 64 | 65 | func init() { 66 | pkgs, err := packages.Load(&packages.Config{Mode: loadMode}, PackagePathXPCommonAPIs) 67 | if err != nil { 68 | panic(errors.Wrap(err, "cannot load crossplane-runtime package to get reference types")) 69 | } 70 | if len(pkgs) != 1 && pkgs[0].Name != "v1" { 71 | panic(errors.Errorf("unexpected package name %s", pkgs[0].Name)) 72 | } 73 | typeReferenceField = pkgs[0].Types.Scope().Lookup("Reference").Type() 74 | typeSelectorField = pkgs[0].Types.Scope().Lookup("Selector").Type() 75 | typeSecretKeySelector = pkgs[0].Types.Scope().Lookup("SecretKeySelector").Type() 76 | 77 | commentOptional, err = comments.New("") 78 | if err != nil { 79 | panic(errors.Errorf("cannot build new comment for reference fields")) 80 | } 81 | req := false 82 | commentOptional.KubebuilderOptions.Required = &req 83 | } 84 | 85 | // TypePath returns go package path for the input type. This is a helper 86 | // function to be used whenever this information is needed, like configuring to 87 | // reference to a type. Should not be used if the type is in the same package as 88 | // the caller. 89 | func TypePath(i interface{}) string { 90 | return reflect.TypeOf(i).PkgPath() + "." + reflect.TypeOf(i).Name() 91 | } 92 | -------------------------------------------------------------------------------- /pkg/types/reference_test.go: -------------------------------------------------------------------------------- 1 | package types 2 | 3 | import ( 4 | "go/token" 5 | "go/types" 6 | "testing" 7 | 8 | "github.com/google/go-cmp/cmp" 9 | twtypes "github.com/muvaf/typewriter/pkg/types" 10 | 11 | "github.com/crossplane/terrajet/pkg/config" 12 | ) 13 | 14 | func TestBuilder_generateReferenceFields(t *testing.T) { 15 | tp := types.NewPackage("github.com/crossplane/terrajet/pkg/types", "tjtypes") 16 | 17 | type args struct { 18 | t *types.TypeName 19 | f *types.Var 20 | r config.Reference 21 | } 22 | type want struct { 23 | outFields []*types.Var 24 | outTags []string 25 | outComments twtypes.Comments 26 | } 27 | cases := map[string]struct { 28 | args 29 | want 30 | }{ 31 | "OnlyRefType": { 32 | args: args{ 33 | t: types.NewTypeName(token.NoPos, tp, "Params", types.Universe.Lookup("string").Type()), 34 | f: types.NewField(token.NoPos, tp, "TestField", types.Universe.Lookup("string").Type(), false), 35 | r: config.Reference{ 36 | Type: "testObject", 37 | }, 38 | }, want: want{ 39 | outFields: []*types.Var{ 40 | types.NewField(token.NoPos, tp, "TestFieldRef", types.NewPointer(typeReferenceField), false), 41 | types.NewField(token.NoPos, tp, "TestFieldSelector", types.NewPointer(typeSelectorField), false), 42 | }, 43 | outTags: []string{ 44 | `json:"testFieldRef,omitempty" tf:"-"`, 45 | `json:"testFieldSelector,omitempty" tf:"-"`, 46 | }, 47 | outComments: twtypes.Comments{ 48 | "github.com/crossplane/terrajet/pkg/types.Params:TestFieldRef": "// +kubebuilder:validation:Optional\n", 49 | "github.com/crossplane/terrajet/pkg/types.Params:TestFieldSelector": "// +kubebuilder:validation:Optional\n", 50 | }, 51 | }, 52 | }, 53 | "OnlyRefTypeSlice": { 54 | args: args{ 55 | t: types.NewTypeName(token.NoPos, tp, "Params", types.Universe.Lookup("string").Type()), 56 | f: types.NewField(token.NoPos, tp, "TestField", types.NewSlice(types.Universe.Lookup("string").Type()), false), 57 | r: config.Reference{ 58 | Type: "testObject", 59 | }, 60 | }, want: want{ 61 | outFields: []*types.Var{ 62 | types.NewField(token.NoPos, tp, "TestFieldRefs", types.NewSlice(typeReferenceField), false), 63 | types.NewField(token.NoPos, tp, "TestFieldSelector", types.NewPointer(typeSelectorField), false), 64 | }, 65 | outTags: []string{ 66 | `json:"testFieldRefs,omitempty" tf:"-"`, 67 | `json:"testFieldSelector,omitempty" tf:"-"`, 68 | }, 69 | outComments: twtypes.Comments{ 70 | "github.com/crossplane/terrajet/pkg/types.Params:TestFieldRefs": "// +kubebuilder:validation:Optional\n", 71 | "github.com/crossplane/terrajet/pkg/types.Params:TestFieldSelector": "// +kubebuilder:validation:Optional\n", 72 | }, 73 | }, 74 | }, 75 | "WithCustomFieldName": { 76 | args: args{ 77 | t: types.NewTypeName(token.NoPos, tp, "Params", types.Universe.Lookup("string").Type()), 78 | f: types.NewField(token.NoPos, tp, "TestField", types.Universe.Lookup("string").Type(), false), 79 | r: config.Reference{ 80 | Type: "TestObject", 81 | RefFieldName: "CustomRef", 82 | }, 83 | }, want: want{ 84 | outFields: []*types.Var{ 85 | types.NewField(token.NoPos, tp, "CustomRef", types.NewPointer(typeReferenceField), false), 86 | types.NewField(token.NoPos, tp, "TestFieldSelector", types.NewPointer(typeSelectorField), false), 87 | }, 88 | outTags: []string{ 89 | `json:"customRef,omitempty" tf:"-"`, 90 | `json:"testFieldSelector,omitempty" tf:"-"`, 91 | }, 92 | outComments: twtypes.Comments{ 93 | "github.com/crossplane/terrajet/pkg/types.Params:CustomRef": "// +kubebuilder:validation:Optional\n", 94 | "github.com/crossplane/terrajet/pkg/types.Params:TestFieldSelector": "// +kubebuilder:validation:Optional\n", 95 | }, 96 | }, 97 | }, 98 | "WithCustomSelectorName": { 99 | args: args{ 100 | t: types.NewTypeName(token.NoPos, tp, "Params", types.Universe.Lookup("string").Type()), 101 | f: types.NewField(token.NoPos, tp, "TestField", types.Universe.Lookup("string").Type(), false), 102 | r: config.Reference{ 103 | Type: "TestObject", 104 | SelectorFieldName: "CustomSelector", 105 | }, 106 | }, want: want{ 107 | outFields: []*types.Var{ 108 | types.NewField(token.NoPos, tp, "TestFieldRef", types.NewPointer(typeReferenceField), false), 109 | types.NewField(token.NoPos, tp, "CustomSelector", types.NewPointer(typeSelectorField), false), 110 | }, 111 | outTags: []string{ 112 | `json:"testFieldRef,omitempty" tf:"-"`, 113 | `json:"customSelector,omitempty" tf:"-"`, 114 | }, 115 | outComments: twtypes.Comments{ 116 | "github.com/crossplane/terrajet/pkg/types.Params:TestFieldRef": "// +kubebuilder:validation:Optional\n", 117 | "github.com/crossplane/terrajet/pkg/types.Params:CustomSelector": "// +kubebuilder:validation:Optional\n", 118 | }, 119 | }, 120 | }, 121 | } 122 | for name, tc := range cases { 123 | t.Run(name, func(t *testing.T) { 124 | g := &Builder{ 125 | comments: twtypes.Comments{}, 126 | } 127 | gotFields, gotTags := g.generateReferenceFields(tc.args.t, tc.args.f, tc.args.r) 128 | if diff := cmp.Diff(tc.want.outFields, gotFields, cmp.Comparer(func(a, b *types.Var) bool { 129 | return a.String() == b.String() 130 | })); diff != "" { 131 | t.Errorf("generateReferenceFields() fields = %v, want %v", gotFields, tc.want.outFields) 132 | } 133 | if diff := cmp.Diff(tc.want.outTags, gotTags); diff != "" { 134 | t.Errorf("generateReferenceFields() tags = %v, want %v", gotTags, tc.want.outTags) 135 | } 136 | if diff := cmp.Diff(tc.want.outComments, g.comments); diff != "" { 137 | t.Errorf("generateReferenceFields() comments = %v, want %v", g.comments, tc.want.outComments) 138 | } 139 | }) 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | // Package version contains the version of terrajet repo 2 | package version 3 | 4 | // Version will be overridden with the current version at build time using the -X linker flag 5 | var Version = "0.0.0" 6 | --------------------------------------------------------------------------------