├── .dockerignore ├── .env.example ├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ ├── build.yaml │ ├── pr.yaml │ └── push-image.yaml ├── .gitignore ├── .golangci.yml ├── Dockerfile ├── LICENSE ├── Makefile ├── PROJECT ├── README.md ├── api └── v1alpha1 │ ├── groupversion_info.go │ ├── helpers.go │ ├── helpers_test.go │ ├── k8s_configmaps.go │ ├── k8s_configmaps_test.go │ ├── k8s_helpers.go │ ├── k8s_helpers_test.go │ ├── k8s_jobs.go │ ├── k8s_jobs_test.go │ ├── k8s_rbac.go │ ├── k8s_rbac_test.go │ ├── k8s_secrets.go │ ├── k8s_secrets_test.go │ ├── suite_test.go │ ├── terraform_template.go │ ├── terraform_template_test.go │ ├── terraform_types.go │ ├── terraform_types_test.go │ └── zz_generated.deepcopy.go ├── config ├── crd │ ├── bases │ │ └── run.terraform-operator.io_terraforms.yaml │ ├── kustomization.yaml │ ├── kustomizeconfig.yaml │ └── patches │ │ ├── cainjection_in_terraforms.yaml │ │ └── webhook_in_terraforms.yaml ├── default │ ├── kustomization.yaml │ ├── manager_auth_proxy_patch.yaml │ └── manager_config_patch.yaml ├── manager │ ├── controller_manager_config.yaml │ ├── kustomization.yaml │ └── manager.yaml ├── manifest │ ├── kustomization.yaml │ └── terraform-operator.yaml ├── prometheus │ ├── kustomization.yaml │ └── monitor.yaml ├── rbac │ ├── auth_proxy_client_clusterrole.yaml │ ├── auth_proxy_role.yaml │ ├── auth_proxy_role_binding.yaml │ ├── auth_proxy_service.yaml │ ├── kustomization.yaml │ ├── leader_election_role.yaml │ ├── leader_election_role_binding.yaml │ ├── role.yaml │ ├── role_binding.yaml │ ├── service_account.yaml │ ├── terraform_editor_role.yaml │ └── terraform_viewer_role.yaml └── samples │ ├── README.md │ ├── role-terraform-runner.yaml │ ├── terraform-aws.yaml │ ├── terraform-azure.yaml │ ├── terraform-basic.yaml │ ├── terraform-dependencies.yaml │ ├── terraform-git-ssh.yaml │ └── terraform-var-files.yaml ├── controllers ├── suite_test.go ├── terraform_controller.go ├── terraform_controller_operation.go └── terraform_controller_test.go ├── docs ├── _config.yml ├── _includes │ └── head_custom.html ├── api-ref.md ├── contributing-guide.md ├── customize.md ├── design.md ├── examples.md ├── examples │ ├── aws.md │ ├── azure.md │ ├── dependency.md │ ├── git-ssh.md │ └── var-file.md ├── features.md ├── features │ ├── 1.version.md │ ├── 10.outputs.md │ ├── 11.destroy.md │ ├── 12.retries.md │ ├── 2.module-source.md │ ├── 3.variables.md │ ├── 4.variable-files.md │ ├── 5.workspace.md │ ├── 6.backend.md │ ├── 7.providers.md │ ├── 8.dependencies.md │ └── 9.git-ssh.md ├── img │ ├── design.png │ └── tfo.svg ├── index.md ├── installation.md └── monitoring.md ├── go.mod ├── go.sum ├── hack └── boilerplate.go.txt ├── internal ├── kube │ └── client.go ├── metrics │ ├── metrics.go │ ├── metrics_test.go │ └── suite_test.go └── utils │ ├── env.go │ └── file.go └── main.go /.dockerignore: -------------------------------------------------------------------------------- 1 | # More info: https://docs.docker.com/engine/reference/builder/#dockerignore-file 2 | # Ignore build and test binaries. 3 | bin/ 4 | testbin/ 5 | -------------------------------------------------------------------------------- /.env.example: -------------------------------------------------------------------------------- 1 | export DOCKER_REGISTRY= 2 | export TERRAFORM_RUNNER_IMAGE= 3 | export TERRAFORM_RUNNER_IMAGE_TAG= 4 | export KNOWN_HOSTS_CONFIGMAP_NAME= -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: ibraheemalsaady 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Versions** 24 | * Operator: 0.1.0 25 | * Runner: 0.4.0 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: '' 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/build.yaml: -------------------------------------------------------------------------------- 1 | name: build 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | - main 8 | tags-ignore: 9 | - '*.*.*' 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Set up Go 19 | uses: actions/setup-go@v2 20 | with: 21 | go-version: 1.18 22 | 23 | - name: Build 24 | run: make build 25 | 26 | - name: Test 27 | run: make test-cov 28 | 29 | - name: Exclude Generated 30 | run: | 31 | cat coverage.tmp.txt | grep -v "_generated.deepcopy.go" > coverage.txt 32 | go tool cover -func coverage.txt 33 | rm coverage.tmp.txt 34 | 35 | - name: Docker Build Test 36 | run: docker build -t controller:latest . 37 | 38 | - name: Codecov Push 39 | run: bash <(curl -s https://codecov.io/bash) 40 | -------------------------------------------------------------------------------- /.github/workflows/pr.yaml: -------------------------------------------------------------------------------- 1 | name: Pull Request 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | - master 8 | 9 | jobs: 10 | build: 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v2 18 | with: 19 | go-version: 1.18 20 | 21 | - name: Build 22 | run: make build 23 | 24 | ## Ref: https://github.com/golangci/golangci-lint-action 25 | - name: golangci-lint 26 | uses: golangci/golangci-lint-action@v3 27 | 28 | - name: Test 29 | run: make docker-build 30 | 31 | - name: Docker Build Test 32 | run: docker build -t controller:latest . 33 | -------------------------------------------------------------------------------- /.github/workflows/push-image.yaml: -------------------------------------------------------------------------------- 1 | name: push-image 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*.*.*' 7 | 8 | jobs: 9 | push: 10 | runs-on: ubuntu-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v2 14 | 15 | - name: Login to DockerHub Registry 16 | run: echo ${{ secrets.DOCKER_PASSWORD }} | docker login -u ${{ secrets.DOCKER_USERNAME }} --password-stdin 17 | 18 | - name: get release version 19 | id: release_version 20 | run: echo ::set-output name=tag::${GITHUB_REF#refs/*/} 21 | 22 | - name: docker build 23 | run: docker build -t ${IMG} . 24 | env: 25 | IMG: kubechamp/terraform-operator:${{ steps.release_version.outputs.tag }} 26 | 27 | - name: docker push 28 | run: docker push ${IMG} 29 | env: 30 | IMG: kubechamp/terraform-operator:${{ steps.release_version.outputs.tag }} 31 | 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Binaries for programs and plugins 3 | *.exe 4 | *.exe~ 5 | *.dll 6 | *.so 7 | *.dylib 8 | bin 9 | testbin/* 10 | 11 | # Test binary, build with `go test -c` 12 | *.test 13 | 14 | # Output of the go coverage tool, specifically when used with LiteIDE 15 | *.out 16 | cover.html 17 | coverage.txt 18 | 19 | # Kubernetes Generated files - skip generated files, except for vendored files 20 | 21 | !vendor/**/zz_generated.* 22 | 23 | # editor and IDE paraphernalia 24 | .idea 25 | *.swp 26 | *.swo 27 | *~ 28 | 29 | **/.DS_Store 30 | *kubeconfig 31 | *.env 32 | 33 | **/*k8s-secret.yaml 34 | 35 | .vscode -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # Build the manager binary 2 | FROM golang:1.18 as builder 3 | 4 | WORKDIR /workspace 5 | # Copy the Go Modules manifests 6 | COPY go.mod go.mod 7 | COPY go.sum go.sum 8 | # cache deps before building and copying source so that we don't need to re-download as much 9 | # and so that source changes don't invalidate our downloaded layer 10 | RUN go mod download 11 | 12 | # Copy the go source 13 | COPY main.go main.go 14 | COPY api/ api/ 15 | COPY controllers/ controllers/ 16 | COPY internal/ internal/ 17 | 18 | # Build 19 | RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -a -o manager main.go 20 | 21 | # Use distroless as minimal base image to package the manager binary 22 | # Refer to https://github.com/GoogleContainerTools/distroless for more details 23 | FROM gcr.io/distroless/static:nonroot 24 | WORKDIR / 25 | COPY --from=builder /workspace/manager . 26 | USER 65532:65532 27 | 28 | ENTRYPOINT ["/manager"] 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | # Image URL to use all building/pushing image targets 3 | IMG ?= controller:latest 4 | # ENVTEST_K8S_VERSION refers to the version of kubebuilder assets to be downloaded by envtest binary. 5 | ENVTEST_K8S_VERSION = 1.24.1 6 | 7 | # Get the currently used golang install path (in GOPATH/bin, unless GOBIN is set) 8 | ifeq (,$(shell go env GOBIN)) 9 | GOBIN=$(shell go env GOPATH)/bin 10 | else 11 | GOBIN=$(shell go env GOBIN) 12 | endif 13 | 14 | # Setting SHELL to bash allows bash commands to be executed by recipes. 15 | # This is a requirement for 'setup-envtest.sh' in the test target. 16 | # Options are set to exit when a recipe line exits non-zero or a piped command fails. 17 | SHELL = /usr/bin/env bash -o pipefail 18 | .SHELLFLAGS = -ec 19 | 20 | .PHONY: all 21 | all: build 22 | 23 | ##@ General 24 | 25 | # The help target prints out all targets with their descriptions organized 26 | # beneath their categories. The categories are represented by '##@' and the 27 | # target descriptions by '##'. The awk commands is responsible for reading the 28 | # entire set of makefiles included in this invocation, looking for lines of the 29 | # file as xyz: ## something, and then pretty-format the target and help. Then, 30 | # if there's a line with ##@ something, that gets pretty-printed as a category. 31 | # More info on the usage of ANSI control characters for terminal formatting: 32 | # https://en.wikipedia.org/wiki/ANSI_escape_code#SGR_parameters 33 | # More info on the awk command: 34 | # http://linuxcommand.org/lc3_adv_awk.php 35 | 36 | .PHONY: help 37 | help: ## Display this help. 38 | @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n make \033[36m\033[0m\n"} /^[a-zA-Z_0-9-]+:.*?##/ { printf " \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST) 39 | 40 | ##@ Development 41 | 42 | .PHONY: manifests 43 | manifests: controller-gen ## Generate WebhookConfiguration, ClusterRole and CustomResourceDefinition objects. 44 | $(CONTROLLER_GEN) rbac:roleName=manager-role crd webhook paths="./..." output:crd:artifacts:config=config/crd/bases 45 | 46 | .PHONY: generate 47 | generate: controller-gen ## Generate code containing DeepCopy, DeepCopyInto, and DeepCopyObject method implementations. 48 | $(CONTROLLER_GEN) object:headerFile="hack/boilerplate.go.txt" paths="./..." 49 | 50 | .PHONY: fmt 51 | fmt: ## Run go fmt against code. 52 | go fmt ./... 53 | 54 | .PHONY: vet 55 | vet: ## Run go vet against code. 56 | go vet ./... 57 | 58 | .PHONY: test 59 | test: manifests generate fmt vet envtest ## Run tests. 60 | KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -coverprofile cover.out 61 | 62 | ##@ Build 63 | 64 | .PHONY: build 65 | build: generate fmt vet ## Build manager binary. 66 | go build -o bin/manager main.go 67 | 68 | .PHONY: run 69 | run: manifests generate fmt vet ## Run a controller from your host. 70 | go run ./main.go --requeue-job-watch=5s 71 | 72 | .PHONY: docker-build 73 | docker-build: test ## Build docker image with the manager. 74 | docker build -t ${IMG} . 75 | 76 | .PHONY: docker-push 77 | docker-push: ## Push docker image with the manager. 78 | docker push ${IMG} 79 | 80 | ##@ Deployment 81 | 82 | ifndef ignore-not-found 83 | ignore-not-found = false 84 | endif 85 | 86 | .PHONY: install 87 | install: manifests kustomize ## Install CRDs into the K8s cluster specified in ~/.kube/config. 88 | $(KUSTOMIZE) build config/crd | kubectl apply -f - 89 | 90 | .PHONY: uninstall 91 | uninstall: manifests kustomize ## Uninstall CRDs from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 92 | $(KUSTOMIZE) build config/crd | kubectl delete --ignore-not-found=$(ignore-not-found) -f - 93 | 94 | .PHONY: deploy 95 | deploy: manifests kustomize ## Deploy controller to the K8s cluster specified in ~/.kube/config. 96 | cd config/manager && $(KUSTOMIZE) edit set image controller=${IMG} 97 | $(KUSTOMIZE) build config/default | kubectl apply -f - 98 | 99 | .PHONY: undeploy 100 | undeploy: ## Undeploy controller from the K8s cluster specified in ~/.kube/config. Call with ignore-not-found=true to ignore resource not found errors during deletion. 101 | $(KUSTOMIZE) build config/default | kubectl delete --ignore-not-found=$(ignore-not-found) -f - 102 | 103 | ##@ Build Dependencies 104 | 105 | ## Location to install dependencies to 106 | LOCALBIN ?= $(shell pwd)/bin 107 | $(LOCALBIN): 108 | mkdir -p $(LOCALBIN) 109 | 110 | ## Tool Binaries 111 | KUSTOMIZE ?= $(LOCALBIN)/kustomize 112 | CONTROLLER_GEN ?= $(LOCALBIN)/controller-gen 113 | ENVTEST ?= $(LOCALBIN)/setup-envtest 114 | 115 | ## Tool Versions 116 | KUSTOMIZE_VERSION ?= v3.8.7 117 | CONTROLLER_TOOLS_VERSION ?= v0.9.0 118 | 119 | KUSTOMIZE_INSTALL_SCRIPT ?= "https://raw.githubusercontent.com/kubernetes-sigs/kustomize/master/hack/install_kustomize.sh" 120 | .PHONY: kustomize 121 | kustomize: $(KUSTOMIZE) ## Download kustomize locally if necessary. 122 | $(KUSTOMIZE): $(LOCALBIN) 123 | curl -s $(KUSTOMIZE_INSTALL_SCRIPT) | bash -s -- $(subst v,,$(KUSTOMIZE_VERSION)) $(LOCALBIN) 124 | 125 | .PHONY: controller-gen 126 | controller-gen: $(CONTROLLER_GEN) ## Download controller-gen locally if necessary. 127 | $(CONTROLLER_GEN): $(LOCALBIN) 128 | GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-tools/cmd/controller-gen@$(CONTROLLER_TOOLS_VERSION) 129 | 130 | .PHONY: envtest 131 | envtest: $(ENVTEST) ## Download envtest-setup locally if necessary. 132 | $(ENVTEST): $(LOCALBIN) 133 | GOBIN=$(LOCALBIN) go install sigs.k8s.io/controller-runtime/tools/setup-envtest@latest 134 | 135 | ##@ Custom additional commands 136 | GOLINTER ?= $(LOCALBIN)/golangci-lint 137 | GOLINTER_VERSION ?= v1.46.2 138 | 139 | .PHONY: golinter 140 | golinter: $(GOLINTER) ## Download golinter setup locally if necessary. 141 | $(GOLINTER): $(LOCALBIN) 142 | GOBIN=$(LOCALBIN) go install github.com/golangci/golangci-lint/cmd/golangci-lint@$(GOLINTER_VERSION) 143 | 144 | .PHONY: lint 145 | lint: golinter 146 | golangci-lint run --out-format code-climate | jq -r '.[] | "\(.location.path):\(.location.lines.begin) \(.description)"' 147 | 148 | .PHONY: test-cov 149 | test-cov: manifests generate fmt vet envtest ## Run tests. 150 | KUBEBUILDER_ASSETS="$(shell $(ENVTEST) use $(ENVTEST_K8S_VERSION) -p path)" go test ./... -race -coverprofile coverage.tmp.txt -covermode=atomic 151 | 152 | .PHONY: covhtml 153 | covhtml: 154 | go tool cover -html=cover.out -o cover.html 155 | -------------------------------------------------------------------------------- /PROJECT: -------------------------------------------------------------------------------- 1 | domain: terraform-operator.io 2 | layout: 3 | - go.kubebuilder.io/v3 4 | projectName: terraform-operator 5 | repo: github.com/kuptan/terraform-operator 6 | resources: 7 | - api: 8 | crdVersion: v1 9 | namespaced: true 10 | controller: true 11 | domain: terraform-operator.io 12 | group: run 13 | kind: Terraform 14 | path: github.com/kuptan/terraform-operator/api/v1alpha1 15 | version: v1alpha1 16 | version: "3" 17 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Terraform Operator 2 |

3 | 4 |

5 | 6 | build 7 | 8 | 9 | 10 | codecov 11 | 12 | 13 | 14 | go report 15 | 16 | 17 | 18 | license 19 | 20 | 21 | 22 | license 23 | 24 |

25 | 26 | The Terraform Operator provides support to run Terraform modules in Kubernetes in a declarative way as a [Kubernetes manifest](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/). 27 | 28 | This project makes running a Terraform module, Kubernetes native through a single Kubernetes [CRD](https://kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/). You can run the manifest with kubectl, Terraform, GitOps tools, etc... 29 | 30 | > **Warning** 31 | > 32 | > The Terraform Operator is an experimental project at this stage 33 | 34 | 35 | **Disclaimer** 36 | 37 | This project is not a YAML to HCL converter. It just provides a way to run Terraform commands through a Kubernetes CRD. To see how this controller works, have a look at the [design doc](https://kuptan.github.io/terraform-operator/design/) 38 | 39 | ## Installation 40 | 41 | **Helm** 42 | 43 | ```bash 44 | helm repo add kuptan https://kuptan.github.io/helm-charts 45 | helm install terraform-operator kuptan/terraform-operator 46 | ``` 47 | 48 | Chart can be found [here](https://github.com/kuptan/helm-charts/tree/master/charts/terraform-operator) 49 | 50 | **Kubectl** 51 | 52 | ```bash 53 | kubectl apply -k https://github.com/kuptan/terraform-operator/config/crd 54 | kubectl apply -k https://github.com/kuptan/terraform-operator/config/manifest 55 | ``` 56 | 57 | ## Docuemntation 58 | Check the Terraform Operator [docs](https://kuptan.github.io/terraform-operator/) for more details and examples 59 | 60 | ## Features 61 | - [x] Point to any Terraform module (including Git) 62 | - [x] Private Git repos authentication 63 | - [x] Define Terraform variables and variable files 64 | - [x] Target specific Terraform workspace 65 | - [x] Custom backend & providers configuration 66 | - [x] Terraform module outputs written to a Kubernetes Secret 67 | - [x] Dependency on other workflows 68 | - [x] Terraform variables from the output of a dependency workflow 69 | - [x] Specify retry limits 70 | 71 | ## Usage 72 | For more examples on how to use this CRD, check the [samples](https://kuptan.github.io/terraform-operator/examples/) 73 | 74 | ```yaml 75 | apiVersion: run.terraform-operator.io/v1alpha1 76 | kind: Terraform 77 | metadata: 78 | name: first-module 79 | spec: 80 | terraformVersion: 1.0.2 81 | 82 | module: 83 | source: IbraheemAlSaady/test/module 84 | ## optional module version 85 | version: 86 | 87 | ## a terraform workspace to select 88 | workspace: 89 | 90 | ## a custom terraform backend 91 | ## if not provided, Kubernetes backend will be used as a default 92 | backend: | 93 | backend "local" { 94 | path = "/tmp/tfmodule/mytfstate.tfstate" 95 | } 96 | 97 | ## a custom providers config 98 | providersConfig: 99 | 100 | ## a list of terraform variables to be provided 101 | variables: 102 | - key: length 103 | value: "16" 104 | 105 | - key: something 106 | ## only works if the dependency is in the same namespace 107 | dependencyRef: 108 | name: my-dependency-name 109 | key: the output secret key 110 | 111 | - key: AWS_ACCESS_KEY 112 | valueFrom: 113 | ## can be configMapKeyRef as well 114 | secretKeyRef: 115 | name: aws-credentials 116 | key: AWS_ACCESS_KEY 117 | environmentVariable: true 118 | 119 | ## files with ext '.tfvars' or '.tf' that will be mounted into the terraform runner job 120 | ## to be passed to terraform as '-var-file' 121 | variableFiles: 122 | - key: terraform-env-config 123 | valueFrom: 124 | ## can also be 'secret' 125 | configMap: 126 | name: "terraform-env-config" 127 | # secret: 128 | # secretName: mysecret 129 | 130 | dependsOn: 131 | - name: run-base 132 | ## if its in another namespace 133 | namespace: 134 | 135 | ## ssh key from a secret to allow pull modules from private git repos 136 | gitSSHKey: 137 | valueFrom: 138 | secret: 139 | ## secret key must be id_rsa 140 | secretName: git-ssh-key 141 | defaultMode: 0600 142 | 143 | ## outputs defined will be stored in a Kubernetes secret 144 | outputs: 145 | ## The Kubernetes Secret key 146 | - key: my_new_output_name 147 | ## the output name from the module 148 | moduleOutputName: result 149 | 150 | ## a flag to run a terraform destroy 151 | destroy: false 152 | 153 | ## a flag to delete the job after the job is completed 154 | deleteCompletedJobs: false 155 | 156 | ## number of retries in case of run failure 157 | retryLimit: 2 158 | ``` 159 | 160 | ## Roadmap 161 | Check the [Terraform Operator Project](https://github.com/orgs/kuptan/projects/1) to see what's on the roadmap 162 | 163 | ## Contributing 164 | If you find this project useful, help us: 165 | 166 | - Support the development of this project and star this repo! :star: 167 | - Help new users with issues they may encounter :muscle: 168 | - Send a pull request with your new features and bug fixes :rocket: 169 | 170 | For instructions about setting up your environment to develop and extend the operator, please see [contributing.md](https://kuptan.github.io/terraform-operator/contributing-guide/) 171 | -------------------------------------------------------------------------------- /api/v1alpha1/groupversion_info.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022. 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 v1alpha1 contains API Schema definitions for the run v1alpha1 API group 18 | // +kubebuilder:object:generate=true 19 | // +groupName=run.terraform-operator.io 20 | package v1alpha1 21 | 22 | import ( 23 | "k8s.io/apimachinery/pkg/runtime/schema" 24 | "sigs.k8s.io/controller-runtime/pkg/scheme" 25 | ) 26 | 27 | // Kubernetes Controller information 28 | var ( 29 | // GroupVersion is group version used to register these objects 30 | GroupVersion = schema.GroupVersion{Group: "run.terraform-operator.io", Version: "v1alpha1"} 31 | 32 | // SchemeBuilder is used to add go types to the GroupVersionKind scheme 33 | SchemeBuilder = &scheme.Builder{GroupVersion: GroupVersion} 34 | 35 | // AddToScheme adds the types in this group-version to the given scheme. 36 | AddToScheme = SchemeBuilder.AddToScheme 37 | ) 38 | -------------------------------------------------------------------------------- /api/v1alpha1/helpers.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "crypto/rand" 5 | "fmt" 6 | "math/big" 7 | "strings" 8 | ) 9 | 10 | // returns a bool on whether a string is available in a given array of string 11 | func containsString(slice []string, s string) bool { 12 | for _, item := range slice { 13 | if item == s { 14 | return true 15 | } 16 | } 17 | return false 18 | } 19 | 20 | // removes a string from a given array of string 21 | func removeString(slice []string, s string) (result []string) { 22 | for _, item := range slice { 23 | if item == s { 24 | continue 25 | } 26 | result = append(result, item) 27 | } 28 | return 29 | } 30 | 31 | // generates a random alphanumeric based on the length provided 32 | func random(n int64) string { 33 | var letters = []rune("abcdefghijklmnopqrstuvwxyz123456790") 34 | 35 | b := make([]rune, n) 36 | 37 | for i := range b { 38 | generated, _ := rand.Int(rand.Reader, big.NewInt(int64(len(letters)))) 39 | 40 | b[i] = letters[generated.Int64()] 41 | } 42 | return string(b) 43 | } 44 | 45 | // returns common labels to be attached to children resources 46 | func getCommonLabels(name string, runID string) map[string]string { 47 | return map[string]string{ 48 | "terraformRunName": name, 49 | "terraformRunId": runID, 50 | "component": "Terraform-run", 51 | "owner": "run.terraform-operator.io", 52 | } 53 | } 54 | 55 | func truncateResourceName(s string, i int) string { 56 | name := s 57 | if len(s) > i { 58 | name = s[0:i] 59 | // End in alphanum, Assume only "-" and "." can be in name 60 | name = strings.TrimRight(name, "-") 61 | name = strings.TrimRight(name, ".") 62 | } 63 | return name 64 | } 65 | 66 | // getUniqueResourceName returns a unique name for the terraform Run job 67 | func getUniqueResourceName(name string, runID string) string { 68 | return fmt.Sprintf("%s-%s", truncateResourceName(name, 220), runID) 69 | } 70 | 71 | // getOutputSecretname returns a unique name for the terraform Run job 72 | func getOutputSecretname(name string) string { 73 | return fmt.Sprintf("%s-outputs", truncateResourceName(name, 220)) 74 | } 75 | -------------------------------------------------------------------------------- /api/v1alpha1/helpers_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("Helpers", func() { 9 | BeforeEach(func() { 10 | // Add any setup steps that needs to be executed before each test 11 | }) 12 | 13 | AfterEach(func() { 14 | // Add any teardown steps that needs to be executed after each test 15 | }) 16 | 17 | Context("Helpers functions", func() { 18 | It("should return true if string is available in an array of strings", func() { 19 | arr := []string{"abc", "def"} 20 | 21 | Expect(containsString(arr, "abc")).To(BeTrue()) 22 | Expect(containsString(arr, "123")).To(BeFalse()) 23 | }) 24 | 25 | It("should remove a string from an array", func() { 26 | arr := []string{"abc", "def"} 27 | 28 | arr = removeString(arr, "abc") 29 | 30 | Expect(arr).To(HaveLen(1)) 31 | Expect(arr[0]).To(Equal("def")) 32 | }) 33 | 34 | It("should generate a random string with a specified length", func() { 35 | str := random(5) 36 | 37 | Expect(str).To(HaveLen(5)) 38 | }) 39 | 40 | It("should return common labels", func() { 41 | labels := getCommonLabels("foo", "1234") 42 | 43 | Expect(labels["terraformRunName"]).ToNot(BeEmpty()) 44 | Expect(labels["terraformRunId"]).ToNot(BeEmpty()) 45 | Expect(labels["component"]).ToNot(BeEmpty()) 46 | Expect(labels["owner"]).ToNot(BeEmpty()) 47 | }) 48 | 49 | It("should return the unique resource name", func() { 50 | name := getUniqueResourceName("foo", "1234") 51 | Expect(name).To(Equal("foo-1234")) 52 | }) 53 | 54 | It("should return the the name of the secret output", func() { 55 | name := getOutputSecretname("foo") 56 | Expect(name).To(Equal("foo-outputs")) 57 | }) 58 | 59 | It("should return a truncated value if length of the name is larger than allowed", func() { 60 | name := truncateResourceName("my-value-for-the-name", 15) 61 | // should trim from the right and keep the string to 15 characters 62 | Expect(name).To(Equal("my-value-for-th")) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_configmaps.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/kuptan/terraform-operator/internal/kube" 7 | corev1 "k8s.io/api/core/v1" 8 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 9 | "k8s.io/apimachinery/pkg/types" 10 | ) 11 | 12 | // getConfigMapSpecForModule returns a Kubernetes ConifgMap spec for the terraform module 13 | // This configmap will be mounted in the Terraform Runner pod 14 | func getConfigMapSpecForModule(name string, namespace string, module string, runID string, owner metav1.OwnerReference) *corev1.ConfigMap { 15 | cm := &corev1.ConfigMap{ 16 | ObjectMeta: metav1.ObjectMeta{ 17 | Name: getUniqueResourceName(name, runID), 18 | Namespace: namespace, 19 | Labels: getCommonLabels(name, runID), 20 | OwnerReferences: []metav1.OwnerReference{ 21 | owner, 22 | }, 23 | }, 24 | Data: map[string]string{ 25 | "main.tf": module, 26 | }, 27 | } 28 | 29 | return cm 30 | } 31 | 32 | // createConfigMapForModule creates the ConfigMap for the Terraform workflow/run 33 | func createConfigMapForModule(ctx context.Context, namespacedName types.NamespacedName, run *Terraform) (*corev1.ConfigMap, error) { 34 | configMaps := kube.ClientSet.CoreV1().ConfigMaps(namespacedName.Namespace) 35 | 36 | tpl, err := getTerraformModuleFromTemplate(run) 37 | 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | configMap := getConfigMapSpecForModule( 43 | namespacedName.Name, 44 | namespacedName.Namespace, 45 | string(tpl), run.Status.RunID, 46 | run.GetOwnerReference()) 47 | 48 | if _, err := configMaps.Create(ctx, configMap, metav1.CreateOptions{}); err != nil { 49 | return nil, err 50 | } 51 | 52 | return configMap, nil 53 | } 54 | 55 | // deleteConfigMapByRun deletes the Kubernetes Job of the workflow/run 56 | func deleteConfigMapByRun(ctx context.Context, runName string, namespace string, runID string) error { 57 | configMaps := kube.ClientSet.CoreV1().ConfigMaps(namespace) 58 | 59 | resourceName := getUniqueResourceName(runName, runID) 60 | 61 | deletePolicy := metav1.DeletePropagationForeground 62 | 63 | if err := configMaps.Delete(ctx, resourceName, metav1.DeleteOptions{ 64 | PropagationPolicy: &deletePolicy, 65 | }); err != nil { 66 | return err 67 | } 68 | 69 | return nil 70 | } 71 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_configmaps_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | 6 | "k8s.io/apimachinery/pkg/api/errors" 7 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 8 | "k8s.io/apimachinery/pkg/types" 9 | 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | var _ = Describe("Kubernetes ConfigMaps", func() { 15 | BeforeEach(func() { 16 | // Add any setup steps that needs to be executed before each test 17 | }) 18 | 19 | AfterEach(func() { 20 | // Add any teardown steps that needs to be executed after each test 21 | }) 22 | 23 | Context("ConfigMap", func() { 24 | key := types.NamespacedName{ 25 | Name: "bar", 26 | Namespace: "default", 27 | } 28 | 29 | run := &Terraform{ 30 | ObjectMeta: metav1.ObjectMeta{ 31 | Name: "bar", 32 | Namespace: "default", 33 | }, 34 | Spec: TerraformSpec{ 35 | TerraformVersion: "1.0.2", 36 | Module: Module{ 37 | Source: "IbraheemAlSaady/test/module", 38 | Version: "0.0.2", 39 | }, 40 | Destroy: false, 41 | DeleteCompletedJobs: false, 42 | }, 43 | Status: TerraformStatus{ 44 | RunID: "1234", 45 | }, 46 | } 47 | 48 | It("should create the configmap successfully", func() { 49 | cfg, err := createConfigMapForModule(context.Background(), key, run) 50 | 51 | expectedName := "bar-1234" 52 | 53 | Expect(err).ToNot(HaveOccurred()) 54 | Expect(cfg).ToNot(BeNil()) 55 | Expect(cfg.Name).To(Equal(expectedName)) 56 | }) 57 | 58 | It("should delete the configmap successfully", func() { 59 | err := deleteConfigMapByRun(context.Background(), key.Name, key.Namespace, run.Status.RunID) 60 | 61 | Expect(err).ToNot(HaveOccurred()) 62 | }) 63 | 64 | It("should return an error if the configmap does not exist", func() { 65 | err := deleteConfigMapByRun(context.Background(), key.Name, key.Namespace, run.Status.RunID) 66 | 67 | Expect(err).To(HaveOccurred()) 68 | Expect(errors.IsNotFound(err)).To(BeTrue()) 69 | }) 70 | }) 71 | }) 72 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_helpers.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | corev1 "k8s.io/api/core/v1" 5 | ) 6 | 7 | // getVolumeSpec returns a volume spec 8 | func getVolumeSpec(name string, source corev1.VolumeSource) corev1.Volume { 9 | return corev1.Volume{ 10 | Name: name, 11 | VolumeSource: source, 12 | } 13 | } 14 | 15 | // getVolumeSpecFromConfigMap returns a volume spec from configMap 16 | func getVolumeSpecFromConfigMap(volumeName string, configMapName string) corev1.Volume { 17 | return corev1.Volume{ 18 | Name: volumeName, 19 | VolumeSource: corev1.VolumeSource{ 20 | ConfigMap: &corev1.ConfigMapVolumeSource{ 21 | LocalObjectReference: corev1.LocalObjectReference{ 22 | Name: configMapName, 23 | }, 24 | }, 25 | }, 26 | } 27 | } 28 | 29 | // getEmptyDirVolume returns and emptyDir volume spec 30 | func getEmptyDirVolume(name string) corev1.Volume { 31 | return corev1.Volume{ 32 | Name: name, 33 | VolumeSource: corev1.VolumeSource{ 34 | EmptyDir: &corev1.EmptyDirVolumeSource{}, 35 | }, 36 | } 37 | } 38 | 39 | // getVolumeMountSpec returns a volume mount spec 40 | func getVolumeMountSpec(volumeName string, mountPath string, readOnly bool) corev1.VolumeMount { 41 | return corev1.VolumeMount{ 42 | Name: volumeName, 43 | MountPath: mountPath, 44 | ReadOnly: readOnly, 45 | } 46 | } 47 | 48 | // getVolumeMountSpecWithSubPath returns a volume mount spec with subpath option 49 | func getVolumeMountSpecWithSubPath(volumeName string, mountPath string, subPath string, readOnly bool) corev1.VolumeMount { 50 | return corev1.VolumeMount{ 51 | Name: volumeName, 52 | MountPath: mountPath, 53 | ReadOnly: readOnly, 54 | SubPath: subPath, 55 | } 56 | } 57 | 58 | // getEnvVariable returns a Kubernetes environment variable spec 59 | func getEnvVariable(name string, value string) corev1.EnvVar { 60 | return corev1.EnvVar{ 61 | Name: name, 62 | Value: value, 63 | } 64 | } 65 | 66 | // getEnvVariableFromFieldSelector returns a Kubernetes environment variable from a field selector 67 | func getEnvVariableFromFieldSelector(name string, path string) corev1.EnvVar { 68 | return corev1.EnvVar{ 69 | Name: name, 70 | ValueFrom: &corev1.EnvVarSource{ 71 | FieldRef: &corev1.ObjectFieldSelector{ 72 | FieldPath: path, 73 | }, 74 | }, 75 | } 76 | } 77 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_helpers_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | v1 "k8s.io/api/core/v1" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("Kubernetes Helpers", func() { 11 | BeforeEach(func() { 12 | // Add any setup steps that needs to be executed before each test 13 | }) 14 | 15 | AfterEach(func() { 16 | // Add any teardown steps that needs to be executed after each test 17 | }) 18 | 19 | Context("Helpers functions", func() { 20 | It("should return a correct volume", func() { 21 | vol := getVolumeSpec("vol1", v1.VolumeSource{ 22 | ConfigMap: &v1.ConfigMapVolumeSource{ 23 | LocalObjectReference: v1.LocalObjectReference{ 24 | Name: "cfg1", 25 | }, 26 | }, 27 | }) 28 | 29 | Expect(vol).ToNot(BeNil()) 30 | Expect(vol.Name).To(Equal("vol1")) 31 | Expect(vol.ConfigMap.Name).To(Equal("cfg1")) 32 | }) 33 | 34 | It("should return a correct volume from a configmap", func() { 35 | vol := getVolumeSpecFromConfigMap("vol1", "cfg1") 36 | 37 | Expect(vol).ToNot(BeNil()) 38 | Expect(vol.Name).To(Equal("vol1")) 39 | Expect(vol.ConfigMap.Name).To(Equal("cfg1")) 40 | }) 41 | 42 | It("should return an emptyDir volume", func() { 43 | vol := getEmptyDirVolume("vol1") 44 | 45 | Expect(vol).ToNot(BeNil()) 46 | Expect(vol.Name).To(Equal("vol1")) 47 | Expect(vol.EmptyDir).ToNot(BeNil()) 48 | }) 49 | 50 | It("should return a valid volume mount spec", func() { 51 | mount := getVolumeMountSpec("vol1", "/tmp", true) 52 | 53 | Expect(mount).ToNot(BeNil()) 54 | Expect(mount.Name).To(Equal("vol1")) 55 | Expect(mount.MountPath).To(Equal("/tmp")) 56 | Expect(mount.ReadOnly).To(BeTrue()) 57 | }) 58 | 59 | It("should return a valid volume mount spec with subpath", func() { 60 | mount := getVolumeMountSpecWithSubPath("vol1", "/tmp/file.tf", "file.tf", true) 61 | 62 | Expect(mount).ToNot(BeNil()) 63 | Expect(mount.Name).To(Equal("vol1")) 64 | Expect(mount.MountPath).To(Equal("/tmp/file.tf")) 65 | Expect(mount.SubPath).To(Equal("file.tf")) 66 | Expect(mount.ReadOnly).To(BeTrue()) 67 | }) 68 | 69 | It("should return an env var", func() { 70 | env := getEnvVariable("key", "value") 71 | 72 | Expect(env).ToNot(BeNil()) 73 | Expect(env.Name).To(Equal("key")) 74 | Expect(env.Value).To(Equal("value")) 75 | }) 76 | 77 | It("should return an env var", func() { 78 | env := getEnvVariableFromFieldSelector("key", "metadata.name") 79 | 80 | Expect(env).ToNot(BeNil()) 81 | Expect(env.Name).To(Equal("key")) 82 | Expect(env.ValueFrom.FieldRef).ToNot(BeNil()) 83 | Expect(env.ValueFrom.FieldRef.FieldPath).To(Equal("metadata.name")) 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_jobs.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | 8 | "github.com/kuptan/terraform-operator/internal/kube" 9 | "github.com/kuptan/terraform-operator/internal/utils" 10 | batchv1 "k8s.io/api/batch/v1" 11 | corev1 "k8s.io/api/core/v1" 12 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 13 | ) 14 | 15 | const ( 16 | tfVarsMountPath string = "/tmp/tfvars" 17 | moduleWorkingDirMountPath string = "/tmp/tfmodule" 18 | conifgMapModuleMountPath string = "/terraform/modules" 19 | gitSSHKeyMountPath string = "/root/.ssh" 20 | 21 | knownHostsVolumeName string = "known-hosts" 22 | emptyDirVolumeName string = "tfmodule" 23 | gitSSHKeyVolumeName string = "git-ssh" 24 | ) 25 | 26 | // getTerraformRunnerDockerImage returns the Docker image for the Terraform Runner 27 | func getTerraformRunnerDockerImage() string { 28 | return fmt.Sprintf("%s/%s:%s", utils.Env.DockerRepository, utils.Env.TerraformRunnerImage, utils.Env.TerraformRunnerImageTag) 29 | } 30 | 31 | // getBusyboxDockerImage returns the busy box image 32 | func getBusyboxDockerImage() string { 33 | return fmt.Sprintf("%s/%s", utils.Env.DockerRepository, "busybox") 34 | } 35 | 36 | // getEnvVarKey appends the prefix TF_VAR_ to the Terraform variable if its not marked as an environment variable 37 | func getEnvVarKey(v Variable) string { 38 | prefix := "" 39 | 40 | if !v.EnvironmentVariable { 41 | prefix = "TF_VAR_" 42 | } 43 | 44 | return fmt.Sprintf("%s%s", prefix, v.Key) 45 | } 46 | 47 | // getRunnerSpecificEnvVars returns a list of environment variables to add to the Terraform Runner container 48 | func (t *Terraform) getRunnerSpecificEnvVars() []corev1.EnvVar { 49 | envVars := []corev1.EnvVar{} 50 | 51 | envVars = append(envVars, getEnvVariable("TERRAFORM_VERSION", t.Spec.TerraformVersion)) 52 | envVars = append(envVars, getEnvVariable("TERRAFORM_WORKING_DIR", moduleWorkingDirMountPath)) 53 | envVars = append(envVars, getEnvVariable("TERRAFORM_VAR_FILES_PATH", tfVarsMountPath)) 54 | envVars = append(envVars, getEnvVariable("OUTPUT_SECRET_NAME", getOutputSecretname(t.Name))) 55 | envVars = append(envVars, getEnvVariable("TERRAFORM_DESTROY", strconv.FormatBool(t.Spec.Destroy))) 56 | 57 | envVars = append(envVars, getEnvVariableFromFieldSelector("POD_NAMESPACE", "metadata.namespace")) 58 | 59 | if t.Spec.Workspace != "" { 60 | envVars = append(envVars, getEnvVariable("TERRAFORM_WORKSPACE", t.Spec.Workspace)) 61 | } 62 | 63 | return envVars 64 | } 65 | 66 | // getEnvVariables returns Kubernetes Pod environment variables (corev1.EnvVar) to be passed to the workflow/run job 67 | func (t *Terraform) getEnvVariables() []corev1.EnvVar { 68 | vars := []corev1.EnvVar{} 69 | 70 | for _, v := range t.Spec.Variables { 71 | if v.ValueFrom != nil { 72 | vars = append(vars, corev1.EnvVar{ 73 | Name: getEnvVarKey(v), 74 | ValueFrom: v.ValueFrom, 75 | }) 76 | } 77 | 78 | if v.Value != "" { 79 | vars = append(vars, corev1.EnvVar{ 80 | Name: getEnvVarKey(v), 81 | Value: v.Value, 82 | }) 83 | } 84 | } 85 | 86 | vars = append(vars, t.getRunnerSpecificEnvVars()...) 87 | 88 | return vars 89 | } 90 | 91 | // getRunnerSpecificVolumes returns the workflow/run volumes list 92 | func (t *Terraform) getRunnerSpecificVolumes() []corev1.Volume { 93 | volumes := []corev1.Volume{} 94 | 95 | name := getUniqueResourceName(t.Name, t.Status.RunID) 96 | 97 | volumes = append(volumes, getEmptyDirVolume(emptyDirVolumeName)) 98 | volumes = append(volumes, getVolumeSpecFromConfigMap(name, name)) 99 | 100 | if t.Spec.GitSSHKey != nil && t.Spec.GitSSHKey.ValueFrom != nil { 101 | volumes = append(volumes, getVolumeSpec(gitSSHKeyVolumeName, *t.Spec.GitSSHKey.ValueFrom)) 102 | volumes = append(volumes, getVolumeSpecFromConfigMap(knownHostsVolumeName, utils.Env.KnownHostsConfigMapName)) 103 | } 104 | 105 | return volumes 106 | } 107 | 108 | // getJobVolumes return the Kubernetes Job volumes as a list of corev1.Volume 109 | func (t *Terraform) getJobVolumes() []corev1.Volume { 110 | volumes := []corev1.Volume{} 111 | 112 | for _, file := range t.Spec.VariableFiles { 113 | volumes = append(volumes, getVolumeSpec(file.Key, *file.ValueFrom)) 114 | } 115 | 116 | volumes = append(volumes, t.getRunnerSpecificVolumes()...) 117 | 118 | return volumes 119 | } 120 | 121 | // getRunnerSpecificVolumeMounts returns a list of volume mounts 122 | func (t *Terraform) getRunnerSpecificVolumeMounts() []corev1.VolumeMount { 123 | mounts := []corev1.VolumeMount{} 124 | 125 | mounts = append(mounts, getVolumeMountSpec(emptyDirVolumeName, moduleWorkingDirMountPath, false)) 126 | mounts = append(mounts, getVolumeMountSpec(getUniqueResourceName(t.Name, t.Status.RunID), conifgMapModuleMountPath, false)) 127 | 128 | if t.Spec.GitSSHKey != nil && t.Spec.GitSSHKey.ValueFrom != nil { 129 | sshKeyFileName := "id_rsa" 130 | sshKnownHostsFileName := "known_hosts" 131 | 132 | sshKeyMountPath := fmt.Sprintf("%s/%s", gitSSHKeyMountPath, sshKeyFileName) 133 | sshKnownHostsMountPath := fmt.Sprintf("%s/%s", gitSSHKeyMountPath, sshKnownHostsFileName) 134 | 135 | mounts = append(mounts, getVolumeMountSpecWithSubPath(gitSSHKeyVolumeName, sshKeyMountPath, sshKeyFileName, false)) 136 | mounts = append(mounts, getVolumeMountSpecWithSubPath(knownHostsVolumeName, sshKnownHostsMountPath, sshKnownHostsFileName, false)) 137 | } 138 | 139 | return mounts 140 | } 141 | 142 | // getJobVolumeMounts return the volumes mounts for the Kubernetes Job of the workflow/run 143 | func (t *Terraform) getJobVolumeMounts() []corev1.VolumeMount { 144 | mounts := []corev1.VolumeMount{} 145 | 146 | for _, file := range t.Spec.VariableFiles { 147 | mountPath := fmt.Sprintf("%s/%s", tfVarsMountPath, file.Key) 148 | mounts = append(mounts, getVolumeMountSpec(file.Key, mountPath, true)) 149 | } 150 | 151 | mounts = append(mounts, t.getRunnerSpecificVolumeMounts()...) 152 | 153 | return mounts 154 | } 155 | 156 | // getInitContainersSpec returns the initContainers definition for the workflow/run job 157 | func getInitContainersSpec(t *Terraform) []corev1.Container { 158 | containers := []corev1.Container{} 159 | 160 | cpModule := fmt.Sprintf("cp %s/main.tf %s/main.tf", conifgMapModuleMountPath, moduleWorkingDirMountPath) 161 | 162 | commands := []string{ 163 | "/bin/sh", 164 | "-c", 165 | } 166 | 167 | args := []string{ 168 | cpModule, 169 | } 170 | 171 | containers = append(containers, corev1.Container{ 172 | Name: "busybox", 173 | Image: getBusyboxDockerImage(), 174 | VolumeMounts: t.getRunnerSpecificVolumeMounts(), 175 | Command: commands, 176 | Args: args, 177 | }) 178 | 179 | return containers 180 | } 181 | 182 | // getJobSpecForRun returns a Kubernetes job spec for the Terraform Runner 183 | func getJobSpecForRun(t *Terraform, owner metav1.OwnerReference) *batchv1.Job { 184 | 185 | envVars := t.getEnvVariables() 186 | volumes := t.getJobVolumes() 187 | mounts := t.getJobVolumeMounts() 188 | 189 | job := &batchv1.Job{ 190 | ObjectMeta: metav1.ObjectMeta{ 191 | Name: getUniqueResourceName(t.Name, t.Status.RunID), 192 | Namespace: t.Namespace, 193 | Labels: getCommonLabels(t.Name, t.Status.RunID), 194 | OwnerReferences: []metav1.OwnerReference{ 195 | owner, 196 | }, 197 | }, 198 | Spec: batchv1.JobSpec{ 199 | Template: corev1.PodTemplateSpec{ 200 | ObjectMeta: metav1.ObjectMeta{ 201 | Labels: getCommonLabels(t.Name, t.Status.RunID), 202 | }, 203 | Spec: corev1.PodSpec{ 204 | ServiceAccountName: "terraform-runner", 205 | InitContainers: getInitContainersSpec(t), 206 | Containers: []corev1.Container{ 207 | { 208 | Name: "terraform", 209 | Image: getTerraformRunnerDockerImage(), 210 | VolumeMounts: mounts, 211 | Env: envVars, 212 | ImagePullPolicy: corev1.PullIfNotPresent, 213 | }, 214 | }, 215 | Volumes: volumes, 216 | RestartPolicy: corev1.RestartPolicyNever, 217 | }, 218 | }, 219 | }, 220 | } 221 | 222 | job.Spec.BackoffLimit = &t.Spec.RetryLimit 223 | 224 | return job 225 | } 226 | 227 | // getJobForRun returns the Kubernetes Job of a specific workflow/run 228 | func getJobForRun(ctx context.Context, runName string, namespace string, runID string) (*batchv1.Job, error) { 229 | jobs := kube.ClientSet.BatchV1().Jobs(namespace) 230 | 231 | name := getUniqueResourceName(runName, runID) 232 | 233 | job, err := jobs.Get(ctx, name, metav1.GetOptions{}) 234 | 235 | if err != nil { 236 | return nil, err 237 | } 238 | 239 | return job, err 240 | } 241 | 242 | // createJobForRun creates a Kubernetes Job to execute the workflow/run 243 | func createJobForRun(ctx context.Context, run *Terraform) (*batchv1.Job, error) { 244 | jobs := kube.ClientSet.BatchV1().Jobs(run.Namespace) 245 | 246 | ownerRef := run.GetOwnerReference() 247 | 248 | job := getJobSpecForRun(run, ownerRef) 249 | 250 | if _, err := jobs.Create(ctx, job, metav1.CreateOptions{}); err != nil { 251 | return nil, err 252 | } 253 | 254 | return job, nil 255 | } 256 | 257 | // deleteJobByRun deletes the Kubernetes Job of the workflow/run 258 | func deleteJobByRun(ctx context.Context, runName string, namespace string, runID string) error { 259 | jobs := kube.ClientSet.BatchV1().Jobs(namespace) 260 | 261 | resourceName := getUniqueResourceName(runName, runID) 262 | 263 | deletePolicy := metav1.DeletePropagationForeground 264 | 265 | if err := jobs.Delete(ctx, resourceName, metav1.DeleteOptions{ 266 | PropagationPolicy: &deletePolicy, 267 | }); err != nil { 268 | return err 269 | } 270 | 271 | return nil 272 | } 273 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_jobs_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | 7 | batchv1 "k8s.io/api/batch/v1" 8 | corev1 "k8s.io/api/core/v1" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | 11 | "github.com/kuptan/terraform-operator/internal/kube" 12 | . "github.com/onsi/ginkgo" 13 | . "github.com/onsi/gomega" 14 | ) 15 | 16 | var _ = Describe("Kubernetes Jobs", func() { 17 | BeforeEach(func() { 18 | // Add any setup steps that needs to be executed before each test 19 | }) 20 | 21 | AfterEach(func() { 22 | // Add any teardown steps that needs to be executed after each test 23 | }) 24 | 25 | Context("Job Spec Validation", func() { 26 | var job *batchv1.Job 27 | 28 | run := &Terraform{ 29 | ObjectMeta: metav1.ObjectMeta{ 30 | Name: "bar", 31 | Namespace: "default", 32 | }, 33 | Spec: TerraformSpec{ 34 | TerraformVersion: "1.0.2", 35 | Workspace: "dev", 36 | Module: Module{ 37 | Source: "IbraheemAlSaady/test/module", 38 | Version: "0.0.1", 39 | }, 40 | GitSSHKey: &GitSSHKey{ 41 | ValueFrom: &corev1.VolumeSource{ 42 | Secret: &corev1.SecretVolumeSource{ 43 | SecretName: "mysecret", 44 | }, 45 | }, 46 | }, 47 | Destroy: false, 48 | DeleteCompletedJobs: false, 49 | }, 50 | Status: TerraformStatus{ 51 | RunID: "12345", 52 | }, 53 | } 54 | 55 | ownerRef := metav1.OwnerReference{ 56 | APIVersion: fmt.Sprintf("%s/%s", GroupVersion.Group, GroupVersion.Version), 57 | Kind: "terraform", 58 | Name: "foot", 59 | UID: "1234", 60 | } 61 | 62 | It("returns the job spec and should not be null", func() { 63 | jobSpec := getJobSpecForRun(run, ownerRef) 64 | 65 | Expect(jobSpec).ToNot(BeNil()) 66 | 67 | job = jobSpec 68 | }) 69 | 70 | It("should contain a volume for the git ssh", func() { 71 | var sshVolume *corev1.Volume 72 | 73 | for _, v := range job.Spec.Template.Spec.Volumes { 74 | vol := v 75 | if v.Name == gitSSHKeyVolumeName { 76 | sshVolume = &vol 77 | break 78 | } 79 | } 80 | 81 | Expect(sshVolume).ToNot(BeNil()) 82 | Expect(sshVolume.Name).To(Equal(gitSSHKeyVolumeName)) 83 | Expect(sshVolume.VolumeSource.Secret.SecretName).To(Equal(run.Spec.GitSSHKey.ValueFrom.Secret.SecretName)) 84 | }) 85 | 86 | It("should contain an environment variable for Terraform workspace", func() { 87 | var envVar corev1.EnvVar 88 | 89 | for _, e := range job.Spec.Template.Spec.Containers[0].Env { 90 | if e.Name == "TERRAFORM_WORKSPACE" { 91 | envVar = e 92 | break 93 | } 94 | } 95 | 96 | Expect(envVar).ToNot(BeNil()) 97 | Expect(envVar.Value).To(Equal(run.Spec.Workspace)) 98 | }) 99 | }) 100 | 101 | Context("Multi var file job", func() { 102 | var job *batchv1.Job 103 | 104 | run := &Terraform{ 105 | ObjectMeta: metav1.ObjectMeta{ 106 | Name: "bar1", 107 | Namespace: "default", 108 | }, 109 | Spec: TerraformSpec{ 110 | TerraformVersion: "1.0.2", 111 | Module: Module{ 112 | Source: "IbraheemAlSaady/test/module", 113 | Version: "0.0.1", 114 | }, 115 | VariableFiles: []VariableFile{ 116 | VariableFile{ 117 | Key: "common", 118 | ValueFrom: &corev1.VolumeSource{ 119 | ConfigMap: &corev1.ConfigMapVolumeSource{ 120 | LocalObjectReference: corev1.LocalObjectReference{ 121 | Name: "cfg1", 122 | }, 123 | }, 124 | }, 125 | }, 126 | VariableFile{ 127 | Key: "data", 128 | ValueFrom: &corev1.VolumeSource{ 129 | ConfigMap: &corev1.ConfigMapVolumeSource{ 130 | LocalObjectReference: corev1.LocalObjectReference{ 131 | Name: "cfg2", 132 | }, 133 | }, 134 | }, 135 | }, 136 | }, 137 | }, 138 | Status: TerraformStatus{ 139 | RunID: "12345", 140 | }, 141 | } 142 | 143 | ownerRef := metav1.OwnerReference{ 144 | APIVersion: fmt.Sprintf("%s/%s", GroupVersion.Group, GroupVersion.Version), 145 | Kind: "terraform", 146 | Name: "foot", 147 | UID: "1234", 148 | } 149 | 150 | It("should return the job spec", func() { 151 | jobSpec := getJobSpecForRun(run, ownerRef) 152 | 153 | Expect(jobSpec).ToNot(BeNil()) 154 | 155 | job = jobSpec 156 | }) 157 | 158 | It("should be able to create the job", func() { 159 | created, err := kube.ClientSet.BatchV1().Jobs("default").Create(context.Background(), job, metav1.CreateOptions{}) 160 | 161 | Expect(err).ToNot(HaveOccurred()) 162 | Expect(created).ToNot(BeNil()) 163 | }) 164 | }) 165 | }) 166 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_rbac.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/kuptan/terraform-operator/internal/kube" 7 | corev1 "k8s.io/api/core/v1" 8 | rbacv1 "k8s.io/api/rbac/v1" 9 | "k8s.io/apimachinery/pkg/api/errors" 10 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 11 | ) 12 | 13 | // createServiceAccount creates a Kubernetes ServiceAccount for the Terraform Runner 14 | func createServiceAccount(ctx context.Context, name string, namespace string) (*corev1.ServiceAccount, error) { 15 | key := &corev1.ServiceAccount{ 16 | ObjectMeta: metav1.ObjectMeta{ 17 | Name: name, 18 | Namespace: namespace, 19 | }, 20 | } 21 | 22 | sa, err := kube.ClientSet.CoreV1().ServiceAccounts(namespace).Create(ctx, key, metav1.CreateOptions{}) 23 | 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return sa, nil 29 | } 30 | 31 | // createRoleBinding creates a Kubernetes RoleBinding for the Terraform Runner 32 | func createRoleBinding(ctx context.Context, name string, namespace string) (*rbacv1.RoleBinding, error) { 33 | key := &rbacv1.RoleBinding{ 34 | ObjectMeta: metav1.ObjectMeta{ 35 | Name: name, 36 | Namespace: namespace, 37 | }, 38 | RoleRef: rbacv1.RoleRef{ 39 | Kind: "ClusterRole", 40 | Name: name, 41 | APIGroup: "rbac.authorization.k8s.io", 42 | }, 43 | Subjects: []rbacv1.Subject{ 44 | rbacv1.Subject{ 45 | Kind: "ServiceAccount", 46 | Name: name, 47 | Namespace: namespace, 48 | }, 49 | }, 50 | } 51 | 52 | role, err := kube.ClientSet.RbacV1().RoleBindings(namespace).Create(ctx, key, metav1.CreateOptions{}) 53 | 54 | if err != nil { 55 | return nil, err 56 | } 57 | 58 | return role, nil 59 | } 60 | 61 | // isServiceAccountExist checks whether the ServiceAccount for the Terraform Runner exist 62 | func isServiceAccountExist(ctx context.Context, name string, namespace string) (bool, error) { 63 | _, err := kube.ClientSet.CoreV1().ServiceAccounts(namespace).Get(ctx, name, metav1.GetOptions{}) 64 | 65 | if err != nil { 66 | if errors.IsNotFound(err) { 67 | return false, nil 68 | } 69 | 70 | return false, err 71 | } 72 | 73 | return true, nil 74 | } 75 | 76 | // isRoleBindingExist checks if the RoleBinding for the Terraform Runner exists 77 | func isRoleBindingExist(ctx context.Context, name string, namespace string) (bool, error) { 78 | _, err := kube.ClientSet.RbacV1().RoleBindings(namespace).Get(ctx, name, metav1.GetOptions{}) 79 | 80 | if err != nil { 81 | if errors.IsNotFound(err) { 82 | return false, nil 83 | } 84 | 85 | return false, err 86 | } 87 | 88 | return true, nil 89 | } 90 | 91 | // createRbacConfigIfNotExist validates if RBAC exist for the Terraform Runner and creates it if not exist 92 | func createRbacConfigIfNotExist(ctx context.Context, name string, namespace string) error { 93 | saExist, err := isServiceAccountExist(ctx, name, namespace) 94 | 95 | if err != nil { 96 | return err 97 | } 98 | 99 | roleBindingExist, err := isRoleBindingExist(ctx, name, namespace) 100 | 101 | if err != nil { 102 | return err 103 | } 104 | 105 | if !saExist { 106 | if _, err := createServiceAccount(ctx, name, namespace); err != nil { 107 | return err 108 | } 109 | } 110 | 111 | if !roleBindingExist { 112 | if _, err := createRoleBinding(ctx, name, namespace); err != nil { 113 | return err 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_rbac_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | 8 | "github.com/kuptan/terraform-operator/internal/kube" 9 | . "github.com/onsi/ginkgo" 10 | . "github.com/onsi/gomega" 11 | ) 12 | 13 | var _ = Describe("Kubernetes RBAC", func() { 14 | BeforeEach(func() { 15 | // Add any setup steps that needs to be executed before each test 16 | }) 17 | 18 | AfterEach(func() { 19 | // Add any teardown steps that needs to be executed after each test 20 | }) 21 | 22 | rbacName := "rbac-name" 23 | namespace := "default" 24 | 25 | Context("RBAC", func() { 26 | 27 | It("service account should not be found", func() { 28 | found, err := isServiceAccountExist(context.Background(), rbacName, namespace) 29 | 30 | Expect(err).ToNot(HaveOccurred()) 31 | Expect(found).To(BeFalse()) 32 | }) 33 | 34 | It("role binding should not be found", func() { 35 | found, err := isRoleBindingExist(context.Background(), rbacName, namespace) 36 | 37 | Expect(err).ToNot(HaveOccurred()) 38 | Expect(found).To(BeFalse()) 39 | }) 40 | 41 | It("should create service account and role binding", func() { 42 | err := createRbacConfigIfNotExist(context.Background(), rbacName, namespace) 43 | Expect(err).ToNot(HaveOccurred()) 44 | 45 | sa, err := kube.ClientSet.CoreV1().ServiceAccounts(namespace).Get(context.Background(), rbacName, metav1.GetOptions{}) 46 | 47 | Expect(err).ToNot(HaveOccurred()) 48 | Expect(sa.Name).To(Equal(rbacName)) 49 | 50 | roleBinding, err := kube.ClientSet.RbacV1().RoleBindings(namespace).Get(context.Background(), rbacName, metav1.GetOptions{}) 51 | 52 | Expect(err).ToNot(HaveOccurred()) 53 | Expect(roleBinding.Name).To(Equal(rbacName)) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_secrets.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/kuptan/terraform-operator/internal/kube" 7 | corev1 "k8s.io/api/core/v1" 8 | "k8s.io/apimachinery/pkg/api/errors" 9 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 10 | "k8s.io/apimachinery/pkg/types" 11 | ) 12 | 13 | // isSecretExist checks whether a Secret exist 14 | func isSecretExist(ctx context.Context, name string, namespace string) (*corev1.Secret, error) { 15 | secret, err := kube.ClientSet.CoreV1().Secrets(namespace).Get(ctx, name, metav1.GetOptions{}) 16 | 17 | if err != nil { 18 | if errors.IsNotFound(err) { 19 | return nil, nil 20 | } 21 | 22 | return nil, err 23 | } 24 | 25 | return secret, nil 26 | } 27 | 28 | // createSecretForOutputs creates a secret to store the the Terraform output of the workflow/run 29 | func createSecretForOutputs(ctx context.Context, namespacedName types.NamespacedName, t *Terraform) (*corev1.Secret, error) { 30 | secretName := getOutputSecretname(namespacedName.Name) 31 | 32 | exist, err := isSecretExist(ctx, secretName, namespacedName.Namespace) 33 | 34 | if err != nil { 35 | return nil, err 36 | } 37 | 38 | if exist != nil { 39 | return exist, nil 40 | } 41 | 42 | secrets := kube.ClientSet.CoreV1().Secrets(namespacedName.Namespace) 43 | 44 | obj := &corev1.Secret{ 45 | ObjectMeta: metav1.ObjectMeta{ 46 | Name: secretName, 47 | Labels: getCommonLabels(namespacedName.Name, t.Status.RunID), 48 | OwnerReferences: []metav1.OwnerReference{ 49 | t.GetOwnerReference(), 50 | }, 51 | }, 52 | Type: corev1.SecretTypeOpaque, 53 | Data: map[string][]byte{}, 54 | } 55 | 56 | secret, err := secrets.Create(ctx, obj, metav1.CreateOptions{}) 57 | 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | return secret, nil 63 | } 64 | -------------------------------------------------------------------------------- /api/v1alpha1/k8s_secrets_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "context" 5 | 6 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 7 | "k8s.io/apimachinery/pkg/types" 8 | 9 | "github.com/kuptan/terraform-operator/internal/kube" 10 | . "github.com/onsi/ginkgo" 11 | . "github.com/onsi/gomega" 12 | ) 13 | 14 | var _ = Describe("Kubernetes Secrets", func() { 15 | BeforeEach(func() { 16 | // Add any setup steps that needs to be executed before each test 17 | }) 18 | 19 | AfterEach(func() { 20 | // Add any teardown steps that needs to be executed after each test 21 | }) 22 | 23 | Context("Secrets", func() { 24 | key := types.NamespacedName{ 25 | Name: "bar", 26 | Namespace: "default", 27 | } 28 | 29 | run := &Terraform{ 30 | ObjectMeta: metav1.ObjectMeta{ 31 | Name: "bar", 32 | Namespace: "default", 33 | }, 34 | Spec: TerraformSpec{ 35 | TerraformVersion: "1.0.2", 36 | Module: Module{ 37 | Source: "IbraheemAlSaady/test/module", 38 | Version: "0.0.2", 39 | }, 40 | Destroy: false, 41 | DeleteCompletedJobs: false, 42 | }, 43 | Status: TerraformStatus{ 44 | RunID: "1234", 45 | }, 46 | } 47 | 48 | expectedSecretName := key.Name + "-outputs" 49 | 50 | It("should create the secret successfully", func() { 51 | secret, err := createSecretForOutputs(context.Background(), key, run) 52 | 53 | Expect(err).ToNot(HaveOccurred()) 54 | Expect(secret).ToNot(BeNil()) 55 | Expect(secret.Name).To(Equal(expectedSecretName)) 56 | }) 57 | 58 | It("should retutrn the secret if exist", func() { 59 | secret, err := isSecretExist(context.Background(), expectedSecretName, key.Namespace) 60 | 61 | Expect(err).ToNot(HaveOccurred()) 62 | Expect(secret).ToNot(BeNil()) 63 | Expect(secret.Name).To(Equal(expectedSecretName)) 64 | }) 65 | 66 | It("should not fail to create a secret that already exist", func() { 67 | secret, err := createSecretForOutputs(context.Background(), key, run) 68 | 69 | Expect(err).ToNot(HaveOccurred()) 70 | Expect(secret).ToNot(BeNil()) 71 | Expect(secret.Name).To(Equal(expectedSecretName)) 72 | }) 73 | 74 | It("should return nils if a secret was not found", func() { 75 | secrets := kube.ClientSet.CoreV1().Secrets(key.Namespace) 76 | 77 | deletePolicy := metav1.DeletePropagationForeground 78 | 79 | secrets.Delete(context.Background(), expectedSecretName, metav1.DeleteOptions{ 80 | PropagationPolicy: &deletePolicy, 81 | }) 82 | 83 | secret, err := isSecretExist(context.Background(), expectedSecretName, key.Namespace) 84 | 85 | Expect(err).ToNot(HaveOccurred()) 86 | Expect(secret).To(BeNil()) 87 | }) 88 | }) 89 | }) 90 | -------------------------------------------------------------------------------- /api/v1alpha1/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022. 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 v1alpha1 18 | 19 | import ( 20 | "os" 21 | "path/filepath" 22 | "testing" 23 | 24 | . "github.com/onsi/ginkgo" 25 | . "github.com/onsi/gomega" 26 | "k8s.io/client-go/kubernetes/fake" 27 | "k8s.io/client-go/kubernetes/scheme" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | "sigs.k8s.io/controller-runtime/pkg/envtest" 30 | "sigs.k8s.io/controller-runtime/pkg/envtest/printer" 31 | logf "sigs.k8s.io/controller-runtime/pkg/log" 32 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 33 | 34 | "github.com/kuptan/terraform-operator/internal/kube" 35 | "github.com/kuptan/terraform-operator/internal/utils" 36 | //+kubebuilder:scaffold:imports 37 | ) 38 | 39 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 40 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 41 | 42 | var k8sClient client.Client 43 | var testEnv *envtest.Environment 44 | 45 | func TestAPIs(t *testing.T) { 46 | RegisterFailHandler(Fail) 47 | 48 | RunSpecsWithDefaultAndCustomReporters(t, 49 | "Terraform v1alpha1", 50 | []Reporter{printer.NewlineReporter{}}) 51 | } 52 | 53 | func resetClientSet() { 54 | kube.ClientSet = fake.NewSimpleClientset() 55 | } 56 | 57 | var _ = BeforeSuite(func() { 58 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 59 | 60 | By("bootstrapping test environment") 61 | testEnv = &envtest.Environment{ 62 | CRDDirectoryPaths: []string{filepath.Join("..", "..", "config", "crd", "bases")}, 63 | ErrorIfCRDPathMissing: true, 64 | } 65 | 66 | os.Setenv("DOCKER_REGISTRY", "docker.io") 67 | os.Setenv("TERRAFORM_RUNNER_IMAGE", "ibraheemalsaady/terraform-runner") 68 | os.Setenv("TERRAFORM_RUNNER_IMAGE_TAG", "0.0.3") 69 | os.Setenv("KNOWN_HOSTS_CONFIGMAP_NAME", "operator-known-hosts") 70 | 71 | err := SchemeBuilder.AddToScheme(scheme.Scheme) 72 | Expect(err).NotTo(HaveOccurred()) 73 | 74 | cfg, err := testEnv.Start() 75 | Expect(err).NotTo(HaveOccurred()) 76 | Expect(cfg).NotTo(BeNil()) 77 | 78 | //+kubebuilder:scaffold:scheme 79 | 80 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 81 | Expect(err).NotTo(HaveOccurred()) 82 | Expect(k8sClient).NotTo(BeNil()) 83 | 84 | kube.ClientSet = fake.NewSimpleClientset() 85 | 86 | utils.LoadEnv() 87 | }, 60) 88 | 89 | var _ = AfterSuite(func() { 90 | By("tearing down the test environment") 91 | err := testEnv.Stop() 92 | Expect(err).NotTo(HaveOccurred()) 93 | }) 94 | -------------------------------------------------------------------------------- /api/v1alpha1/terraform_template.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | "bytes" 5 | "text/template" 6 | ) 7 | 8 | // getTerraformModuleFromTemplate generates the Terraform module template 9 | func getTerraformModuleFromTemplate(run *Terraform) ([]byte, error) { 10 | tfTemplate, err := template.New("main.tf").Parse(`terraform { 11 | {{- if .Spec.Backend }} 12 | {{.Spec.Backend}} 13 | {{- end}} 14 | 15 | required_version = "~> {{.Spec.TerraformVersion}}" 16 | } 17 | 18 | {{- if .Spec.ProvidersConfig }} 19 | {{.Spec.ProvidersConfig}} 20 | {{- end}} 21 | 22 | {{- range .Spec.Variables}} 23 | {{- if not .EnvironmentVariable }} 24 | variable "{{.Key}}" {} 25 | {{- end}} 26 | {{- end}} 27 | 28 | ## additional-blocks 29 | 30 | module "operator" { 31 | source = "{{.Spec.Module.Source}}" 32 | 33 | {{- if .Spec.Module.Version }} 34 | version = "{{.Spec.Module.Version}}" 35 | {{- end}} 36 | 37 | {{- range .Spec.Variables}} 38 | {{- if not .EnvironmentVariable }} 39 | {{.Key}} = var.{{.Key}} 40 | {{- end}} 41 | {{- end}} 42 | } 43 | 44 | {{- range .Spec.Outputs}} 45 | output "{{.Key}}" { 46 | value = module.operator.{{.ModuleOutputName}} 47 | } 48 | {{- end}}`) 49 | 50 | if err != nil { 51 | return nil, err 52 | } 53 | var tpl bytes.Buffer 54 | 55 | if err := tfTemplate.Execute(&tpl, run); err != nil { 56 | return nil, err 57 | } 58 | 59 | return tpl.Bytes(), nil 60 | } 61 | -------------------------------------------------------------------------------- /api/v1alpha1/terraform_template_test.go: -------------------------------------------------------------------------------- 1 | package v1alpha1 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("Terraform Module", func() { 9 | expectedFile := `terraform { 10 | 11 | required_version = "~> 1.0.2" 12 | } 13 | variable "length" {} 14 | 15 | ## additional-blocks 16 | 17 | module "operator" { 18 | source = "IbraheemAlSaady/test/module" 19 | version = "0.0.1" 20 | length = var.length 21 | }` 22 | Context("Terraform Template", func() { 23 | It("should generate the final module", func() { 24 | run := &Terraform{ 25 | Spec: TerraformSpec{ 26 | TerraformVersion: "1.0.2", 27 | Module: Module{ 28 | Source: "IbraheemAlSaady/test/module", 29 | Version: "0.0.1", 30 | }, 31 | Variables: []Variable{ 32 | Variable{ 33 | Key: "length", 34 | Value: "16", 35 | }, 36 | }, 37 | Destroy: false, 38 | DeleteCompletedJobs: false, 39 | }, 40 | } 41 | 42 | tpl, err := getTerraformModuleFromTemplate(run) 43 | 44 | tplString := string(tpl) 45 | 46 | Expect(err).ToNot(HaveOccurred()) 47 | 48 | Expect(tplString).To(Equal(expectedFile)) 49 | }) 50 | }) 51 | }) 52 | -------------------------------------------------------------------------------- /api/v1alpha1/zz_generated.deepcopy.go: -------------------------------------------------------------------------------- 1 | //go:build !ignore_autogenerated 2 | // +build !ignore_autogenerated 3 | 4 | /* 5 | Copyright 2022. 6 | 7 | Licensed under the Apache License, Version 2.0 (the "License"); 8 | you may not use this file except in compliance with the License. 9 | You may obtain a copy of the License at 10 | 11 | http://www.apache.org/licenses/LICENSE-2.0 12 | 13 | Unless required by applicable law or agreed to in writing, software 14 | distributed under the License is distributed on an "AS IS" BASIS, 15 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 16 | See the License for the specific language governing permissions and 17 | limitations under the License. 18 | */ 19 | 20 | // Code generated by controller-gen. DO NOT EDIT. 21 | 22 | package v1alpha1 23 | 24 | import ( 25 | "k8s.io/api/core/v1" 26 | runtime "k8s.io/apimachinery/pkg/runtime" 27 | ) 28 | 29 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 30 | func (in *DependsOn) DeepCopyInto(out *DependsOn) { 31 | *out = *in 32 | } 33 | 34 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new DependsOn. 35 | func (in *DependsOn) DeepCopy() *DependsOn { 36 | if in == nil { 37 | return nil 38 | } 39 | out := new(DependsOn) 40 | in.DeepCopyInto(out) 41 | return out 42 | } 43 | 44 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 45 | func (in *GitSSHKey) DeepCopyInto(out *GitSSHKey) { 46 | *out = *in 47 | if in.ValueFrom != nil { 48 | in, out := &in.ValueFrom, &out.ValueFrom 49 | *out = new(v1.VolumeSource) 50 | (*in).DeepCopyInto(*out) 51 | } 52 | } 53 | 54 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new GitSSHKey. 55 | func (in *GitSSHKey) DeepCopy() *GitSSHKey { 56 | if in == nil { 57 | return nil 58 | } 59 | out := new(GitSSHKey) 60 | in.DeepCopyInto(out) 61 | return out 62 | } 63 | 64 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 65 | func (in *Module) DeepCopyInto(out *Module) { 66 | *out = *in 67 | } 68 | 69 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Module. 70 | func (in *Module) DeepCopy() *Module { 71 | if in == nil { 72 | return nil 73 | } 74 | out := new(Module) 75 | in.DeepCopyInto(out) 76 | return out 77 | } 78 | 79 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 80 | func (in *Output) DeepCopyInto(out *Output) { 81 | *out = *in 82 | } 83 | 84 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Output. 85 | func (in *Output) DeepCopy() *Output { 86 | if in == nil { 87 | return nil 88 | } 89 | out := new(Output) 90 | in.DeepCopyInto(out) 91 | return out 92 | } 93 | 94 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 95 | func (in *PreviousRunStatus) DeepCopyInto(out *PreviousRunStatus) { 96 | *out = *in 97 | } 98 | 99 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new PreviousRunStatus. 100 | func (in *PreviousRunStatus) DeepCopy() *PreviousRunStatus { 101 | if in == nil { 102 | return nil 103 | } 104 | out := new(PreviousRunStatus) 105 | in.DeepCopyInto(out) 106 | return out 107 | } 108 | 109 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 110 | func (in *Terraform) DeepCopyInto(out *Terraform) { 111 | *out = *in 112 | out.TypeMeta = in.TypeMeta 113 | in.ObjectMeta.DeepCopyInto(&out.ObjectMeta) 114 | in.Spec.DeepCopyInto(&out.Spec) 115 | out.Status = in.Status 116 | } 117 | 118 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Terraform. 119 | func (in *Terraform) DeepCopy() *Terraform { 120 | if in == nil { 121 | return nil 122 | } 123 | out := new(Terraform) 124 | in.DeepCopyInto(out) 125 | return out 126 | } 127 | 128 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 129 | func (in *Terraform) DeepCopyObject() runtime.Object { 130 | if c := in.DeepCopy(); c != nil { 131 | return c 132 | } 133 | return nil 134 | } 135 | 136 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 137 | func (in *TerraformDependencyRef) DeepCopyInto(out *TerraformDependencyRef) { 138 | *out = *in 139 | } 140 | 141 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformDependencyRef. 142 | func (in *TerraformDependencyRef) DeepCopy() *TerraformDependencyRef { 143 | if in == nil { 144 | return nil 145 | } 146 | out := new(TerraformDependencyRef) 147 | in.DeepCopyInto(out) 148 | return out 149 | } 150 | 151 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 152 | func (in *TerraformList) DeepCopyInto(out *TerraformList) { 153 | *out = *in 154 | out.TypeMeta = in.TypeMeta 155 | in.ListMeta.DeepCopyInto(&out.ListMeta) 156 | if in.Items != nil { 157 | in, out := &in.Items, &out.Items 158 | *out = make([]Terraform, len(*in)) 159 | for i := range *in { 160 | (*in)[i].DeepCopyInto(&(*out)[i]) 161 | } 162 | } 163 | } 164 | 165 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformList. 166 | func (in *TerraformList) DeepCopy() *TerraformList { 167 | if in == nil { 168 | return nil 169 | } 170 | out := new(TerraformList) 171 | in.DeepCopyInto(out) 172 | return out 173 | } 174 | 175 | // DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object. 176 | func (in *TerraformList) DeepCopyObject() runtime.Object { 177 | if c := in.DeepCopy(); c != nil { 178 | return c 179 | } 180 | return nil 181 | } 182 | 183 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 184 | func (in *TerraformSpec) DeepCopyInto(out *TerraformSpec) { 185 | *out = *in 186 | out.Module = in.Module 187 | if in.DependsOn != nil { 188 | in, out := &in.DependsOn, &out.DependsOn 189 | *out = make([]*DependsOn, len(*in)) 190 | for i := range *in { 191 | if (*in)[i] != nil { 192 | in, out := &(*in)[i], &(*out)[i] 193 | *out = new(DependsOn) 194 | **out = **in 195 | } 196 | } 197 | } 198 | if in.Variables != nil { 199 | in, out := &in.Variables, &out.Variables 200 | *out = make([]Variable, len(*in)) 201 | for i := range *in { 202 | (*in)[i].DeepCopyInto(&(*out)[i]) 203 | } 204 | } 205 | if in.VariableFiles != nil { 206 | in, out := &in.VariableFiles, &out.VariableFiles 207 | *out = make([]VariableFile, len(*in)) 208 | for i := range *in { 209 | (*in)[i].DeepCopyInto(&(*out)[i]) 210 | } 211 | } 212 | if in.Outputs != nil { 213 | in, out := &in.Outputs, &out.Outputs 214 | *out = make([]*Output, len(*in)) 215 | for i := range *in { 216 | if (*in)[i] != nil { 217 | in, out := &(*in)[i], &(*out)[i] 218 | *out = new(Output) 219 | **out = **in 220 | } 221 | } 222 | } 223 | if in.GitSSHKey != nil { 224 | in, out := &in.GitSSHKey, &out.GitSSHKey 225 | *out = new(GitSSHKey) 226 | (*in).DeepCopyInto(*out) 227 | } 228 | } 229 | 230 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformSpec. 231 | func (in *TerraformSpec) DeepCopy() *TerraformSpec { 232 | if in == nil { 233 | return nil 234 | } 235 | out := new(TerraformSpec) 236 | in.DeepCopyInto(out) 237 | return out 238 | } 239 | 240 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 241 | func (in *TerraformStatus) DeepCopyInto(out *TerraformStatus) { 242 | *out = *in 243 | } 244 | 245 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new TerraformStatus. 246 | func (in *TerraformStatus) DeepCopy() *TerraformStatus { 247 | if in == nil { 248 | return nil 249 | } 250 | out := new(TerraformStatus) 251 | in.DeepCopyInto(out) 252 | return out 253 | } 254 | 255 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 256 | func (in *Variable) DeepCopyInto(out *Variable) { 257 | *out = *in 258 | if in.ValueFrom != nil { 259 | in, out := &in.ValueFrom, &out.ValueFrom 260 | *out = new(v1.EnvVarSource) 261 | (*in).DeepCopyInto(*out) 262 | } 263 | if in.DependencyRef != nil { 264 | in, out := &in.DependencyRef, &out.DependencyRef 265 | *out = new(TerraformDependencyRef) 266 | **out = **in 267 | } 268 | } 269 | 270 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new Variable. 271 | func (in *Variable) DeepCopy() *Variable { 272 | if in == nil { 273 | return nil 274 | } 275 | out := new(Variable) 276 | in.DeepCopyInto(out) 277 | return out 278 | } 279 | 280 | // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil. 281 | func (in *VariableFile) DeepCopyInto(out *VariableFile) { 282 | *out = *in 283 | if in.ValueFrom != nil { 284 | in, out := &in.ValueFrom, &out.ValueFrom 285 | *out = new(v1.VolumeSource) 286 | (*in).DeepCopyInto(*out) 287 | } 288 | } 289 | 290 | // DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new VariableFile. 291 | func (in *VariableFile) DeepCopy() *VariableFile { 292 | if in == nil { 293 | return nil 294 | } 295 | out := new(VariableFile) 296 | in.DeepCopyInto(out) 297 | return out 298 | } 299 | -------------------------------------------------------------------------------- /config/crd/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # This kustomization.yaml is not intended to be run by itself, 2 | # since it depends on service name and namespace that are out of this kustomize package. 3 | # It should be run by config/default 4 | resources: 5 | - bases/run.terraform-operator.io_terraforms.yaml 6 | #+kubebuilder:scaffold:crdkustomizeresource 7 | 8 | patchesStrategicMerge: 9 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix. 10 | # patches here are for enabling the conversion webhook for each CRD 11 | #- patches/webhook_in_terraforms.yaml 12 | #+kubebuilder:scaffold:crdkustomizewebhookpatch 13 | 14 | # [CERTMANAGER] To enable cert-manager, uncomment all the sections with [CERTMANAGER] prefix. 15 | # patches here are for enabling the CA injection for each CRD 16 | #- patches/cainjection_in_terraforms.yaml 17 | #+kubebuilder:scaffold:crdkustomizecainjectionpatch 18 | 19 | # the following config is for teaching kustomize how to do kustomization for CRDs. 20 | configurations: 21 | - kustomizeconfig.yaml 22 | -------------------------------------------------------------------------------- /config/crd/kustomizeconfig.yaml: -------------------------------------------------------------------------------- 1 | # This file is for teaching kustomize how to substitute name and namespace reference in CRD 2 | nameReference: 3 | - kind: Service 4 | version: v1 5 | fieldSpecs: 6 | - kind: CustomResourceDefinition 7 | version: v1 8 | group: apiextensions.k8s.io 9 | path: spec/conversion/webhook/clientConfig/service/name 10 | 11 | namespace: 12 | - kind: CustomResourceDefinition 13 | version: v1 14 | group: apiextensions.k8s.io 15 | path: spec/conversion/webhook/clientConfig/service/namespace 16 | create: false 17 | 18 | varReference: 19 | - path: metadata/annotations 20 | -------------------------------------------------------------------------------- /config/crd/patches/cainjection_in_terraforms.yaml: -------------------------------------------------------------------------------- 1 | # The following patch adds a directive for certmanager to inject CA into the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | annotations: 6 | cert-manager.io/inject-ca-from: $(CERTIFICATE_NAMESPACE)/$(CERTIFICATE_NAME) 7 | name: terraforms.run.terraform-operator.io 8 | -------------------------------------------------------------------------------- /config/crd/patches/webhook_in_terraforms.yaml: -------------------------------------------------------------------------------- 1 | # The following patch enables a conversion webhook for the CRD 2 | apiVersion: apiextensions.k8s.io/v1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | name: terraforms.run.terraform-operator.io 6 | spec: 7 | conversion: 8 | strategy: Webhook 9 | webhook: 10 | clientConfig: 11 | service: 12 | namespace: system 13 | name: webhook-service 14 | path: /convert 15 | conversionReviewVersions: 16 | - v1 17 | -------------------------------------------------------------------------------- /config/default/kustomization.yaml: -------------------------------------------------------------------------------- 1 | # Adds namespace to all resources. 2 | namespace: terraform-operator-system 3 | 4 | # Value of this field is prepended to the 5 | # names of all resources, e.g. a deployment named 6 | # "wordpress" becomes "alices-wordpress". 7 | # Note that it should also match with the prefix (text before '-') of the namespace 8 | # field above. 9 | namePrefix: terraform-operator- 10 | 11 | # Labels to add to all resources and selectors. 12 | #commonLabels: 13 | # someName: someValue 14 | 15 | bases: 16 | - ../crd 17 | - ../rbac 18 | - ../manager 19 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 20 | # crd/kustomization.yaml 21 | #- ../webhook 22 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 'WEBHOOK' components are required. 23 | #- ../certmanager 24 | # [PROMETHEUS] To enable prometheus monitor, uncomment all sections with 'PROMETHEUS'. 25 | #- ../prometheus 26 | 27 | patchesStrategicMerge: 28 | # Protect the /metrics endpoint by putting it behind auth. 29 | # If you want your controller-manager to expose the /metrics 30 | # endpoint w/o any authn/z, please comment the following line. 31 | - manager_auth_proxy_patch.yaml 32 | 33 | # Mount the controller config file for loading manager configurations 34 | # through a ComponentConfig type 35 | #- manager_config_patch.yaml 36 | 37 | # [WEBHOOK] To enable webhook, uncomment all the sections with [WEBHOOK] prefix including the one in 38 | # crd/kustomization.yaml 39 | #- manager_webhook_patch.yaml 40 | 41 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER'. 42 | # Uncomment 'CERTMANAGER' sections in crd/kustomization.yaml to enable the CA injection in the admission webhooks. 43 | # 'CERTMANAGER' needs to be enabled to use ca injection 44 | #- webhookcainjection_patch.yaml 45 | 46 | # the following config is for teaching kustomize how to do var substitution 47 | vars: 48 | # [CERTMANAGER] To enable cert-manager, uncomment all sections with 'CERTMANAGER' prefix. 49 | #- name: CERTIFICATE_NAMESPACE # namespace of the certificate CR 50 | # objref: 51 | # kind: Certificate 52 | # group: cert-manager.io 53 | # version: v1 54 | # name: serving-cert # this name should match the one in certificate.yaml 55 | # fieldref: 56 | # fieldpath: metadata.namespace 57 | #- name: CERTIFICATE_NAME 58 | # objref: 59 | # kind: Certificate 60 | # group: cert-manager.io 61 | # version: v1 62 | # name: serving-cert # this name should match the one in certificate.yaml 63 | #- name: SERVICE_NAMESPACE # namespace of the service 64 | # objref: 65 | # kind: Service 66 | # version: v1 67 | # name: webhook-service 68 | # fieldref: 69 | # fieldpath: metadata.namespace 70 | #- name: SERVICE_NAME 71 | # objref: 72 | # kind: Service 73 | # version: v1 74 | # name: webhook-service 75 | -------------------------------------------------------------------------------- /config/default/manager_auth_proxy_patch.yaml: -------------------------------------------------------------------------------- 1 | # This patch inject a sidecar container which is a HTTP proxy for the 2 | # controller manager, it performs RBAC authorization against the Kubernetes API using SubjectAccessReviews. 3 | apiVersion: apps/v1 4 | kind: Deployment 5 | metadata: 6 | name: controller-manager 7 | namespace: system 8 | spec: 9 | template: 10 | spec: 11 | containers: 12 | - name: kube-rbac-proxy 13 | image: gcr.io/kubebuilder/kube-rbac-proxy:v0.8.0 14 | args: 15 | - "--secure-listen-address=0.0.0.0:8443" 16 | - "--upstream=http://127.0.0.1:8080/" 17 | - "--logtostderr=true" 18 | - "--v=10" 19 | ports: 20 | - containerPort: 8443 21 | protocol: TCP 22 | name: https 23 | - name: manager 24 | args: 25 | - "--health-probe-bind-address=:8081" 26 | - "--metrics-bind-address=127.0.0.1:8080" 27 | - "--leader-elect" 28 | -------------------------------------------------------------------------------- /config/default/manager_config_patch.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | spec: 7 | template: 8 | spec: 9 | containers: 10 | - name: manager 11 | args: 12 | - "--config=controller_manager_config.yaml" 13 | volumeMounts: 14 | - name: manager-config 15 | mountPath: /controller_manager_config.yaml 16 | subPath: controller_manager_config.yaml 17 | volumes: 18 | - name: manager-config 19 | configMap: 20 | name: manager-config 21 | -------------------------------------------------------------------------------- /config/manager/controller_manager_config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: controller-runtime.sigs.k8s.io/v1alpha1 2 | kind: ControllerManagerConfig 3 | health: 4 | healthProbeBindAddress: :8081 5 | metrics: 6 | bindAddress: 127.0.0.1:8080 7 | webhook: 8 | port: 9443 9 | leaderElection: 10 | leaderElect: true 11 | resourceName: d5cf1615.terraform-operator.io 12 | -------------------------------------------------------------------------------- /config/manager/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - manager.yaml 3 | 4 | generatorOptions: 5 | disableNameSuffixHash: true 6 | 7 | configMapGenerator: 8 | - name: manager-config 9 | files: 10 | - controller_manager_config.yaml 11 | -------------------------------------------------------------------------------- /config/manager/manager.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: system 7 | --- 8 | apiVersion: apps/v1 9 | kind: Deployment 10 | metadata: 11 | name: controller-manager 12 | namespace: system 13 | labels: 14 | control-plane: controller-manager 15 | spec: 16 | selector: 17 | matchLabels: 18 | control-plane: controller-manager 19 | replicas: 1 20 | template: 21 | metadata: 22 | annotations: 23 | kubectl.kubernetes.io/default-container: manager 24 | labels: 25 | control-plane: controller-manager 26 | spec: 27 | securityContext: 28 | runAsNonRoot: true 29 | containers: 30 | - command: 31 | - /manager 32 | args: 33 | - --leader-elect 34 | image: controller:latest 35 | name: manager 36 | securityContext: 37 | allowPrivilegeEscalation: false 38 | livenessProbe: 39 | httpGet: 40 | path: /healthz 41 | port: 8081 42 | initialDelaySeconds: 15 43 | periodSeconds: 20 44 | readinessProbe: 45 | httpGet: 46 | path: /readyz 47 | port: 8081 48 | initialDelaySeconds: 5 49 | periodSeconds: 10 50 | # TODO(user): Configure the resources accordingly based on the project requirements. 51 | # More info: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ 52 | resources: 53 | limits: 54 | cpu: 500m 55 | memory: 128Mi 56 | requests: 57 | cpu: 10m 58 | memory: 64Mi 59 | serviceAccountName: controller-manager 60 | terminationGracePeriodSeconds: 10 61 | -------------------------------------------------------------------------------- /config/manifest/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - terraform-operator.yaml -------------------------------------------------------------------------------- /config/manifest/terraform-operator.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | # Source: terraform-operator/templates/serviceaccount.yaml 3 | apiVersion: v1 4 | kind: ServiceAccount 5 | metadata: 6 | labels: 7 | app: terraform-operator 8 | annotations: 9 | null 10 | name: terraform-operator 11 | namespace: default 12 | automountServiceAccountToken: true 13 | --- 14 | # Source: terraform-operator/templates/clusterrole.yaml 15 | apiVersion: rbac.authorization.k8s.io/v1 16 | kind: ClusterRole 17 | metadata: 18 | name: terraform-operator 19 | labels: 20 | app: terraform-operator 21 | app.kubernetes.io/part-of: "terraform-operator" 22 | rules: 23 | - apiGroups: [""] 24 | resources: 25 | - configmaps 26 | - secrets 27 | - pods 28 | - serviceaccounts 29 | verbs: 30 | - create 31 | - delete 32 | - get 33 | - list 34 | - patch 35 | - update 36 | - watch 37 | - apiGroups: ["batch"] 38 | resources: 39 | - jobs 40 | verbs: 41 | - get 42 | - create 43 | - delete 44 | - list 45 | - watch 46 | - apiGroups: ["rbac.authorization.k8s.io"] 47 | resources: 48 | - rolebindings 49 | verbs: 50 | - create 51 | - delete 52 | - get 53 | - list 54 | - patch 55 | - update 56 | - watch 57 | - apiGroups: 58 | - run.terraform-operator.io 59 | resources: 60 | - terraforms 61 | verbs: 62 | - create 63 | - delete 64 | - get 65 | - list 66 | - patch 67 | - update 68 | - watch 69 | - apiGroups: 70 | - run.terraform-operator.io 71 | resources: 72 | - terraforms 73 | verbs: 74 | - create 75 | - delete 76 | - get 77 | - list 78 | - patch 79 | - update 80 | - watch 81 | - apiGroups: 82 | - run.terraform-operator.io 83 | resources: 84 | - terraforms/finalizers 85 | verbs: 86 | - update 87 | - apiGroups: 88 | - run.terraform-operator.io 89 | resources: 90 | - terraforms/status 91 | verbs: 92 | - get 93 | - patch 94 | - update 95 | 96 | - apiGroups: 97 | - "" 98 | - events.k8s.io 99 | resources: 100 | - events 101 | verbs: 102 | - '*' 103 | 104 | - apiGroups: ["coordination.k8s.io"] 105 | resources: ["leases"] 106 | verbs: ["create", "update", "watch", "get"] 107 | --- 108 | # Source: terraform-operator/templates/clusterrole.yaml 109 | apiVersion: rbac.authorization.k8s.io/v1 110 | kind: ClusterRole 111 | metadata: 112 | name: terraform-runner 113 | labels: 114 | app: terraform-operator 115 | app.kubernetes.io/part-of: "terraform-operator" 116 | rules: 117 | - apiGroups: [""] 118 | resources: ["secrets"] 119 | verbs: ["create", "get", "update", "list"] 120 | - apiGroups: ["coordination.k8s.io"] 121 | resources: ["leases"] 122 | verbs: ["create", "update", "watch", "get"] 123 | --- 124 | # Source: terraform-operator/templates/clusterrolebinding.yaml 125 | apiVersion: rbac.authorization.k8s.io/v1 126 | kind: ClusterRoleBinding 127 | metadata: 128 | name: terraform-operator 129 | labels: 130 | app: terraform-operator 131 | app.kubernetes.io/part-of: "terraform-operator" 132 | subjects: 133 | - kind: ServiceAccount 134 | name: terraform-operator 135 | namespace: default 136 | roleRef: 137 | kind: ClusterRole 138 | name: terraform-operator 139 | apiGroup: rbac.authorization.k8s.io 140 | --- 141 | apiVersion: v1 142 | kind: ConfigMap 143 | metadata: 144 | name: terraform-operator-known-hosts 145 | data: 146 | known_hosts: |- 147 | bitbucket.org ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kNvEcqTKl/VqLat/MaB33pZy0y3rJZtnqwR2qOOvbwKZYKiEO1O6VqNEBxKvJJelCq0dTXWT5pbO2gDXC6h6QDXCaHo6pOHGPUy+YBaGQRGuSusMEASYiWunYN0vCAI8QaXnWMXNMdFP3jHAJH0eDsoiGnLPBlBp4TNm6rYI74nMzgz3B9IikW4WVK+dc8KZJZWYjAuORU3jc1c/NPskD2ASinf8v3xnfXeukU0sJ5N6m5E8VLjObPEO+mN2t/FZTMZLiFqPWc/ALSqnMnnhwrNi2rbfg/rd/IpL8Le3pSBne8+seeFVBoGqzHM9yXw== 148 | github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= 149 | github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl 150 | github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== 151 | gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY= 152 | gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf 153 | gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9 154 | ssh.dev.azure.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7Hr1oTWqNqOlzGJOfGJ4NakVyIzf1rXYd4d7wo6jBlkLvCA4odBlL0mDUyZ0/QUfTTqeu+tm22gOsv+VrVTMk6vwRU75gY/y9ut5Mb3bR5BV58dKXyq9A9UeB5Cakehn5Zgm6x1mKoVyf+FFn26iYqXJRgzIZZcZ5V6hrE0Qg39kZm4az48o0AUbf6Sp4SLdvnuMa2sVNwHBboS7EJkm57XQPVU3/QpyNLHbWDdzwtrlS+ez30S3AdYhLKEOxAG8weOnyrtLJAUen9mTkol8oII1edf7mWWbWVf0nBmly21+nZcmCTISQBtdcyPaEno7fFQMDD26/s0lfKob4Kw8H 155 | vs-ssh.visualstudio.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7Hr1oTWqNqOlzGJOfGJ4NakVyIzf1rXYd4d7wo6jBlkLvCA4odBlL0mDUyZ0/QUfTTqeu+tm22gOsv+VrVTMk6vwRU75gY/y9ut5Mb3bR5BV58dKXyq9A9UeB5Cakehn5Zgm6x1mKoVyf+FFn26iYqXJRgzIZZcZ5V6hrE0Qg39kZm4az48o0AUbf6Sp4SLdvnuMa2sVNwHBboS7EJkm57XQPVU3/QpyNLHbWDdzwtrlS+ez30S3AdYhLKEOxAG8weOnyrtLJAUen9mTkol8oII1edf7mWWbWVf0nBmly21+nZcmCTISQBtdcyPaEno7fFQMDD26/s0lfKob4Kw8H 156 | --- 157 | # Source: terraform-operator/templates/deployment.yaml 158 | apiVersion: apps/v1 159 | kind: Deployment 160 | metadata: 161 | name: terraform-operator 162 | namespace: default 163 | labels: 164 | app: terraform-operator 165 | annotations: 166 | spec: 167 | replicas: 1 168 | selector: 169 | matchLabels: 170 | app: terraform-operator 171 | template: 172 | metadata: 173 | labels: 174 | app: terraform-operator 175 | app.kubernetes.io/part-of: "terraform-operator" 176 | spec: 177 | serviceAccountName: terraform-operator 178 | 179 | imagePullSecrets: 180 | 181 | containers: 182 | - name: terraform-operator 183 | image: docker.io/kubechamp/terraform-operator:0.1.3 184 | imagePullPolicy: IfNotPresent 185 | 186 | securityContext: 187 | readOnlyRootFilesystem: true 188 | 189 | ports: 190 | - name: http-metrics 191 | containerPort: 8080 192 | protocol: TCP 193 | 194 | env: 195 | - name: DOCKER_REGISTRY 196 | value: docker.io 197 | - name: TERRAFORM_RUNNER_IMAGE 198 | value: kubechamp/terraform-runner 199 | - name: TERRAFORM_RUNNER_IMAGE_TAG 200 | value: 0.0.4 201 | - name: KNOWN_HOSTS_CONFIGMAP_NAME 202 | value: terraform-operator-known-hosts 203 | --- 204 | apiVersion: v1 205 | kind: Service 206 | metadata: 207 | name: terraform-operator-metrics 208 | labels: 209 | app: terraform-operator 210 | namespace: "default" 211 | annotations: 212 | null 213 | spec: 214 | type: ClusterIP 215 | ports: 216 | - name: http-metrics 217 | port: 8080 218 | targetPort: http-metrics 219 | selector: 220 | app: terraform-operator -------------------------------------------------------------------------------- /config/prometheus/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | - monitor.yaml 3 | -------------------------------------------------------------------------------- /config/prometheus/monitor.yaml: -------------------------------------------------------------------------------- 1 | 2 | # Prometheus Monitor Service (Metrics) 3 | apiVersion: monitoring.coreos.com/v1 4 | kind: ServiceMonitor 5 | metadata: 6 | labels: 7 | control-plane: controller-manager 8 | name: controller-manager-metrics-monitor 9 | namespace: system 10 | spec: 11 | endpoints: 12 | - path: /metrics 13 | port: https 14 | scheme: https 15 | bearerTokenFile: /var/run/secrets/kubernetes.io/serviceaccount/token 16 | tlsConfig: 17 | insecureSkipVerify: true 18 | selector: 19 | matchLabels: 20 | control-plane: controller-manager 21 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_client_clusterrole.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: metrics-reader 5 | rules: 6 | - nonResourceURLs: 7 | - "/metrics" 8 | verbs: 9 | - get 10 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | name: proxy-role 5 | rules: 6 | - apiGroups: 7 | - authentication.k8s.io 8 | resources: 9 | - tokenreviews 10 | verbs: 11 | - create 12 | - apiGroups: 13 | - authorization.k8s.io 14 | resources: 15 | - subjectaccessreviews 16 | verbs: 17 | - create 18 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: proxy-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: proxy-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/auth_proxy_service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | labels: 5 | control-plane: controller-manager 6 | name: controller-manager-metrics-service 7 | namespace: system 8 | spec: 9 | ports: 10 | - name: https 11 | port: 8443 12 | protocol: TCP 13 | targetPort: https 14 | selector: 15 | control-plane: controller-manager 16 | -------------------------------------------------------------------------------- /config/rbac/kustomization.yaml: -------------------------------------------------------------------------------- 1 | resources: 2 | # All RBAC will be applied under this service account in 3 | # the deployment namespace. You may comment out this resource 4 | # if your manager will use a service account that exists at 5 | # runtime. Be sure to update RoleBinding and ClusterRoleBinding 6 | # subjects if changing service account names. 7 | - service_account.yaml 8 | - role.yaml 9 | - role_binding.yaml 10 | - leader_election_role.yaml 11 | - leader_election_role_binding.yaml 12 | # Comment the following 4 lines if you want to disable 13 | # the auth proxy (https://github.com/brancz/kube-rbac-proxy) 14 | # which protects your /metrics endpoint. 15 | - auth_proxy_service.yaml 16 | - auth_proxy_role.yaml 17 | - auth_proxy_role_binding.yaml 18 | - auth_proxy_client_clusterrole.yaml 19 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions to do leader election. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: Role 4 | metadata: 5 | name: leader-election-role 6 | rules: 7 | - apiGroups: 8 | - "" 9 | resources: 10 | - configmaps 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - create 16 | - update 17 | - patch 18 | - delete 19 | - apiGroups: 20 | - coordination.k8s.io 21 | resources: 22 | - leases 23 | verbs: 24 | - get 25 | - list 26 | - watch 27 | - create 28 | - update 29 | - patch 30 | - delete 31 | - apiGroups: 32 | - "" 33 | resources: 34 | - events 35 | verbs: 36 | - create 37 | - patch 38 | -------------------------------------------------------------------------------- /config/rbac/leader_election_role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: RoleBinding 3 | metadata: 4 | name: leader-election-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: Role 8 | name: leader-election-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/role.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | creationTimestamp: null 6 | name: manager-role 7 | rules: 8 | - apiGroups: 9 | - run.terraform-operator.io 10 | resources: 11 | - terraforms 12 | verbs: 13 | - create 14 | - delete 15 | - get 16 | - list 17 | - patch 18 | - update 19 | - watch 20 | - apiGroups: 21 | - run.terraform-operator.io 22 | resources: 23 | - terraforms/finalizers 24 | verbs: 25 | - update 26 | - apiGroups: 27 | - run.terraform-operator.io 28 | resources: 29 | - terraforms/status 30 | verbs: 31 | - get 32 | - patch 33 | - update 34 | -------------------------------------------------------------------------------- /config/rbac/role_binding.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRoleBinding 3 | metadata: 4 | name: manager-rolebinding 5 | roleRef: 6 | apiGroup: rbac.authorization.k8s.io 7 | kind: ClusterRole 8 | name: manager-role 9 | subjects: 10 | - kind: ServiceAccount 11 | name: controller-manager 12 | namespace: system 13 | -------------------------------------------------------------------------------- /config/rbac/service_account.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ServiceAccount 3 | metadata: 4 | name: controller-manager 5 | namespace: system 6 | -------------------------------------------------------------------------------- /config/rbac/terraform_editor_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to edit terraforms. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: terraform-editor-role 6 | rules: 7 | - apiGroups: 8 | - run.terraform-operator.io 9 | resources: 10 | - terraforms 11 | verbs: 12 | - create 13 | - delete 14 | - get 15 | - list 16 | - patch 17 | - update 18 | - watch 19 | - apiGroups: 20 | - run.terraform-operator.io 21 | resources: 22 | - terraforms/status 23 | verbs: 24 | - get 25 | -------------------------------------------------------------------------------- /config/rbac/terraform_viewer_role.yaml: -------------------------------------------------------------------------------- 1 | # permissions for end users to view terraforms. 2 | apiVersion: rbac.authorization.k8s.io/v1 3 | kind: ClusterRole 4 | metadata: 5 | name: terraform-viewer-role 6 | rules: 7 | - apiGroups: 8 | - run.terraform-operator.io 9 | resources: 10 | - terraforms 11 | verbs: 12 | - get 13 | - list 14 | - watch 15 | - apiGroups: 16 | - run.terraform-operator.io 17 | resources: 18 | - terraforms/status 19 | verbs: 20 | - get 21 | -------------------------------------------------------------------------------- /config/samples/README.md: -------------------------------------------------------------------------------- 1 | # Samples 2 | 3 | some samples on how to use this CRD 4 | 5 | 1. [Terraform AWS](./terraform-aws.yaml) 6 | 2. [Terraform Azure](./terraform-azure.yaml) 7 | 3. [Terraform with var files](./terraform-var-files.yaml) 8 | 4. [Terraform module source from private git repository](./terraform-git-ssh.yaml) 9 | 4. [Terraform dependency on another terraform](./terraform-dependencies.yaml) -------------------------------------------------------------------------------- /config/samples/role-terraform-runner.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: rbac.authorization.k8s.io/v1 2 | kind: ClusterRole 3 | metadata: 4 | # "namespace" omitted since ClusterRoles are not namespaced 5 | name: terraform-runner 6 | rules: 7 | - apiGroups: [""] 8 | resources: ["secrets"] 9 | verbs: ["create", "get", "update", "list"] 10 | - apiGroups: ["coordination.k8s.io"] 11 | resources: ["leases"] 12 | verbs: ["create", "update", "watch", "get"] 13 | -------------------------------------------------------------------------------- /config/samples/terraform-aws.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: run.terraform-operator.io/v1alpha1 2 | kind: Terraform 3 | metadata: 4 | name: terraform-aws-s3 5 | spec: 6 | terraformVersion: 1.0.2 7 | 8 | module: 9 | source: IbraheemAlSaady/test/module//modules/aws 10 | version: 0.0.2 11 | 12 | variables: 13 | - key: name 14 | value: "mytestrandombucket" 15 | - key: AWS_DEFAULT_REGION 16 | value: eu-west-1 17 | environmentVariable: true 18 | - key: AWS_ACCESS_KEY_ID 19 | environmentVariable: true 20 | valueFrom: 21 | secretKeyRef: 22 | name: aws-credentials 23 | key: AWS_ACCESS_KEY_ID 24 | - key: AWS_SECRET_ACCESS_KEY 25 | environmentVariable: true 26 | valueFrom: 27 | secretKeyRef: 28 | name: aws-credentials 29 | key: AWS_SECRET_ACCESS_KEY 30 | 31 | backend: | 32 | backend "s3" { 33 | bucket = "mybucket" 34 | key = "path/to/my/key" 35 | region = "eu-west-1" 36 | } 37 | 38 | providersConfig: | 39 | terraform { 40 | required_providers { 41 | aws = { 42 | source = "hashicorp/aws" 43 | version = "~> 3.0" 44 | } 45 | } 46 | } 47 | 48 | provider "aws" { 49 | region = "eu-west-1" 50 | } 51 | 52 | # workspace: dev 53 | 54 | destroy: false 55 | deleteCompletedJobs: false 56 | 57 | retryLimit: 1 58 | 59 | outputs: 60 | - key: bucket_id 61 | moduleOutputName: id 62 | -------------------------------------------------------------------------------- /config/samples/terraform-azure.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: run.terraform-operator.io/v1alpha1 2 | kind: Terraform 3 | metadata: 4 | name: terraform-az-storage 5 | spec: 6 | terraformVersion: 1.0.2 7 | 8 | module: 9 | source: IbraheemAlSaady/test/module//modules/azure 10 | version: 0.0.2 11 | 12 | variables: 13 | - key: name 14 | value: "mystorage" 15 | - key: ARM_CLIENT_ID 16 | environmentVariable: true 17 | valueFrom: 18 | secretKeyRef: 19 | name: azure-credentials 20 | key: ARM_CLIENT_ID 21 | - key: ARM_CLIENT_SECRET 22 | environmentVariable: true 23 | valueFrom: 24 | secretKeyRef: 25 | name: azure-credentials 26 | key: ARM_CLIENT_SECRET 27 | - key: ARM_SUBSCRIPTION_ID 28 | environmentVariable: true 29 | valueFrom: 30 | secretKeyRef: 31 | name: azure-credentials 32 | key: ARM_SUBSCRIPTION_ID 33 | - key: ARM_TENANT_ID 34 | environmentVariable: true 35 | valueFrom: 36 | secretKeyRef: 37 | name: azure-credentials 38 | key: ARM_TENANT_ID 39 | 40 | backend: | 41 | backend "azurerm" { 42 | resource_group_name = "StorageAccount-ResourceGroup" 43 | storage_account_name = "abcd1234" 44 | container_name = "tfstate" 45 | key = "prod.terraform.tfstate" 46 | } 47 | 48 | providersConfig: | 49 | terraform { 50 | required_providers { 51 | azurerm = { 52 | source = "hashicorp/azurerm" 53 | version = "=2.46.0" 54 | } 55 | } 56 | } 57 | 58 | provider "azurerm" { 59 | features {} 60 | } 61 | 62 | # workspace: dev 63 | 64 | destroy: false 65 | deleteCompletedJobs: false 66 | 67 | retryLimit: 1 68 | 69 | outputs: 70 | - key: storage_account_id 71 | moduleOutputName: id 72 | -------------------------------------------------------------------------------- /config/samples/terraform-basic.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: run.terraform-operator.io/v1alpha1 2 | kind: Terraform 3 | metadata: 4 | name: basic-module 5 | spec: 6 | terraformVersion: 1.1.7 7 | 8 | module: 9 | source: IbraheemAlSaady/test/module 10 | 11 | variables: 12 | - key: length 13 | value: "4" 14 | -------------------------------------------------------------------------------- /config/samples/terraform-dependencies.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: run.terraform-operator.io/v1alpha1 2 | kind: Terraform 3 | metadata: 4 | name: terraform-run1 5 | spec: 6 | terraformVersion: 1.1.7 7 | 8 | module: 9 | source: IbraheemAlSaady/test/module 10 | version: 0.0.3 11 | 12 | variables: 13 | - key: length 14 | value: "4" 15 | 16 | outputs: 17 | - key: result 18 | moduleOutputName: result 19 | - key: number 20 | moduleOutputName: number 21 | --- 22 | apiVersion: run.terraform-operator.io/v1alpha1 23 | kind: Terraform 24 | metadata: 25 | name: terraform-run2 26 | spec: 27 | terraformVersion: 1.1.7 28 | 29 | module: 30 | source: IbraheemAlSaady/test/module 31 | version: 0.0.3 32 | 33 | dependsOn: 34 | - name: terraform-run1 35 | 36 | variables: 37 | - key: length 38 | dependencyRef: 39 | name: terraform-run1 40 | key: number 41 | 42 | outputs: 43 | - key: result 44 | moduleOutputName: result -------------------------------------------------------------------------------- /config/samples/terraform-git-ssh.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: run.terraform-operator.io/v1alpha1 2 | kind: Terraform 3 | metadata: 4 | name: terraform-basic 5 | spec: 6 | terraformVersion: 1.0.2 7 | 8 | module: 9 | source: git::ssh://git@github.com/IbraheemAlSaady/terraform-module-test.git 10 | # version: 0.0.2 11 | 12 | variables: 13 | - key: length 14 | value: "16" 15 | 16 | backend: | 17 | backend "local" { 18 | path = "/tmp/tfmodule/mytfstate.tfstate" 19 | } 20 | 21 | gitSSHKey: 22 | valueFrom: 23 | secret: 24 | secretName: git-ssh-key 25 | defaultMode: 0600 26 | 27 | # workspace: dev 28 | 29 | destroy: false 30 | deleteCompletedJobs: false 31 | 32 | retryLimit: 1 33 | 34 | outputs: 35 | - key: my_output 36 | moduleOutputName: result 37 | -------------------------------------------------------------------------------- /config/samples/terraform-var-files.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: ConfigMap 3 | metadata: 4 | name: var-files-data 5 | data: 6 | common.tfvars: |- 7 | length = 20 8 | --- 9 | apiVersion: v1 10 | kind: ConfigMap 11 | metadata: 12 | name: var-files-data1 13 | data: 14 | data.tfvars: |- 15 | length = 50 16 | --- 17 | apiVersion: run.terraform-operator.io/v1alpha1 18 | kind: Terraform 19 | metadata: 20 | name: terraform-var-files 21 | spec: 22 | terraformVersion: 1.0.2 23 | 24 | module: 25 | source: IbraheemAlSaady/test/module 26 | version: 0.0.2 27 | 28 | variableFiles: 29 | - key: common-config 30 | valueFrom: 31 | configMap: 32 | name: var-files-data 33 | - key: data-config 34 | valueFrom: 35 | configMap: 36 | name: var-files-data1 37 | # - key: secret-config 38 | # valueFrom: 39 | # secret: 40 | # secretName: mysecret 41 | 42 | outputs: 43 | - key: result 44 | moduleOutputName: result -------------------------------------------------------------------------------- /controllers/suite_test.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022. 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 controllers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "os" 23 | "path/filepath" 24 | "testing" 25 | "time" 26 | 27 | . "github.com/onsi/ginkgo" 28 | . "github.com/onsi/gomega" 29 | "github.com/onsi/gomega/gexec" 30 | batchv1 "k8s.io/api/batch/v1" 31 | corev1 "k8s.io/api/core/v1" 32 | rbacv1 "k8s.io/api/rbac/v1" 33 | "k8s.io/apimachinery/pkg/api/errors" 34 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 35 | "k8s.io/client-go/kubernetes/fake" 36 | "k8s.io/client-go/kubernetes/scheme" 37 | ctrl "sigs.k8s.io/controller-runtime" 38 | "sigs.k8s.io/controller-runtime/pkg/client" 39 | "sigs.k8s.io/controller-runtime/pkg/envtest" 40 | "sigs.k8s.io/controller-runtime/pkg/envtest/printer" 41 | logf "sigs.k8s.io/controller-runtime/pkg/log" 42 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 43 | 44 | "github.com/kuptan/terraform-operator/api/v1alpha1" 45 | "github.com/kuptan/terraform-operator/internal/kube" 46 | "github.com/kuptan/terraform-operator/internal/metrics" 47 | "github.com/kuptan/terraform-operator/internal/utils" 48 | //+kubebuilder:scaffold:imports 49 | ) 50 | 51 | // These tests use Ginkgo (BDD-style Go testing framework). Refer to 52 | // http://onsi.github.io/ginkgo/ to learn more about Ginkgo. 53 | 54 | type mockMetricsRecorder struct { 55 | metrics.RecorderInterface 56 | } 57 | 58 | func (m *mockMetricsRecorder) RecordTotal(name string, namespace string) {} 59 | func (m *mockMetricsRecorder) RecordStatus(name string, namespace string, status v1alpha1.TerraformRunStatus) { 60 | } 61 | func (m *mockMetricsRecorder) RecordDuration(name string, namespace string, start time.Time) {} 62 | 63 | var ( 64 | k8sClient client.Client 65 | testEnv *envtest.Environment 66 | mockedMetricsRecorder *mockMetricsRecorder = &mockMetricsRecorder{} 67 | ) 68 | 69 | func TestAPIs(t *testing.T) { 70 | RegisterFailHandler(Fail) 71 | 72 | RunSpecsWithDefaultAndCustomReporters(t, 73 | "Controller Suite", 74 | []Reporter{printer.NewlineReporter{}}) 75 | } 76 | 77 | var _ = BeforeSuite(func() { 78 | logf.SetLogger(zap.New(zap.WriteTo(GinkgoWriter), zap.UseDevMode(true))) 79 | 80 | os.Setenv("DOCKER_REGISTRY", "docker.io") 81 | os.Setenv("TERRAFORM_RUNNER_IMAGE", "ibraheemalsaady/terraform-runner") 82 | os.Setenv("TERRAFORM_RUNNER_IMAGE_TAG", "0.0.3") 83 | os.Setenv("KNOWN_HOSTS_CONFIGMAP_NAME", "operator-known-hosts") 84 | 85 | By("bootstrapping test environment") 86 | testEnv = &envtest.Environment{ 87 | CRDDirectoryPaths: []string{filepath.Join("..", "config", "crd", "bases")}, 88 | ErrorIfCRDPathMissing: true, 89 | } 90 | 91 | cfg, err := testEnv.Start() 92 | Expect(err).NotTo(HaveOccurred()) 93 | Expect(cfg).NotTo(BeNil()) 94 | 95 | err = v1alpha1.AddToScheme(scheme.Scheme) 96 | Expect(err).NotTo(HaveOccurred()) 97 | 98 | //+kubebuilder:scaffold:scheme 99 | 100 | k8sManager, err := ctrl.NewManager(cfg, ctrl.Options{ 101 | Scheme: scheme.Scheme, 102 | }) 103 | 104 | Expect(err).ToNot(HaveOccurred()) 105 | 106 | k8sClient, err = client.New(cfg, client.Options{Scheme: scheme.Scheme}) 107 | Expect(err).NotTo(HaveOccurred()) 108 | Expect(k8sClient).NotTo(BeNil()) 109 | 110 | err = (&TerraformReconciler{ 111 | Client: k8sClient, 112 | Recorder: k8sManager.GetEventRecorderFor("terraform-controller"), 113 | Log: ctrl.Log.WithName("controllers").WithName("TerraformController"), 114 | MetricsRecorder: mockedMetricsRecorder, 115 | }).SetupWithManager(k8sManager, TerraformReconcilerOptions{ 116 | RequeueJobWatchInterval: 10 * time.Second, 117 | RequeueDependencyInterval: 20 * time.Second, 118 | }) 119 | 120 | Expect(err).NotTo(HaveOccurred(), "failed to setup controller in test") 121 | 122 | kube.ClientSet = fake.NewSimpleClientset() 123 | utils.LoadEnv() 124 | 125 | err = prepareRunnerRBAC() 126 | Expect(err).ToNot(HaveOccurred(), "could not prepare rbac for terraform runner") 127 | 128 | go func() { 129 | defer GinkgoRecover() 130 | 131 | err = k8sManager.Start(ctrl.SetupSignalHandler()) 132 | Expect(err).ToNot(HaveOccurred(), "failed to start manager") 133 | 134 | gexec.KillAndWait(4 * time.Second) 135 | 136 | err := testEnv.Stop() 137 | Expect(err).ToNot(HaveOccurred()) 138 | }() 139 | 140 | }, 60) 141 | 142 | // var _ = AfterSuite(func() { 143 | // By("tearing down the test environment") 144 | // err := testEnv.Stop() 145 | // Expect(err).NotTo(HaveOccurred()) 146 | // }) 147 | 148 | func prepareRunnerRBAC() error { 149 | namespace := "default" 150 | serviceAccountName := "terraform-runner" 151 | 152 | serviceAccount := &corev1.ServiceAccount{ 153 | ObjectMeta: metav1.ObjectMeta{ 154 | Name: serviceAccountName, 155 | Namespace: namespace, 156 | }, 157 | } 158 | 159 | clusterRole := &rbacv1.ClusterRole{ 160 | ObjectMeta: metav1.ObjectMeta{ 161 | Name: serviceAccountName, 162 | }, 163 | Rules: []rbacv1.PolicyRule{ 164 | rbacv1.PolicyRule{ 165 | APIGroups: []string{""}, 166 | Resources: []string{"secrets"}, 167 | Verbs: []string{"get", "update"}, 168 | }, 169 | }, 170 | } 171 | 172 | clusterRoleBinding := &rbacv1.ClusterRoleBinding{ 173 | ObjectMeta: metav1.ObjectMeta{ 174 | Name: serviceAccountName, 175 | }, 176 | RoleRef: rbacv1.RoleRef{ 177 | Kind: "ClusterRole", 178 | Name: serviceAccountName, 179 | APIGroup: "rbac.authorization.k8s.io", 180 | }, 181 | Subjects: []rbacv1.Subject{ 182 | rbacv1.Subject{ 183 | Kind: "ServiceAccount", 184 | Name: serviceAccountName, 185 | Namespace: namespace, 186 | }, 187 | }, 188 | } 189 | 190 | ctx := context.Background() 191 | 192 | _, err := kube.ClientSet.CoreV1().ServiceAccounts("default").Create(ctx, serviceAccount, metav1.CreateOptions{}) 193 | 194 | if err != nil { 195 | return err 196 | } 197 | 198 | _, err = kube.ClientSet.RbacV1().ClusterRoles().Create(ctx, clusterRole, metav1.CreateOptions{}) 199 | 200 | if err != nil { 201 | return err 202 | } 203 | 204 | _, err = kube.ClientSet.RbacV1().ClusterRoleBindings().Create(ctx, clusterRoleBinding, metav1.CreateOptions{}) 205 | 206 | if err != nil { 207 | return err 208 | } 209 | 210 | return nil 211 | } 212 | 213 | func makeRunJobRunning(r *v1alpha1.Terraform) { 214 | jobsClient := kube.ClientSet.BatchV1().Jobs("default") 215 | 216 | name := getRunName(r.Name, r.Status.RunID) 217 | 218 | job, _ := jobsClient.Get(context.Background(), name, metav1.GetOptions{}) 219 | 220 | if job == nil { 221 | return 222 | } 223 | 224 | job.Status = batchv1.JobStatus{ 225 | Active: 1, 226 | Succeeded: 0, 227 | Failed: 0, 228 | } 229 | 230 | jobsClient.Update(context.Background(), job, metav1.UpdateOptions{}) 231 | 232 | } 233 | 234 | func makeRunJobSucceed(r *v1alpha1.Terraform) { 235 | jobsClient := kube.ClientSet.BatchV1().Jobs("default") 236 | 237 | name := getRunName(r.Name, r.Status.RunID) 238 | 239 | job, _ := jobsClient.Get(context.Background(), name, metav1.GetOptions{}) 240 | 241 | if job == nil { 242 | return 243 | } 244 | 245 | job.Status = batchv1.JobStatus{ 246 | Active: 0, 247 | Succeeded: 1, 248 | Failed: 0, 249 | } 250 | 251 | jobsClient.Update(context.Background(), job, metav1.UpdateOptions{}) 252 | } 253 | 254 | func makeRunJobFail(r *v1alpha1.Terraform) { 255 | jobsClient := kube.ClientSet.BatchV1().Jobs("default") 256 | 257 | name := getRunName(r.Name, r.Status.RunID) 258 | 259 | job, _ := jobsClient.Get(context.Background(), name, metav1.GetOptions{}) 260 | 261 | if job == nil { 262 | return 263 | } 264 | 265 | job.Status = batchv1.JobStatus{ 266 | Active: 0, 267 | Succeeded: 0, 268 | Failed: 1, 269 | } 270 | 271 | jobsClient.Update(context.Background(), job, metav1.UpdateOptions{}) 272 | 273 | } 274 | 275 | func isJobDeleted(r *v1alpha1.Terraform) bool { 276 | jobsClient := kube.ClientSet.BatchV1().Jobs("default") 277 | 278 | name := getRunName(r.Name, r.Status.RunID) 279 | 280 | _, err := jobsClient.Get(context.Background(), name, metav1.GetOptions{}) 281 | 282 | return errors.IsNotFound(err) 283 | } 284 | 285 | func getRunName(name string, runID string) string { 286 | return fmt.Sprintf("%s-%s", name, runID) 287 | } 288 | -------------------------------------------------------------------------------- /controllers/terraform_controller.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022. 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 controllers 18 | 19 | import ( 20 | "context" 21 | "fmt" 22 | "time" 23 | 24 | "k8s.io/apimachinery/pkg/api/errors" 25 | "k8s.io/apimachinery/pkg/runtime" 26 | "k8s.io/client-go/tools/record" 27 | ctrl "sigs.k8s.io/controller-runtime" 28 | "sigs.k8s.io/controller-runtime/pkg/client" 29 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 30 | 31 | batchv1 "k8s.io/api/batch/v1" 32 | corev1 "k8s.io/api/core/v1" 33 | 34 | "github.com/go-logr/logr" 35 | "github.com/kuptan/terraform-operator/api/v1alpha1" 36 | "github.com/kuptan/terraform-operator/internal/metrics" 37 | ) 38 | 39 | // TerraformReconciler reconciles a Terraform object 40 | type TerraformReconciler struct { 41 | client.Client 42 | Scheme *runtime.Scheme 43 | Recorder record.EventRecorder 44 | MetricsRecorder metrics.RecorderInterface 45 | Log logr.Logger 46 | requeueDependency time.Duration 47 | requeueJobWatch time.Duration 48 | } 49 | 50 | // TerraformReconcilerOptions holds additional options 51 | type TerraformReconcilerOptions struct { 52 | RequeueDependencyInterval time.Duration 53 | RequeueJobWatchInterval time.Duration 54 | } 55 | 56 | //+kubebuilder:rbac:groups=run.terraform-operator.io,resources=terraforms,verbs=get;list;watch;create;update;patch;delete 57 | //+kubebuilder:rbac:groups=run.terraform-operator.io,resources=terraforms/status,verbs=get;update;patch 58 | //+kubebuilder:rbac:groups=run.terraform-operator.io,resources=terraforms/finalizers,verbs=update 59 | 60 | // Reconcile is part of the main kubernetes reconciliation loop which aims to 61 | // move the current state of the cluster closer to the desired state. 62 | // TODO(user): Modify the Reconcile function to compare the state specified by 63 | // the Terraform object against the actual cluster state, and then 64 | // perform operations to make the cluster state reflect the state specified by 65 | // the user. 66 | // 67 | // For more details, check Reconcile and its Result here: 68 | // - https://pkg.go.dev/sigs.k8s.io/controller-runtime@v0.10.0/pkg/reconcile 69 | func (r *TerraformReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) { 70 | run := &v1alpha1.Terraform{} 71 | start := time.Now() 72 | durationMsg := fmt.Sprintf("reconcilation finished in %s", time.Since(start).String()) 73 | 74 | if err := r.Get(ctx, req.NamespacedName, run); err != nil { 75 | if errors.IsNotFound(err) { 76 | return ctrl.Result{}, nil 77 | } 78 | return ctrl.Result{}, err 79 | } 80 | 81 | if !controllerutil.ContainsFinalizer(run, v1alpha1.TerraformFinalizer) { 82 | controllerutil.AddFinalizer(run, v1alpha1.TerraformFinalizer) 83 | if err := r.Update(ctx, run); err != nil { 84 | r.Log.Error(err, "unable to register finalizer") 85 | 86 | return ctrl.Result{}, err 87 | } 88 | 89 | r.Recorder.Event(run, corev1.EventTypeNormal, "Added finalizer", "Object finalizer is added") 90 | 91 | return ctrl.Result{}, nil 92 | } 93 | 94 | // Examine if the object is under deletion 95 | if !run.ObjectMeta.DeletionTimestamp.IsZero() { 96 | return r.handleRunDelete(ctx, run) 97 | } 98 | 99 | if run.IsSubmitted() || run.IsWaiting() { 100 | result, err := r.handleRunCreate(ctx, run, req.NamespacedName) 101 | 102 | if err != nil { 103 | return ctrl.Result{}, err 104 | } 105 | 106 | r.Recorder.Event(run, "Normal", "Created", fmt.Sprintf("Run(%s) submitted", run.Status.RunID)) 107 | r.MetricsRecorder.RecordTotal(run.Name, run.Namespace) 108 | 109 | if result.RequeueAfter > 0 { 110 | r.Log.Info(fmt.Sprintf("%s, next run in %s", durationMsg, result.RequeueAfter.String())) 111 | 112 | return result, nil 113 | } 114 | 115 | return result, nil 116 | } 117 | 118 | if run.IsStarted() { 119 | result, err := r.handleRunJobWatch(ctx, run) 120 | 121 | if err != nil { 122 | return ctrl.Result{}, err 123 | } 124 | 125 | if result.RequeueAfter > 0 { 126 | r.Log.Info(fmt.Sprintf("%s, next run in %s", durationMsg, result.RequeueAfter.String())) 127 | 128 | return result, nil 129 | } 130 | 131 | return result, nil 132 | } 133 | 134 | if run.IsUpdated() { 135 | r.Log.Info("updating a terraform run") 136 | 137 | result, err := r.handleRunUpdate(ctx, run, req.NamespacedName) 138 | 139 | if err != nil { 140 | return ctrl.Result{}, err 141 | } 142 | 143 | if result.RequeueAfter > 0 { 144 | r.Log.Info(fmt.Sprintf("%s, next run in %s", durationMsg, result.RequeueAfter.String())) 145 | 146 | return result, nil 147 | } 148 | 149 | return ctrl.Result{}, nil 150 | } 151 | 152 | return ctrl.Result{}, nil 153 | } 154 | 155 | // SetupWithManager sets up the controller with the Manager. 156 | func (r *TerraformReconciler) SetupWithManager(mgr ctrl.Manager, opts TerraformReconcilerOptions) error { 157 | r.requeueDependency = opts.RequeueDependencyInterval 158 | r.requeueJobWatch = opts.RequeueJobWatchInterval 159 | 160 | return ctrl.NewControllerManagedBy(mgr). 161 | For(&v1alpha1.Terraform{}). 162 | Owns(&batchv1.Job{}). 163 | Owns(&corev1.ConfigMap{}). 164 | Owns(&corev1.Secret{}). 165 | Complete(r) 166 | } 167 | -------------------------------------------------------------------------------- /controllers/terraform_controller_operation.go: -------------------------------------------------------------------------------- 1 | package controllers 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "time" 8 | 9 | "github.com/kuptan/terraform-operator/api/v1alpha1" 10 | "sigs.k8s.io/controller-runtime/pkg/controller/controllerutil" 11 | 12 | v1 "k8s.io/api/core/v1" 13 | "k8s.io/apimachinery/pkg/types" 14 | ctrl "sigs.k8s.io/controller-runtime" 15 | ) 16 | 17 | func (r *TerraformReconciler) updateRunStatus(ctx context.Context, run *v1alpha1.Terraform, status v1alpha1.TerraformRunStatus) { 18 | run.Status.RunStatus = status 19 | 20 | // set completion time of the run only if status is completed/failed 21 | if status == v1alpha1.RunCompleted || status == v1alpha1.RunFailed { 22 | run.Status.CompletionTime = time.Now().Format(time.UnixDate) 23 | } 24 | 25 | // record the status only if completed/failed/waiting 26 | if status == v1alpha1.RunCompleted || status == v1alpha1.RunFailed || status == v1alpha1.RunWaitingForDependency { 27 | r.MetricsRecorder.RecordStatus(run.Name, run.Namespace, status) 28 | } 29 | 30 | if err := r.Status().Update(ctx, run); err != nil { 31 | r.Log.Error(err, "failed to update status") 32 | } 33 | } 34 | 35 | func (r *TerraformReconciler) handleRunCreate(ctx context.Context, run *v1alpha1.Terraform, namespacedName types.NamespacedName) (ctrl.Result, error) { 36 | dependencies, err := r.checkDependencies(ctx, *run) 37 | 38 | run.Status.ObservedGeneration = run.Generation 39 | 40 | if err != nil { 41 | if !run.IsWaiting() { 42 | r.Recorder.Event(run, "Normal", "Waiting", "Dependencies are not yet completed") 43 | r.updateRunStatus(ctx, run, v1alpha1.RunWaitingForDependency) 44 | } 45 | 46 | return ctrl.Result{ 47 | RequeueAfter: r.requeueDependency, 48 | }, nil 49 | } 50 | 51 | run.SetRunID() 52 | 53 | r.Log.Info("cleaning up old resources if exist") 54 | 55 | setVariablesFromDependencies(run, dependencies) 56 | 57 | _, err = run.CreateTerraformRun(ctx, namespacedName) 58 | 59 | if err != nil { 60 | r.Log.Error(err, "failed create a terraform run") 61 | 62 | r.updateRunStatus(ctx, run, v1alpha1.RunFailed) 63 | 64 | return ctrl.Result{}, err 65 | } 66 | 67 | if err = run.CleanupResources(ctx); err != nil { 68 | r.Log.Error(err, "failed to cleanup resources") 69 | } 70 | 71 | run.Status.OutputSecretName = run.GetOutputSecretName() 72 | run.Status.StartedTime = time.Now().Format(time.UnixDate) 73 | 74 | r.updateRunStatus(ctx, run, v1alpha1.RunStarted) 75 | 76 | return ctrl.Result{}, nil 77 | } 78 | 79 | func (r *TerraformReconciler) handleRunUpdate(ctx context.Context, run *v1alpha1.Terraform, namespacedName types.NamespacedName) (ctrl.Result, error) { 80 | r.Recorder.Event(run, "Normal", "Updated", "Creating a new run job") 81 | 82 | return r.handleRunCreate(ctx, run, namespacedName) 83 | } 84 | 85 | func (r *TerraformReconciler) handleRunDelete(ctx context.Context, run *v1alpha1.Terraform) (ctrl.Result, error) { 86 | r.Log.Info("terraform run is being deleted", "name", run.Name) 87 | 88 | r.MetricsRecorder.RecordStatus(run.Name, run.Namespace, v1alpha1.RunDeleted) 89 | controllerutil.RemoveFinalizer(run, v1alpha1.TerraformFinalizer) 90 | 91 | if err := r.Update(ctx, run); err != nil { 92 | return ctrl.Result{}, err 93 | } 94 | 95 | return ctrl.Result{}, nil 96 | } 97 | 98 | func (r *TerraformReconciler) handleRunJobWatch(ctx context.Context, run *v1alpha1.Terraform) (ctrl.Result, error) { 99 | job, err := run.GetJobByRun(ctx) 100 | 101 | r.Log.Info("waiting for terraform job run to complete", "name", job.Name) 102 | 103 | if err != nil { 104 | return ctrl.Result{}, err 105 | } 106 | 107 | startTime, err := time.Parse(time.UnixDate, run.Status.StartedTime) 108 | 109 | if err != nil { 110 | r.Log.Error(err, "failed to parse workflow start time") 111 | } 112 | 113 | defer r.MetricsRecorder.RecordDuration(run.Name, run.Namespace, startTime) 114 | 115 | // job hasn't started 116 | if job.Status.Active == 0 && job.Status.Succeeded == 0 && job.Status.Failed == 0 { 117 | return ctrl.Result{RequeueAfter: r.requeueJobWatch}, nil 118 | } 119 | 120 | // job is still running 121 | if job.Status.Active > 0 { 122 | if !run.IsRunning() { 123 | r.updateRunStatus(ctx, run, v1alpha1.RunRunning) 124 | 125 | r.Recorder.Event(run, "Normal", "Running", fmt.Sprintf("Run(%s) waiting for run job to finish", run.Status.RunID)) 126 | } 127 | 128 | return ctrl.Result{RequeueAfter: r.requeueJobWatch}, nil 129 | } 130 | 131 | // job is successful 132 | if job.Status.Succeeded > 0 { 133 | r.Log.Info("terraform run job completed successfully") 134 | 135 | if run.Spec.DeleteCompletedJobs { 136 | r.Log.Info("deleting completed job") 137 | 138 | if err := run.DeleteAfterCompletion(ctx); err != nil { 139 | r.Log.Error(err, "failed to delete terraform run job after completion", "name", job.Name) 140 | } else { 141 | r.Recorder.Event(run, "Normal", "Cleanup", fmt.Sprintf("Run(%s) kubernetes job was deleted", run.Status.RunID)) 142 | } 143 | } 144 | 145 | if !run.Spec.Destroy { 146 | r.Recorder.Event(run, "Normal", "Completed", fmt.Sprintf("Run(%s) completed", run.Status.RunID)) 147 | } else { 148 | r.Recorder.Event(run, "Normal", "Destroyed", fmt.Sprintf("Run(%s) completed with terraform destroy", run.Status.RunID)) 149 | } 150 | 151 | r.updateRunStatus(ctx, run, v1alpha1.RunCompleted) 152 | 153 | return ctrl.Result{}, nil 154 | } 155 | 156 | // if it got here, then the job is failed -- sadly .... :( :( :( 157 | r.Recorder.Event(run, "Warning", "Failed", fmt.Sprintf("Run(%s) failed", run.Status.RunID)) 158 | r.Log.Error(errors.New("job failed"), "terraform run job failed to complete", "name", job.Name) 159 | 160 | r.updateRunStatus(ctx, run, v1alpha1.RunFailed) 161 | 162 | return ctrl.Result{}, nil 163 | } 164 | 165 | func (r *TerraformReconciler) checkDependencies(ctx context.Context, run v1alpha1.Terraform) ([]v1alpha1.Terraform, error) { 166 | dependencies := []v1alpha1.Terraform{} 167 | 168 | for _, d := range run.Spec.DependsOn { 169 | 170 | if d.Namespace == "" { 171 | d.Namespace = run.Namespace 172 | } 173 | 174 | dName := types.NamespacedName{ 175 | Namespace: d.Namespace, 176 | Name: d.Name, 177 | } 178 | 179 | var dRun v1alpha1.Terraform 180 | 181 | err := r.Get(ctx, dName, &dRun) 182 | 183 | if err != nil { 184 | return dependencies, fmt.Errorf("unable to get '%s' dependency: %w", dName, err) 185 | } 186 | 187 | if dRun.Generation != dRun.Status.ObservedGeneration { 188 | return dependencies, fmt.Errorf("dependency '%s' is not ready", dName) 189 | } 190 | 191 | if dRun.Status.RunStatus != v1alpha1.RunCompleted { 192 | return dependencies, fmt.Errorf("dependency '%s' is not ready", dName) 193 | } 194 | 195 | dependencies = append(dependencies, dRun) 196 | } 197 | 198 | return dependencies, nil 199 | } 200 | 201 | // setVariablesFromDependencies sets the variable from the output of a dependency 202 | // this currently only works with runs within the same namespace 203 | func setVariablesFromDependencies(run *v1alpha1.Terraform, dependencies []v1alpha1.Terraform) { 204 | if len(dependencies) == 0 { 205 | return 206 | } 207 | 208 | for _, v := range run.Spec.Variables { 209 | if v.DependencyRef == nil { 210 | continue 211 | } 212 | 213 | for index, d := range dependencies { 214 | if d.Name == v.DependencyRef.Name && d.Namespace == run.Namespace { 215 | tfVarRef := &v1.EnvVarSource{ 216 | SecretKeyRef: &v1.SecretKeySelector{ 217 | Key: v.DependencyRef.Key, 218 | LocalObjectReference: v1.LocalObjectReference{ 219 | Name: d.Status.OutputSecretName, 220 | }, 221 | }, 222 | } 223 | 224 | tfVar := v1alpha1.Variable{ 225 | Key: v.Key, 226 | DependencyRef: v.DependencyRef, 227 | ValueFrom: tfVarRef, 228 | } 229 | 230 | // remove the current variable from the list 231 | run.Spec.Variables = append(run.Spec.Variables[:index], run.Spec.Variables[index+1:]...) 232 | // add a new variable with the valueFrom 233 | run.Spec.Variables = append(run.Spec.Variables, tfVar) 234 | } 235 | } 236 | } 237 | } 238 | -------------------------------------------------------------------------------- /docs/_config.yml: -------------------------------------------------------------------------------- 1 | ## theme _config.yml: https://github.com/pmarsceill/just-the-docs/blob/master/_config.yml 2 | 3 | remote_theme: pmarsceill/just-the-docs 4 | 5 | title: Terraform Operator 6 | baseurl: "/terraform-operator" 7 | url: "https://kuptan.github.io" 8 | 9 | permalink: pretty 10 | exclude: ["script/", "LICENSE", "hack/", "bin/", "README.md"] 11 | 12 | logo: "img/tfo.svg" 13 | 14 | search_enabled: true 15 | search: 16 | # Split pages into sections that can be searched individually 17 | # Supports 1 - 6, default: 2 18 | heading_level: 2 19 | # Maximum amount of previews per search result 20 | # Default: 3 21 | previews: 2 22 | # Maximum amount of words to display before a matched word in the preview 23 | # Default: 5 24 | preview_words_before: 3 25 | # Maximum amount of words to display after a matched word in the preview 26 | # Default: 10 27 | preview_words_after: 3 28 | # Set the search token separator 29 | # Default: /[\s\-/]+/ 30 | # Example: enable support for hyphenated search words 31 | tokenizer_separator: /[\s/]+/ 32 | # Display the relative url in search results 33 | # Supports true (default) or false 34 | rel_url: true 35 | # Enable or disable the search button that appears in the bottom right corner of every page 36 | # Supports true or false (default) 37 | button: false 38 | 39 | heading_anchors: true 40 | 41 | aux_links: 42 | "GitHub": 43 | - "//github.com/kuptan/terraform-operator" 44 | 45 | aux_links_new_tab: false 46 | 47 | nav_sort: case_sensitive 48 | 49 | footer_content: "Distributed by an Apache2 license." 50 | 51 | # Footer last edited timestamp 52 | last_edit_timestamp: true # show or hide edit time - page must have `last_modified_date` defined in the frontmatter 53 | last_edit_time_format: "%b %e %Y at %I:%M %p" # uses ruby's time format: https://ruby-doc.org/stdlib-2.7.0/libdoc/time/rdoc/Time.html 54 | 55 | # Color scheme currently only supports "dark", "light"/nil (default), or a custom scheme that you define 56 | color_scheme: nil 57 | 58 | kramdown: 59 | syntax_highlighter_opts: 60 | block: 61 | line_numbers: false 62 | 63 | plugins: 64 | - jekyll-seo-tag 65 | 66 | compress_html: 67 | clippings: all 68 | comments: all 69 | endings: all 70 | startings: [] 71 | blanklines: false 72 | profile: false 73 | # ignore: 74 | # envs: all -------------------------------------------------------------------------------- /docs/_includes/head_custom.html: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/api-ref.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: API Reference 4 | nav_order: 9 5 | --- 6 | 7 | # API Reference 8 | To view the API reference, please visit [this page](https://doc.crds.dev/github.com/kuptan/terraform-operator) 9 | 10 | 196 | -------------------------------------------------------------------------------- /docs/contributing-guide.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Contributing Guide 4 | nav_order: 8 5 | --- 6 | 7 | # Contributing Guide 8 | If you're interested in contributing to this project, you'll need: 9 | 10 | * Go installed - see this [Getting Started](https://golang.org/doc/install) guide for Go. 11 | * Docker installed - see this [Getting Started](https://docs.docker.com/install/) guide for Docker. 12 | * `Kubebuilder` - see this [Quick Start](https://book.kubebuilder.io/quick-start.html) guide for installation instructions. 13 | * Kubernetes command-line tool `kubectl` 14 | * Access to a Kubernetes cluster. Some options are: 15 | * k3d 16 | * minikube 17 | * cloud managed (AWS EKS, Azure AKS, GKE, etc...) 18 | 19 | # Quick start 20 | This project uses the [terraform-runner](https://github.com/kuptan/terraform-runner) project to execute terraform commands. 21 | If you're not familiar with how this controller works under the hood, its highly recommended to visit the [design docs](./design.md) first. 22 | 23 | #### Now lets start 24 | 25 | 1. Clone the repositorry and open the project in a code editor (e.g: visual studio code) 26 | 2. In the root directory, create a `.env` file with the following environment variables 27 | 28 | ```bash 29 | export DOCKER_REGISTRY=docker.io 30 | export TERRAFORM_RUNNER_IMAGE=kubechamp/terraform-runner 31 | 32 | ## For the latest tags, check docker hub: https://hub.docker.com/r/kubechamp/terraform-runner 33 | export TERRAFORM_RUNNER_IMAGE_TAG=0.0.3 # <-- might be a higher version 34 | export KNOWN_HOSTS_CONFIGMAP_NAME=terraform-operator-known-hosts 35 | ``` 36 | 37 | 3. Source the .env with `source .env` 38 | 4. Once you have a Kubernetes cluster running, create a `kubeconfig` file in the root of the project with the config of the Kubernetes cluster 39 | 5. Create the following Kubernetes RBAC objects, this is needed by the `terraform-runner` due to writing outputs to a Kubernetes secret 40 | 41 | ```yaml 42 | apiVersion: v1 43 | kind: ServiceAccount 44 | metadata: 45 | name: terraform-runner 46 | namespace: default 47 | --- 48 | apiVersion: rbac.authorization.k8s.io/v1 49 | kind: ClusterRole 50 | metadata: 51 | # "namespace" omitted since ClusterRoles are not namespaced 52 | name: terraform-runner 53 | rules: 54 | - apiGroups: [""] 55 | resources: ["secrets"] 56 | verbs: ["get", "update"] 57 | --- 58 | apiVersion: rbac.authorization.k8s.io/v1 59 | kind: ClusterRoleBinding 60 | metadata: 61 | name: terraform-runner 62 | subjects: 63 | - kind: ServiceAccount 64 | name: terraform-runner # name of your service account 65 | namespace: default # this is the namespace your service account is in 66 | roleRef: # referring to your ClusterRole 67 | kind: ClusterRole 68 | name: terraform-runner 69 | apiGroup: rbac.authorization.k8s.io 70 | ``` 71 | 72 | 6. If you're testing with private git repos, you need to create the known hosts config map 73 | 74 | ```yaml 75 | apiVersion: v1 76 | kind: ConfigMap 77 | metadata: 78 | name: terraform-operator-known-hosts 79 | data: 80 | known_hosts: |- 81 | bitbucket.org ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAubiN81eDcafrgMeLzaFPsw2kNvEcqTKl/VqLat/MaB33pZy0y3rJZtnqwR2qOOvbwKZYKiEO1O6VqNEBxKvJJelCq0dTXWT5pbO2gDXC6h6QDXCaHo6pOHGPUy+YBaGQRGuSusMEASYiWunYN0vCAI8QaXnWMXNMdFP3jHAJH0eDsoiGnLPBlBp4TNm6rYI74nMzgz3B9IikW4WVK+dc8KZJZWYjAuORU3jc1c/NPskD2ASinf8v3xnfXeukU0sJ5N6m5E8VLjObPEO+mN2t/FZTMZLiFqPWc/ALSqnMnnhwrNi2rbfg/rd/IpL8Le3pSBne8+seeFVBoGqzHM9yXw== 82 | github.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBEmKSENjQEezOmxkZMy7opKgwFB9nkt5YRrYMjNuG5N87uRgg6CLrbo5wAdT/y6v0mKV0U2w0WZ2YB/++Tpockg= 83 | github.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIOMqqnkVzrm0SdG6UOoqKLsabgH5C9okWi0dh2l9GKJl 84 | github.com ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEAq2A7hRGmdnm9tUDbO9IDSwBK6TbQa+PXYPCPy6rbTrTtw7PHkccKrpp0yVhp5HdEIcKr6pLlVDBfOLX9QUsyCOV0wzfjIJNlGEYsdlLJizHhbn2mUjvSAHQqZETYP81eFzLQNnPHt4EVVUh7VfDESU84KezmD5QlWpXLmvU31/yMf+Se8xhHTvKSCZIFImWwoG6mbUoWf9nzpIoaSjB+weqqUUmpaaasXVal72J+UX2B+2RPW3RcT0eOzQgqlJL3RKrTJvdsjE3JEAvGq3lGHSZXy28G3skua2SmVi/w4yCE6gbODqnTWlg7+wC604ydGXA8VJiS5ap43JXiUFFAaQ== 85 | gitlab.com ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBFSMqzJeV9rUzU4kWitGjeR4PWSa29SPqJ1fVkhtj3Hw9xjLVXVYrU9QlYWrOLXBpQ6KWjbjTDTdDkoohFzgbEY= 86 | gitlab.com ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAfuCHKVTjquxvt6CM6tdG4SLp1Btn/nOeHHE5UOzRdf 87 | gitlab.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQCsj2bNKTBSpIYDEGk9KxsGh3mySTRgMtXL583qmBpzeQ+jqCMRgBqB98u3z++J1sKlXHWfM9dyhSevkMwSbhoR8XIq/U0tCNyokEi/ueaBMCvbcTHhO7FcwzY92WK4Yt0aGROY5qX2UKSeOvuP4D6TPqKF1onrSzH9bx9XUf2lEdWT/ia1NEKjunUqu1xOB/StKDHMoX4/OKyIzuS0q/T1zOATthvasJFoPrAjkohTyaDUz2LN5JoH839hViyEG82yB+MjcFV5MU3N1l1QL3cVUCh93xSaua1N85qivl+siMkPGbO5xR/En4iEY6K2XPASUEMaieWVNTRCtJ4S8H+9 88 | ssh.dev.azure.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7Hr1oTWqNqOlzGJOfGJ4NakVyIzf1rXYd4d7wo6jBlkLvCA4odBlL0mDUyZ0/QUfTTqeu+tm22gOsv+VrVTMk6vwRU75gY/y9ut5Mb3bR5BV58dKXyq9A9UeB5Cakehn5Zgm6x1mKoVyf+FFn26iYqXJRgzIZZcZ5V6hrE0Qg39kZm4az48o0AUbf6Sp4SLdvnuMa2sVNwHBboS7EJkm57XQPVU3/QpyNLHbWDdzwtrlS+ez30S3AdYhLKEOxAG8weOnyrtLJAUen9mTkol8oII1edf7mWWbWVf0nBmly21+nZcmCTISQBtdcyPaEno7fFQMDD26/s0lfKob4Kw8H 89 | vs-ssh.visualstudio.com ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQC7Hr1oTWqNqOlzGJOfGJ4NakVyIzf1rXYd4d7wo6jBlkLvCA4odBlL0mDUyZ0/QUfTTqeu+tm22gOsv+VrVTMk6vwRU75gY/y9ut5Mb3bR5BV58dKXyq9A9UeB5Cakehn5Zgm6x1mKoVyf+FFn26iYqXJRgzIZZcZ5V6hrE0Qg39kZm4az48o0AUbf6Sp4SLdvnuMa2sVNwHBboS7EJkm57XQPVU3/QpyNLHbWDdzwtrlS+ez30S3AdYhLKEOxAG8weOnyrtLJAUen9mTkol8oII1edf7mWWbWVf0nBmly21+nZcmCTISQBtdcyPaEno7fFQMDD26/s0lfKob4Kw8H 90 | ``` 91 | 92 | 7. Install the manifests above and install the CRD. See [Dependencies](#dependencies) 93 | 94 | # Building and Running the operator 95 | 96 | ## Basics 97 | The scaffolding for the project is generated using `Kubebuilder`. It is a good idea to become familiar with this [project](https://github.com/kubernetes-sigs/kubebuilder). The [quick start](https://book.kubebuilder.io/quick-start.html) guide is also quite useful. 98 | 99 | See `Makefile` at the root directory of the project. By default, executing `make` will build the project and produce an executable at `./bin/manager` 100 | 101 | ## Dependencies 102 | To run successfully, any CRDs defined in the project should be regenerated and installed 103 | 104 | The following steps should illustrate what is required before the project can be run: 105 | 1. `go mod tidy` - download the dependencies (this can take a while and there is no progress bar - need to be patient for this one) 106 | 2. `make manifests` - regenerates the CRD manifests 107 | 3. `make install` - installs the CRDs into the cluster 108 | 4. `make generate` - generate the code 109 | 110 | ## Running the controller 111 | ``` 112 | make run 113 | ``` 114 | 115 | ## Running Tests 116 | ``` 117 | make test 118 | ``` 119 | 120 | ## Extending the Library 121 | As previously mentioned, familiarity with `kubebuilder` is required for developing this operator. Kubebuilder generates the scaffolding for new Kubernetes APIs. 122 | 123 | ``` 124 | $ kubebuilder create api --group run --version v1alpha1 --kind [YOUR_KIND] 125 | 126 | Create Resource [y/n] 127 | y 128 | Create Controller [y/n] 129 | y 130 | ``` 131 | Once you've developed your API, ensure to regenerate and install your CRDs. See [Dependencies](#dependencies) -------------------------------------------------------------------------------- /docs/customize.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Customization 4 | nav_order: 7 5 | --- 6 | 7 | # Customization 8 | The Terraform Operator uses the [terraform-runner](https://github.com/kuptan/terraform-runner) as its terraform runner to execute terraform commands. If you don't want to use the default [terraform-runner](https://github.com/kuptan/terraform-runner), you can build your own. 9 | 10 | To make the operator use your terraform runner, the Terraform Operator expects the following environment variables: 11 | 12 | ``` 13 | DOCKER_REGISTRY=docker.io 14 | TERRAFORM_RUNNER_IMAGE=kubechamp/terraform-runner 15 | TERRAFORM_RUNNER_IMAGE_TAG=0.0.4 ## <- this might be different 16 | ``` 17 | 18 | The above are the defaults that are passed to the operator. In helm, you can override these values by setting the following: 19 | 20 | ```yaml 21 | terraformRunner: 22 | image: 23 | registry: docker.io 24 | repository: kubechamp/terraform-runner 25 | tag: "0.0.4" 26 | ``` 27 | 28 | ## Building Your Runner 29 | The runner of course must be a docker container at the end, the implementation in the container is up to you, however, there are few things to keep in mind. 30 | 31 | When Terraform Operator creates Kubernetes jobs with the Terraform Runner, it sets some environment variables on the Terraform Runner container. For a technical view, have a look at this [code section](https://github.com/kuptan/terraform-operator/blob/master/api/v1alpha1/k8s_jobs.go#L16) 32 | 33 | 34 | | Environment Variable | Default value | Description | 35 | |--------------------------|----------------------|------------------------------------------------------------------------------------| 36 | | TERRAFORM_VERSION | - | The Terraform version to install, its taken from the `spec.terraformVersion` field | 37 | | OUTPUT_SECRET_NAME | - | The Kubernetes secret to add the Terraform outputs | 38 | | TERRAFORM_WORKING_DIR | `/tmp/tfmodule` | The Terraform working directory | 39 | | TERRAFORM_WORKSPACE | `default` | The Terraform workspace to use | 40 | | TERRAFORM_DESTROY | `false` | Indicates whether to run a Terraform destroy | 41 | | TERRAFORM_VAR_FILES_PATH | `/tmp/tfvars` | The path where var files will be mounted | 42 | | POD_NAMESPACE | `metadata.namespace` | The Kubernetes namespace where the job is created | 43 | 44 | ## Git SSH 45 | If the the `spec.gitSSHKey` was provided to authenticate against private git repositories, the path to the ssh key will be `/root/.ssh/id_rsa`. 46 | 47 | You need to add the ssh key `ssh-add /root/.ssh/id_rsa` -------------------------------------------------------------------------------- /docs/design.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: How It Works 4 | nav_order: 3 5 | --- 6 | 7 | # Terraform Operator Design 8 | Here is how the terraform operator works 9 | 10 | ![operator design](https://github.com/kuptan/terraform-operator/blob/master/docs/img/design.png?raw=true "terraform operator") 11 | 12 | Let's say you created and applied the following manifest to Kubernetes: 13 | 14 | ```yaml 15 | apiVersion: run.terraform-operator.io/v1alpha1 16 | kind: Terraform 17 | ... 18 | spec: 19 | terraformVersion: 1.0.2 20 | 21 | module: 22 | source: "IbraheemAlSaady/test/module" 23 | version: "0.0.1" 24 | 25 | variables: 26 | - key: length 27 | value: "16" 28 | environmentVariable: false 29 | 30 | outputs: 31 | - key: result 32 | moduleOutputName: result ## <-- the module has an output called result 33 | ``` 34 | 35 | Once the Terraform object was created, the controller will pick up the object and create a Kubernetes job. That Kubernetes job runs the [Terraform Runner](#) which will run the Terraform flow and install the required terraform version 36 | 37 | Based on our template, the controller will create a main.tf file with the following content and mounts it into the `terraform runner` job 38 | 39 | ```terraform 40 | 41 | variable "length" {} 42 | 43 | module "operator" { 44 | source = "IbraheemAlSaady/test/module" 45 | version = "0.0.1" 46 | 47 | length = var.length 48 | } 49 | 50 | output "result" { 51 | value = module.operator.result 52 | } 53 | 54 | ``` 55 | 56 | The controller then will start monitoring the Job status and once completed/failed, the controller will update the Status of the Terraform object. 57 | 58 | You may be wondering, but where did the `length` variable value is coming from? The controller will append the `TF_VAR_` to any varialbe that has `environmentVariable` set to `false`, then later will inject it to the Kubernetes Job as an environment variable. 59 | 60 | Aside from the Job, with each Terraform run, the controller will create the following resources: 61 | 62 | 1. **ConfigMap:** this will contain the module rendered as shown above and will be mounted into the terraform runner job 63 | 2. **Secret:** for outputs to be stored 64 | 3. **service account & role binding** the terraform runner require access to the secret to write outputs. If the service account and role binding were not found in the namespace where the Terraform object was created, it will create them 65 | 66 | If `spec.outputs` were defined in the manifest, the outputs will be added to the secret created by the controller -------------------------------------------------------------------------------- /docs/examples.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Examples 4 | nav_order: 5 5 | has_children: true 6 | --- 7 | 8 | # Examples 9 | Examples on defining Terraform objects -------------------------------------------------------------------------------- /docs/examples/aws.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Terraform AWS 4 | parent: Examples 5 | nav_order: 1 6 | --- 7 | 8 | # Using AWS Resources 9 | Lets create a Terraform object that will run a module which creates an S3 bucket, module source can be found [here](https://github.com/IbraheemAlSaady/terraform-module-test/blob/main/modules/aws/main.tf) 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | metadata: 15 | name: terraform-aws-s3 16 | spec: 17 | terraformVersion: 1.0.2 18 | 19 | module: 20 | source: IbraheemAlSaady/test/module//modules/aws 21 | version: 0.0.2 22 | 23 | variables: 24 | - key: name 25 | value: "mys3bucket" 26 | - key: AWS_DEFAULT_REGION 27 | value: eu-west-1 28 | environmentVariable: true 29 | - key: AWS_ACCESS_KEY_ID 30 | environmentVariable: true 31 | valueFrom: 32 | secretKeyRef: 33 | name: aws-credentials 34 | key: AWS_ACCESS_KEY_ID 35 | - key: AWS_SECRET_ACCESS_KEY 36 | environmentVariable: true 37 | valueFrom: 38 | secretKeyRef: 39 | name: aws-credentials 40 | key: AWS_SECRET_ACCESS_KEY 41 | 42 | backend: | 43 | backend "s3" { 44 | bucket = "mybucket" 45 | key = "path/to/my/key" 46 | region = "eu-west-1" 47 | } 48 | 49 | providersConfig: | 50 | terraform { 51 | required_providers { 52 | aws = { 53 | source = "hashicorp/aws" 54 | version = "~> 3.0" 55 | } 56 | } 57 | } 58 | 59 | provider "aws" { 60 | region = "eu-west-1" 61 | } 62 | 63 | outputs: 64 | - key: bucket_id 65 | moduleOutputName: id 66 | ``` 67 | 68 | As you notice, we're passing `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` variables as `environmentVariable`. The values are picked up from a secret called `aws-credentials` which is created in the same namespace where the `Terraform` object is created. This is to authenticate the terraform AWS provider 69 | 70 | We also provided the `providersConfig` section which configures the Terraform providers. A `backend` section is also configured. 71 | 72 | Finally, there is only one output defined, which is `bucket_id`. A secret will be created for the run where the secret key will be `bucket_id` and the value is picked up from the module output, which is `id` as defined in the module source code. -------------------------------------------------------------------------------- /docs/examples/azure.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Terraform Azure 4 | parent: Examples 5 | nav_order: 2 6 | --- 7 | 8 | # Using Azure Resources 9 | Lets create a Terraform object that will run a module which creates an S3 bucket, module source can be found [here](https://github.com/IbraheemAlSaady/terraform-module-test/blob/main/modules/azure/main.tf) 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | metadata: 15 | name: terraform-az-storage 16 | spec: 17 | terraformVersion: 1.0.2 18 | 19 | module: 20 | source: IbraheemAlSaady/test/module//modules/azure 21 | version: 0.0.2 22 | 23 | variables: 24 | - key: name 25 | value: "mystorage" 26 | - key: ARM_CLIENT_ID 27 | environmentVariable: true 28 | valueFrom: 29 | secretKeyRef: 30 | name: azure-credentials 31 | key: ARM_CLIENT_ID 32 | - key: ARM_CLIENT_SECRET 33 | environmentVariable: true 34 | valueFrom: 35 | secretKeyRef: 36 | name: azure-credentials 37 | key: ARM_CLIENT_SECRET 38 | - key: ARM_SUBSCRIPTION_ID 39 | environmentVariable: true 40 | valueFrom: 41 | secretKeyRef: 42 | name: azure-credentials 43 | key: ARM_SUBSCRIPTION_ID 44 | - key: ARM_TENANT_ID 45 | environmentVariable: true 46 | valueFrom: 47 | secretKeyRef: 48 | name: azure-credentials 49 | key: ARM_TENANT_ID 50 | 51 | backend: | 52 | backend "azurerm" { 53 | resource_group_name = "StorageAccount-ResourceGroup" 54 | storage_account_name = "abcd1234" 55 | container_name = "tfstate" 56 | key = "prod.terraform.tfstate" 57 | } 58 | 59 | providersConfig: | 60 | terraform { 61 | required_providers { 62 | azurerm = { 63 | source = "hashicorp/azurerm" 64 | version = "=2.46.0" 65 | } 66 | } 67 | } 68 | 69 | provider "azurerm" { 70 | features {} 71 | } 72 | 73 | outputs: 74 | - key: storage_account_id 75 | moduleOutputName: id 76 | 77 | ``` 78 | 79 | As you notice, we're passing `ARM_CLIENT_ID`, `ARM_CLIENT_SECRET`, `ARM_SUBSCRIPTION_ID`, and `ARM_TENANT_ID` variables as `environmentVariable`. The values are picked up from a secret called `ARM_TENANT_ID` which is created in the same namespace where the `Terraform` object is created. This is to authenticate the terraform Azure provider 80 | 81 | We also provided the `providersConfig` section which configures the Terraform providers. A `backend` section is also configured. 82 | 83 | Finally, there is only one output defined, which is `storage_account_id`. A secret will be created for the run where the secret key will be `storage_account_id` and the value is picked up from the module output, which is `id` as defined in the module source code. -------------------------------------------------------------------------------- /docs/examples/dependency.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Terraform Dependencies 4 | parent: Examples 5 | nav_order: 3 6 | --- 7 | 8 | # Dependencies 9 | A `Terraform` object can depend on another `Terraform` object. Lets take the following example: 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | metadata: 15 | name: terraform-run1 16 | spec: 17 | terraformVersion: 1.0.2 18 | 19 | module: 20 | source: IbraheemAlSaady/test/module 21 | version: 0.0.2 22 | 23 | variables: 24 | - key: length 25 | value: "16" 26 | 27 | outputs: 28 | - key: result 29 | moduleOutputName: result 30 | --- 31 | apiVersion: run.terraform-operator.io/v1alpha1 32 | kind: Terraform 33 | metadata: 34 | name: terraform-run2 35 | spec: 36 | terraformVersion: 1.0.2 37 | 38 | module: 39 | source: IbraheemAlSaady/test/module 40 | version: 0.0.2 41 | 42 | dependsOn: 43 | - name: terraform-run1 44 | 45 | variables: 46 | - key: length 47 | value: "16" 48 | 49 | outputs: 50 | - key: result 51 | moduleOutputName: result 52 | ``` 53 | 54 | In the example above, the run `terraform-run2` will not run and will be in a waiting state until `terraform-run1` is in a completed state. 55 | 56 | You can also create a dependency on a Terraform object in a different namespace as follows: 57 | 58 | ```yaml 59 | ... 60 | spec: 61 | ... 62 | dependsOn: 63 | - name: terraform-run1 64 | namespace: some_other_namespacce 65 | ``` -------------------------------------------------------------------------------- /docs/examples/git-ssh.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Git SSH Auth 4 | parent: Examples 5 | nav_order: 4 6 | --- 7 | 8 | # Git SSH Authentication 9 | You can specify module source from a private git repo, in order to authenticate, you must providate a private key to authenticate. Here is an example: 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | metadata: 15 | name: terraform-basic 16 | spec: 17 | terraformVersion: 1.0.2 18 | 19 | module: 20 | source: git::ssh://git@github.com/IbraheemAlSaady/terraform-module-test.git 21 | 22 | gitSSHKey: 23 | valueFrom: 24 | secret: 25 | secretName: git-ssh-key 26 | defaultMode: 0600 27 | ``` 28 | 29 | In the example above, the `spec.gitSSHKey` configures the SSH private key which will be picked up from a secret named `git-ssh-key`. The `defaultMode` is to set the permission to 600. -------------------------------------------------------------------------------- /docs/examples/var-file.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Variable Files 4 | parent: Examples 5 | nav_order: 5 6 | --- 7 | 8 | # Terraform Variable File 9 | You can specify variable files to your `Terraform` run either from a configmap or a secret. Here is an example: 10 | 11 | ```yaml 12 | apiVersion: v1 13 | kind: ConfigMap 14 | metadata: 15 | name: var-file-data 16 | data: 17 | data.tfvars: |- 18 | length = 50 19 | --- 20 | apiVersion: run.terraform-operator.io/v1alpha1 21 | kind: Terraform 22 | metadata: 23 | name: terraform-var-files 24 | spec: 25 | terraformVersion: 1.0.2 26 | 27 | module: 28 | source: IbraheemAlSaady/test/module 29 | version: 0.0.2 30 | 31 | variableFiles: 32 | - key: data-config 33 | valueFrom: 34 | configMap: 35 | name: var-file-data 36 | ``` 37 | 38 | In the example above, we created a configmap with a key called `data.tfvars` (extension in the key must either be `.tfvars` or `.tf`). In the `Terraform` object `spec.variableFiles`, we can configure the variable files to be used in the run. 39 | 40 | The files in the configmap will be mounted in the Terraform job. -------------------------------------------------------------------------------- /docs/features.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Features 4 | nav_order: 4 5 | has_children: true 6 | --- 7 | 8 | # Features 9 | An overview of the Terraform Operator features -------------------------------------------------------------------------------- /docs/features/1.version.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Terraform Version 4 | parent: Features 5 | nav_order: 1 6 | --- 7 | 8 | ### Terraform Version 9 | You can specify which Terraform version to use to run your module by setting the `spec.terraformVersion` field 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | ... 15 | spec: 16 | ... 17 | terraformVersion: "1.0.2" 18 | ``` -------------------------------------------------------------------------------- /docs/features/10.outputs.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Outputs 4 | parent: Features 5 | nav_order: 10 6 | --- 7 | 8 | # Outputs 9 | Each Terraform run will create a secret to hold the outputs defined in the Terraform object. You can specify outputs as follows: 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | ... 15 | spec: 16 | ... 17 | outputs: 18 | - key: my_id 19 | moduleOutputName: id 20 | ``` 21 | 22 | The output `key` will be the secret key that will hold the value. The `moduleOutputName` is the output name from your Terraform module 23 | -------------------------------------------------------------------------------- /docs/features/11.destroy.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Destroy 4 | parent: Features 5 | nav_order: 11 6 | --- 7 | 8 | # Terraform Destroy 9 | You can run a destroy on your Terraform module by flagging the `spec.destroy` field 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | ... 15 | spec: 16 | ... 17 | destroy: true 18 | 19 | # deleteCompletedJobs: false 20 | ``` 21 | 22 | By default, completed jobs will not be deleted, you can alter the behavior and delete the completed jobs by setting `spec.deleteCompletedJobs` to `true` -------------------------------------------------------------------------------- /docs/features/12.retries.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Retry Limit 4 | parent: Features 5 | nav_order: 12 6 | --- 7 | 8 | # Retry Limit 9 | In case your Terraform run failed to apply/destroy, you can specify the number of retries 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | ... 15 | spec: 16 | ... 17 | retryLimit: 2 18 | ``` -------------------------------------------------------------------------------- /docs/features/2.module-source.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Module Source 4 | parent: Features 5 | nav_order: 2 6 | --- 7 | 8 | # Module Source 9 | The source tells `Terraform` where to find the source code for the desired module. The `source` must be a valid Terraform module source. See [Module sources](https://www.terraform.io/language/modules/sources) 10 | 11 | The module source can be specified in `spec.module.source` field. A module version can also be specified in `spec.module.version` 12 | 13 | ```yaml 14 | apiVersion: run.terraform-operator.io/v1alpha1 15 | kind: Terraform 16 | ... 17 | spec: 18 | ... 19 | module: 20 | source: IbraheemAlSaady/test/module 21 | version: "0.0.2" 22 | ``` 23 | 24 | To specify a source from a `private git repo`, see the [git auth section](./git-ssh.md) -------------------------------------------------------------------------------- /docs/features/3.variables.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Variables 4 | parent: Features 5 | nav_order: 3 6 | --- 7 | 8 | # Variables 9 | Variables can be specified in the `spec.variables` section. There are two ways to how variables are used: 10 | 1. Variable needed by your Terraform module 11 | 2. Environment Variables (possibly needed for your Terraform provider) 12 | 13 | ## Variables for Terraform Modules 14 | These variables are needed by your module, they can be specified as follows: 15 | 16 | ```yaml 17 | apiVersion: run.terraform-operator.io/v1alpha1 18 | kind: Terraform 19 | ... 20 | spec: 21 | ... 22 | variables: 23 | - key: the_name_of_the_variable 24 | value: value_of_the_variable 25 | 26 | # valueFrom: 27 | # secretKeyRef: 28 | # name: aws-credentials 29 | # key: AWS_ACCESS_KEY 30 | 31 | # configMapKeyRef 32 | # name: common-cfg 33 | # key: some-key 34 | ``` 35 | 36 | You can specify the value directly in the `value` field. Variables can also be pulled from a [secretkeyRef](https://kubernetes.io/docs/concepts/configuration/secret/#using-secrets-as-environment-variables) or [configMapKeyRef](https://kubernetes.io/docs/tasks/configure-pod-container/configure-pod-configmap/#define-container-environment-variables-using-configmap-data), this can be done by specifying the `valueFrom` field 37 | 38 | ## Variables as Environment Variables 39 | Yon can specify variables to be set as environment variables, these variables will not be used in your terraform module, but maybe needed by the Terraform provider. Lets take the [AWS Provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs#environment-variables) as an example. 40 | 41 | The provider expects certain variables to be set to authenticate. You can specify environment variables by flagging the `environmentVariable` as follows: 42 | 43 | ```yaml 44 | apiVersion: run.terraform-operator.io/v1alpha1 45 | kind: Terraform 46 | ... 47 | spec: 48 | ... 49 | variables: 50 | - key: the_name_of_the_variable 51 | environmentVariable: true 52 | value: value_of_the_variable 53 | 54 | # valueFrom: 55 | # secretKeyRef: 56 | # name: aws-credentials 57 | # key: AWS_ACCESS_KEY 58 | 59 | # configMapKeyRef 60 | # name: common-cfg 61 | # key: some-key 62 | ``` 63 | 64 | ## Variables from a dependency 65 | You can use a variable from another workflow/run, this will save you the trouble of using the [terraform_remote_state](https://www.terraform.io/language/state/remote-state-data) data resource 66 | 67 | *This currently only works for workflows/runs that are in the same namespace* 68 | 69 | ```yaml 70 | apiVersion: run.terraform-operator.io/v1alpha1 71 | kind: Terraform 72 | metadata: 73 | name: terraform-run1 74 | spec: 75 | terraformVersion: 1.1.7 76 | 77 | module: 78 | source: IbraheemAlSaady/test/module 79 | version: 0.0.3 80 | 81 | variables: 82 | - key: length 83 | value: "4" 84 | 85 | outputs: 86 | - key: number 87 | moduleOutputName: number 88 | --- 89 | apiVersion: run.terraform-operator.io/v1alpha1 90 | kind: Terraform 91 | metadata: 92 | name: terraform-run2 93 | spec: 94 | terraformVersion: 1.1.7 95 | 96 | module: 97 | source: IbraheemAlSaady/test/module 98 | version: 0.0.3 99 | 100 | ## this must be specified in order for the variable dependencyRef to work 101 | dependsOn: 102 | - name: terraform-run1 103 | 104 | variables: 105 | - key: length 106 | dependencyRef: 107 | name: terraform-run1 108 | ## this is the key from the "terraform-run1" output field 109 | key: number 110 | ``` -------------------------------------------------------------------------------- /docs/features/4.variable-files.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Variable Files 4 | parent: Features 5 | nav_order: 4 6 | --- 7 | 8 | # Variable Files 9 | Variable files can be mounted into the run job from configmap or a secret. Here is an example 10 | 11 | ```yaml 12 | apiVersion: v1 13 | kind: ConfigMap 14 | metadata: 15 | name: common-cfg 16 | data: 17 | common.tfvars: |- 18 | length = 20 19 | --- 20 | apiVersion: run.terraform-operator.io/v1alpha1 21 | kind: Terraform 22 | ... 23 | spec: 24 | ... 25 | variableFiles: 26 | - key: common-config 27 | valueFrom: 28 | configMap: 29 | name: common-cfg 30 | 31 | # secret: 32 | # secretName: my-secret-var-file 33 | ``` 34 | 35 | The configmap/secret key must end with the an extension `.tfvars` or `.tf` -------------------------------------------------------------------------------- /docs/features/5.workspace.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Workspace 4 | parent: Features 5 | nav_order: 5 6 | --- 7 | 8 | # Terraform Workspace 9 | You can specify which Terraform `workspace` to target by setting the `spec.workspace` field. Default is the `default` workspace 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | ... 15 | spec: 16 | ... 17 | workspace: dev 18 | ``` 19 | -------------------------------------------------------------------------------- /docs/features/6.backend.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Terraform Backend 4 | parent: Features 5 | nav_order: 6 6 | --- 7 | 8 | # Terraform Backend 9 | You can specify a custom backend to use for your Terraform run. See [backends](https://www.terraform.io/language/settings/backends) 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | ... 15 | spec: 16 | ... 17 | backend: | 18 | backend "local" { 19 | path = "/tmp/tfmodule/mytfstate.tfstate" 20 | } 21 | ``` 22 | 23 | ## Using Kubernetes as a terraform backend 24 | If the `backend` field was not provided, it will default to the Kubernetes backend. For more custom configuration, you can modify the `backend` field as below 25 | 26 | ```yaml 27 | apiVersion: run.terraform-operator.io/v1alpha1 28 | kind: Terraform 29 | ... 30 | spec: 31 | ... 32 | backend: | 33 | backend "kubernetes" { 34 | secret_suffix = "example-module" 35 | in_cluster_config = true 36 | } 37 | ``` 38 | 39 | `Suffix` used when creating secrets. Secrets will be named in the format: tfstate-{workspace}-{secret_suffix}. -------------------------------------------------------------------------------- /docs/features/7.providers.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Terraform Providers 4 | parent: Features 5 | nav_order: 7 6 | --- 7 | 8 | # Terraform Backend 9 | Sometimes you might need to define the Terraform providers explicitly. See [providers docs](https://www.terraform.io/language/providers) 10 | 11 | As an example, below is a definition for the AWS provider 12 | 13 | ```yaml 14 | apiVersion: run.terraform-operator.io/v1alpha1 15 | kind: Terraform 16 | ... 17 | spec: 18 | ... 19 | providersConfig: | 20 | terraform { 21 | required_providers { 22 | aws = { 23 | source = "hashicorp/aws" 24 | version = "~> 3.0" 25 | } 26 | } 27 | } 28 | 29 | provider "aws" { 30 | region = "eu-west-1" 31 | } 32 | ``` 33 | -------------------------------------------------------------------------------- /docs/features/8.dependencies.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Terraform Dependencies 4 | parent: Features 5 | nav_order: 8 6 | --- 7 | 8 | # Dependencies 9 | The Terraform Operator supports dependency on other `Terraform` runs whether within the same namespace or a different namespace. 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | metadata: 15 | name: terraform-first-run 16 | ``` 17 | 18 | ```yaml 19 | apiVersion: run.terraform-operator.io/v1alpha1 20 | kind: Terraform 21 | ... 22 | spec: 23 | ... 24 | dependsOn: 25 | - name: terraform-first-run 26 | # namespace: another-namespace 27 | ``` 28 | 29 | You can also specify variables based on the output of the dependency, check [here](https://kuptan.github.io/terraform-operator/features/3.variables/#Variables-from-a-dependency) for examples -------------------------------------------------------------------------------- /docs/features/9.git-ssh.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Git SSH 4 | parent: Features 5 | nav_order: 9 6 | --- 7 | 8 | # Git SSH Authentication 9 | If your module is located in a private git repo, you will need to provide an SSH key in your Terraform object to allow Terraform to pull your module. 10 | 11 | ```yaml 12 | apiVersion: run.terraform-operator.io/v1alpha1 13 | kind: Terraform 14 | ... 15 | spec: 16 | ... 17 | gitSSHKey: 18 | valueFrom: 19 | secret: 20 | secretName: git-ssh-key 21 | defaultMode: 0600 22 | ``` 23 | 24 | In the example above, the `spec.gitSSHKey` configures the SSH private key which will be picked up from a secret named `git-ssh-key`. The `defaultMode` is to set the permission on the key to 600. -------------------------------------------------------------------------------- /docs/img/design.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kuptan/terraform-operator/f440afaf1b7084d22079cabc42992784d90e428b/docs/img/design.png -------------------------------------------------------------------------------- /docs/img/tfo.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Overview 4 | nav_order: 1 5 | --- 6 | 7 | # Overview 8 | The Terraform Operator provides support to run Terraform modules in Kubernetes in a declarative way as a [Kubernetes manifest](https://kubernetes.io/docs/concepts/cluster-administration/manage-deployment/) 9 | 10 | The motivation behind the Terraform Operator was to run Terraform modules in Kubernetes in a GitOps environment (with Flux or ArgoCD). You might say there are tools that are already built for Kubernetes like CrossPlane. The idea is that we already have the modules and they work just great for us, we just wanted the option to run in Kubernetes without switching to a completely new tool. 11 | 12 | The Terraform Operator can help you with just that. 13 | 14 | ## Features Highlight 15 | 16 | * Define your terraform flows in Kubernetes declaritvely 17 | * Specify the module source and version with abillity to pull from private git repos 18 | * Select the target workspace and define your backend configuration 19 | * Define variables from Kubernetes secrets and configmaps 20 | * Define variable files from Kubernetes secrets and configmaps 21 | * Dependency management where one terraform run depends on another terraform run 22 | * Module outputs can be written to a Kubernetes secret 23 | * Define retry limit on your terraform run in case of failure -------------------------------------------------------------------------------- /docs/installation.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Installation 4 | nav_order: 2 5 | --- 6 | 7 | # Installation 8 | You can install the Terraform Operator either with `Helm` or directly apply the manifests with `kubectl` 9 | 10 | **Helm** 11 | 12 | ```bash 13 | helm repo add kuptan https://kuptan.github.io/helm-charts 14 | helm install terraform-operator kuptan/terraform-operator 15 | ``` 16 | 17 | The Helm Chart source code can be found [here](https://github.com/kuptan/helm-charts/tree/master/charts/terraform-operator) 18 | 19 | **Kubectl** 20 | 21 | ```bash 22 | kubectl apply -k https://github.com/kuptan/terraform-operator/config/crd 23 | kubectl apply -k https://github.com/kuptan/terraform-operator/config/manifest 24 | ``` 25 | -------------------------------------------------------------------------------- /docs/monitoring.md: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | title: Monitoring 4 | nav_order: 6 5 | --- 6 | 7 | # Monitoring 8 | The controller writes the following Prometheus metrics. 9 | 10 | - `tfo_workflow_total`: The total number of submitted workflows/runs 11 | - `tfo_workflow_status`: The current status of a Terraform workflow/run resource reconciliation 12 | - `tfo_workflow_duration_seconds`: The duration in seconds of a Terraform workflow/run 13 | 14 | *The metrics can be scraped from the controller's `/metrics` endpoint, the default metrics address port is set to `8080`* -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/kuptan/terraform-operator 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/go-logr/logr v1.2.3 7 | github.com/onsi/ginkgo v1.16.5 8 | github.com/onsi/gomega v1.19.0 9 | github.com/prometheus/client_golang v1.12.2 10 | k8s.io/api v0.24.3 11 | k8s.io/apimachinery v0.24.3 12 | k8s.io/client-go v0.24.3 13 | sigs.k8s.io/controller-runtime v0.12.3 14 | ) 15 | 16 | require ( 17 | cloud.google.com/go/compute v1.7.0 // indirect 18 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 19 | github.com/Azure/go-autorest/autorest v0.11.27 // indirect 20 | github.com/Azure/go-autorest/autorest/adal v0.9.20 // indirect 21 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 22 | github.com/Azure/go-autorest/logger v0.2.1 // indirect 23 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 24 | github.com/beorn7/perks v1.0.1 // indirect 25 | github.com/cespare/xxhash/v2 v2.1.2 // indirect 26 | github.com/davecgh/go-spew v1.1.1 // indirect 27 | github.com/emicklei/go-restful/v3 v3.9.0 // indirect 28 | github.com/evanphx/json-patch v5.6.0+incompatible // indirect 29 | github.com/fsnotify/fsnotify v1.5.4 // indirect 30 | github.com/go-logr/zapr v1.2.3 // indirect 31 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 32 | github.com/go-openapi/jsonreference v0.20.0 // indirect 33 | github.com/go-openapi/swag v0.21.1 // indirect 34 | github.com/gogo/protobuf v1.3.2 // indirect 35 | github.com/golang-jwt/jwt/v4 v4.4.2 // indirect 36 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 37 | github.com/golang/protobuf v1.5.2 // indirect 38 | github.com/google/gnostic v0.6.9 // indirect 39 | github.com/google/go-cmp v0.5.8 // indirect 40 | github.com/google/gofuzz v1.2.0 // indirect 41 | github.com/google/uuid v1.3.0 // indirect 42 | github.com/imdario/mergo v0.3.13 // indirect 43 | github.com/josharian/intern v1.0.0 // indirect 44 | github.com/json-iterator/go v1.1.12 // indirect 45 | github.com/mailru/easyjson v0.7.7 // indirect 46 | github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect 47 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 48 | github.com/modern-go/reflect2 v1.0.2 // indirect 49 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 50 | github.com/nxadm/tail v1.4.8 // indirect 51 | github.com/pkg/errors v0.9.1 // indirect 52 | github.com/prometheus/client_model v0.2.0 // indirect 53 | github.com/prometheus/common v0.37.0 // indirect 54 | github.com/prometheus/procfs v0.7.3 // indirect 55 | github.com/spf13/pflag v1.0.5 // indirect 56 | go.uber.org/atomic v1.9.0 // indirect 57 | go.uber.org/multierr v1.8.0 // indirect 58 | go.uber.org/zap v1.21.0 // indirect 59 | golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d // indirect 60 | golang.org/x/net v0.0.0-20220708220712-1185a9018129 // indirect 61 | golang.org/x/oauth2 v0.0.0-20220718184931-c8730f7fcb92 // indirect 62 | golang.org/x/sys v0.0.0-20220721230656-c6bc011c0c49 // indirect 63 | golang.org/x/term v0.0.0-20220526004731-065cf7ba2467 // indirect 64 | golang.org/x/text v0.3.7 // indirect 65 | golang.org/x/time v0.0.0-20220609170525-579cf78fd858 // indirect 66 | gomodules.xyz/jsonpatch/v2 v2.2.0 // indirect 67 | google.golang.org/appengine v1.6.7 // indirect 68 | google.golang.org/protobuf v1.28.0 // indirect 69 | gopkg.in/inf.v0 v0.9.1 // indirect 70 | gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 // indirect 71 | gopkg.in/yaml.v2 v2.4.0 // indirect 72 | gopkg.in/yaml.v3 v3.0.1 // indirect 73 | k8s.io/apiextensions-apiserver v0.24.3 // indirect 74 | k8s.io/component-base v0.24.3 // indirect 75 | k8s.io/klog/v2 v2.70.1 // indirect 76 | k8s.io/kube-openapi v0.0.0-20220627174259-011e075b9cb8 // indirect 77 | k8s.io/utils v0.0.0-20220713171938-56c0de1e6f5e // indirect 78 | sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect 79 | sigs.k8s.io/structured-merge-diff/v4 v4.2.1 // indirect 80 | sigs.k8s.io/yaml v1.3.0 // indirect 81 | ) 82 | -------------------------------------------------------------------------------- /hack/boilerplate.go.txt: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022. 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 | */ -------------------------------------------------------------------------------- /internal/kube/client.go: -------------------------------------------------------------------------------- 1 | package kube 2 | 3 | import ( 4 | "context" 5 | "os" 6 | "path/filepath" 7 | 8 | "github.com/kuptan/terraform-operator/internal/utils" 9 | "k8s.io/client-go/kubernetes" 10 | "k8s.io/client-go/rest" 11 | clientcmd "k8s.io/client-go/tools/clientcmd" 12 | "sigs.k8s.io/controller-runtime/pkg/log" 13 | ) 14 | 15 | // ClientSet has the Kubernetes.Interface information, this will be set once the controller is running 16 | var ClientSet kubernetes.Interface 17 | 18 | // CreateK8SConfig creates the Kube client set 19 | func CreateK8SConfig() (*rest.Config, error) { 20 | l := log.FromContext(context.Background()) 21 | dir, err := os.Getwd() 22 | 23 | if err != nil { 24 | l.Error(err, "could not retreive currect directory") 25 | return nil, err 26 | } 27 | 28 | kubeconfigPath := filepath.Join(dir, "kubeconfig") 29 | 30 | var clientset *kubernetes.Clientset 31 | var config *rest.Config 32 | 33 | if utils.FileExists(kubeconfigPath) { 34 | if config, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath); err != nil { 35 | l.Error(err, "failed to create K8s config from kubeconfig") 36 | return nil, err 37 | } 38 | } else { 39 | if config, err = rest.InClusterConfig(); err != nil { 40 | l.Error(err, "Failed to create in-cluster k8s config") 41 | return nil, err 42 | } 43 | } 44 | 45 | clientset, err = kubernetes.NewForConfig(config) 46 | 47 | if err != nil { 48 | l.Error(err, "Failed to create K8s clientset") 49 | } 50 | 51 | ClientSet = clientset 52 | 53 | return config, nil 54 | } 55 | -------------------------------------------------------------------------------- /internal/metrics/metrics.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/kuptan/terraform-operator/api/v1alpha1" 7 | "github.com/prometheus/client_golang/prometheus" 8 | ) 9 | 10 | // RecorderInterface is an interface that holds the functions used by the recorder struct 11 | type RecorderInterface interface { 12 | RecordTotal(name string, namespace string) 13 | RecordStatus(name string, namespace string, status v1alpha1.TerraformRunStatus) 14 | RecordDuration(name string, namespace string, start time.Time) 15 | Collectors() []prometheus.Collector 16 | } 17 | 18 | // Recorder is a struct for recording GitOps Toolkit metrics for a controller. 19 | // 20 | // Use NewRecorder to initialise it with properly configured metric names. 21 | type Recorder struct { 22 | totalCount *prometheus.CounterVec 23 | statusGauge *prometheus.GaugeVec 24 | durationHistogram *prometheus.HistogramVec 25 | } 26 | 27 | // NewRecorder returns a new Recorder with all metric names configured confirm GitOps Toolkit standards. 28 | func NewRecorder() RecorderInterface { 29 | return &Recorder{ 30 | totalCount: prometheus.NewCounterVec( 31 | prometheus.CounterOpts{ 32 | Name: "tfo_workflow_total", 33 | Help: "The total number of submitted workflows", 34 | }, 35 | []string{"name", "namespace"}, 36 | ), 37 | statusGauge: prometheus.NewGaugeVec( 38 | prometheus.GaugeOpts{ 39 | Name: "tfo_workflow_status", 40 | Help: "The current status of a Terraform workflow/run resource reconciliation.", 41 | }, 42 | []string{"name", "namespace"}, 43 | ), 44 | durationHistogram: prometheus.NewHistogramVec( 45 | prometheus.HistogramOpts{ 46 | Name: "tfo_workflow_duration_seconds", 47 | Help: "The duration in seconds of a Terraform workflow/run.", 48 | Buckets: prometheus.ExponentialBuckets(10e-9, 10, 10), 49 | }, 50 | []string{"name", "namespace"}, 51 | ), 52 | } 53 | } 54 | 55 | // Collectors returns a slice of Prometheus collectors, which can be used to register them in a metrics registry. 56 | func (r *Recorder) Collectors() []prometheus.Collector { 57 | return []prometheus.Collector{ 58 | r.totalCount, 59 | r.statusGauge, 60 | r.durationHistogram, 61 | } 62 | } 63 | 64 | // RecordTotal records the total number of submitted workflows 65 | func (r *Recorder) RecordTotal(name string, namespace string) { 66 | r.totalCount.WithLabelValues(name, namespace).Inc() 67 | } 68 | 69 | // RecordStatus records the status for a given terraform workflow/run 70 | func (r *Recorder) RecordStatus(name string, namespace string, status v1alpha1.TerraformRunStatus) { 71 | var value float64 72 | 73 | if status == v1alpha1.RunWaitingForDependency { 74 | value = -1 75 | } 76 | 77 | if status == v1alpha1.RunFailed { 78 | value = 1 79 | } 80 | 81 | r.statusGauge.WithLabelValues(name, namespace).Set(value) 82 | } 83 | 84 | // RecordDuration records the duration since start for the given ref. 85 | func (r *Recorder) RecordDuration(name string, namespace string, start time.Time) { 86 | r.durationHistogram.WithLabelValues(name, namespace).Observe(time.Since(start).Seconds()) 87 | } 88 | -------------------------------------------------------------------------------- /internal/metrics/metrics_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/kuptan/terraform-operator/api/v1alpha1" 7 | . "github.com/onsi/ginkgo" 8 | . "github.com/onsi/gomega" 9 | 10 | "github.com/prometheus/client_golang/prometheus" 11 | ) 12 | 13 | var _ = Describe("Metrics Recorder", func() { 14 | rec := NewRecorder() 15 | 16 | reg := prometheus.NewRegistry() 17 | reg.MustRegister(rec.Collectors()...) 18 | 19 | const ( 20 | name = "terraform-workflow" 21 | namespace = "default" 22 | ) 23 | 24 | Context("Recording Total", func() { 25 | It("should record the total count metric", func() { 26 | rec.RecordTotal(name, namespace) 27 | 28 | var ( 29 | value float64 = 1.0 30 | metricName string = "tfo_workflow_total" 31 | ) 32 | 33 | metricFamilies, err := reg.Gather() 34 | 35 | Expect(err).ToNot(HaveOccurred()) 36 | Expect(metricFamilies).To(HaveLen(1)) 37 | Expect(metricFamilies[0].Name).To(Equal(&metricName)) 38 | Expect(metricFamilies[0].Metric).To(HaveLen(1)) 39 | Expect(metricFamilies[0].Metric[0].Counter).ToNot(BeNil()) 40 | Expect(metricFamilies[0].Metric[0].Counter.Value).To(Equal(&value)) 41 | }) 42 | }) 43 | 44 | Context("Recording Status", func() { 45 | It("should record the waitingForDependency status", func() { 46 | rec.RecordStatus(name, namespace, v1alpha1.RunWaitingForDependency) 47 | 48 | var ( 49 | value float64 = -1.0 50 | metricName string = "tfo_workflow_status" 51 | ) 52 | 53 | metricFamilies, err := reg.Gather() 54 | 55 | Expect(err).ToNot(HaveOccurred()) 56 | Expect(metricFamilies).To(HaveLen(2)) 57 | Expect(metricFamilies[0].Name).To(Equal(&metricName)) 58 | Expect(metricFamilies[0].Metric).To(HaveLen(1)) 59 | Expect(metricFamilies[0].Metric[0].Gauge).ToNot(BeNil()) 60 | Expect(metricFamilies[0].Metric[0].Gauge.Value).To(Equal(&value)) 61 | }) 62 | 63 | It("should record the failed status", func() { 64 | rec.RecordStatus(name, namespace, v1alpha1.RunFailed) 65 | 66 | var ( 67 | value float64 = 1.0 68 | metricName string = "tfo_workflow_status" 69 | ) 70 | 71 | metricFamilies, err := reg.Gather() 72 | 73 | Expect(err).ToNot(HaveOccurred()) 74 | Expect(metricFamilies).To(HaveLen(2)) 75 | Expect(metricFamilies[0].Name).To(Equal(&metricName)) 76 | Expect(metricFamilies[0].Metric).To(HaveLen(1)) 77 | Expect(metricFamilies[0].Metric[0].Gauge).ToNot(BeNil()) 78 | Expect(metricFamilies[0].Metric[0].Gauge.Value).To(Equal(&value)) 79 | }) 80 | 81 | It("should record the completed status", func() { 82 | rec.RecordStatus(name, namespace, v1alpha1.RunCompleted) 83 | 84 | var ( 85 | value float64 = 0.0 86 | metricName string = "tfo_workflow_status" 87 | ) 88 | 89 | metricFamilies, err := reg.Gather() 90 | 91 | Expect(err).ToNot(HaveOccurred()) 92 | Expect(metricFamilies).To(HaveLen(2)) 93 | Expect(metricFamilies[0].Name).To(Equal(&metricName)) 94 | Expect(metricFamilies[0].Metric).To(HaveLen(1)) 95 | Expect(metricFamilies[0].Metric[0].Gauge).ToNot(BeNil()) 96 | Expect(metricFamilies[0].Metric[0].Gauge.Value).To(Equal(&value)) 97 | }) 98 | 99 | }) 100 | 101 | Context("Recording Duration", func() { 102 | It("should record the duration metric", func() { 103 | rec.RecordDuration(name, namespace, time.Now()) 104 | 105 | var ( 106 | metricName string = "tfo_workflow_duration_seconds" 107 | ) 108 | 109 | metricFamilies, err := reg.Gather() 110 | 111 | Expect(err).ToNot(HaveOccurred()) 112 | Expect(metricFamilies).To(HaveLen(3)) 113 | Expect(metricFamilies[0].Name).To(Equal(&metricName)) 114 | Expect(metricFamilies[0].Metric).To(HaveLen(1)) 115 | Expect(metricFamilies[0].Metric[0].Histogram).ToNot(BeNil()) 116 | }) 117 | }) 118 | }) 119 | -------------------------------------------------------------------------------- /internal/metrics/suite_test.go: -------------------------------------------------------------------------------- 1 | package metrics 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestMetrics(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Metrics Suite") 13 | } 14 | -------------------------------------------------------------------------------- /internal/utils/env.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "log" 5 | "os" 6 | ) 7 | 8 | // EnvConfig holds the environment variables information 9 | type EnvConfig struct { 10 | DockerRepository string 11 | TerraformRunnerImage string 12 | TerraformRunnerImageTag string 13 | KnownHostsConfigMapName string 14 | } 15 | 16 | // Env holds the values of the environment variables 17 | var Env *EnvConfig 18 | 19 | // getEnvOrPanic returns a required environment variable and panics if it does not exist 20 | func getEnvOrPanic(name string) string { 21 | env, present := os.LookupEnv(name) 22 | 23 | if !present { 24 | log.Panicf("environment variable '%s' is required but was not found", name) 25 | } 26 | 27 | return env 28 | } 29 | 30 | // getEnvOptional returns an optional environment variable if exist 31 | func getEnvOptional(name string) string { 32 | env, present := os.LookupEnv(name) 33 | 34 | if present { 35 | return env 36 | } 37 | 38 | return "" 39 | } 40 | 41 | // LoadEnv loads teh environment variables 42 | func LoadEnv() { 43 | cfg := &EnvConfig{} 44 | 45 | cfg.DockerRepository = getEnvOrPanic("DOCKER_REGISTRY") 46 | cfg.TerraformRunnerImage = getEnvOrPanic("TERRAFORM_RUNNER_IMAGE") 47 | cfg.TerraformRunnerImageTag = getEnvOrPanic("TERRAFORM_RUNNER_IMAGE_TAG") 48 | cfg.TerraformRunnerImageTag = getEnvOrPanic("TERRAFORM_RUNNER_IMAGE_TAG") 49 | cfg.KnownHostsConfigMapName = getEnvOptional("KNOWN_HOSTS_CONFIGMAP_NAME") 50 | 51 | Env = cfg 52 | } 53 | -------------------------------------------------------------------------------- /internal/utils/file.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // FileExists check if file exists in path 9 | func FileExists(path string) bool { 10 | info, err := os.Stat(filepath.Clean(path)) 11 | 12 | if os.IsNotExist(err) { 13 | return false 14 | } 15 | 16 | return !info.IsDir() 17 | } 18 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | /* 2 | Copyright 2022. 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 main 18 | 19 | import ( 20 | "flag" 21 | "fmt" 22 | "os" 23 | "time" 24 | 25 | // Import all Kubernetes client auth plugins (e.g. Azure, GCP, OIDC, etc.) 26 | // to ensure that exec-entrypoint and run can make use of them. 27 | _ "k8s.io/client-go/plugin/pkg/client/auth" 28 | 29 | "k8s.io/apimachinery/pkg/runtime" 30 | utilruntime "k8s.io/apimachinery/pkg/util/runtime" 31 | clientgoscheme "k8s.io/client-go/kubernetes/scheme" 32 | ctrl "sigs.k8s.io/controller-runtime" 33 | "sigs.k8s.io/controller-runtime/pkg/healthz" 34 | "sigs.k8s.io/controller-runtime/pkg/log/zap" 35 | crtlmetrics "sigs.k8s.io/controller-runtime/pkg/metrics" 36 | 37 | "github.com/kuptan/terraform-operator/api/v1alpha1" 38 | "github.com/kuptan/terraform-operator/controllers" 39 | "github.com/kuptan/terraform-operator/internal/kube" 40 | "github.com/kuptan/terraform-operator/internal/metrics" 41 | "github.com/kuptan/terraform-operator/internal/utils" 42 | //+kubebuilder:scaffold:imports 43 | ) 44 | 45 | var ( 46 | scheme = runtime.NewScheme() 47 | setupLog = ctrl.Log.WithName("setup") 48 | requeueDependency time.Duration 49 | requeueJobWatch time.Duration 50 | ) 51 | 52 | func init() { 53 | utilruntime.Must(clientgoscheme.AddToScheme(scheme)) 54 | 55 | utilruntime.Must(v1alpha1.AddToScheme(scheme)) 56 | //+kubebuilder:scaffold:scheme 57 | } 58 | 59 | func main() { 60 | var metricsAddr string 61 | var enableLeaderElection bool 62 | var probeAddr string 63 | flag.StringVar(&metricsAddr, "metrics-bind-address", ":8080", "The address the metric endpoint binds to.") 64 | flag.StringVar(&probeAddr, "health-probe-bind-address", ":8081", "The address the probe endpoint binds to.") 65 | flag.DurationVar(&requeueJobWatch, "requeue-job-watch", 10*time.Second, "The interval at which job status is reevaluated after a workflow is submitted.") 66 | flag.DurationVar(&requeueDependency, "requeue-dependency", 20*time.Second, "The interval at which dependencies are reevaluated.") 67 | flag.BoolVar(&enableLeaderElection, "leader-elect", false, 68 | "Enable leader election for controller manager. "+ 69 | "Enabling this will ensure there is only one active controller manager.") 70 | opts := zap.Options{ 71 | Development: false, 72 | } 73 | opts.BindFlags(flag.CommandLine) 74 | flag.Parse() 75 | 76 | ctrl.SetLogger(zap.New(zap.UseFlagOptions(&opts))) 77 | 78 | metricsRecorder := metrics.NewRecorder() 79 | crtlmetrics.Registry.MustRegister(metricsRecorder.Collectors()...) 80 | 81 | mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{ 82 | Scheme: scheme, 83 | MetricsBindAddress: metricsAddr, 84 | Port: 9443, 85 | HealthProbeBindAddress: probeAddr, 86 | LeaderElection: enableLeaderElection, 87 | LeaderElectionID: "d5cf1615.terraform-operator.io", 88 | }) 89 | 90 | if err != nil { 91 | setupLog.Error(err, "unable to start manager") 92 | os.Exit(1) 93 | } 94 | 95 | setupLog.Info(fmt.Sprintf("requeue dependency interval: %s", requeueDependency)) 96 | setupLog.Info(fmt.Sprintf("requeue job watch interval: %s", requeueJobWatch)) 97 | 98 | if err = (&controllers.TerraformReconciler{ 99 | Client: mgr.GetClient(), 100 | Scheme: mgr.GetScheme(), 101 | Recorder: mgr.GetEventRecorderFor("terraform-controller"), 102 | MetricsRecorder: metricsRecorder, 103 | Log: ctrl.Log.WithName("controllers").WithName("TerraformController"), 104 | }).SetupWithManager(mgr, controllers.TerraformReconcilerOptions{ 105 | RequeueDependencyInterval: requeueDependency, 106 | RequeueJobWatchInterval: requeueJobWatch, 107 | }); err != nil { 108 | setupLog.Error(err, "unable to create controller", "controller", "Terraform") 109 | os.Exit(1) 110 | } 111 | //+kubebuilder:scaffold:builder 112 | 113 | if err = mgr.AddHealthzCheck("healthz", healthz.Ping); err != nil { 114 | setupLog.Error(err, "unable to set up health check") 115 | os.Exit(1) 116 | } 117 | 118 | if err = mgr.AddReadyzCheck("readyz", healthz.Ping); err != nil { 119 | setupLog.Error(err, "unable to set up ready check") 120 | os.Exit(1) 121 | } 122 | 123 | // Registering clientset 124 | _, err = kube.CreateK8SConfig() 125 | 126 | if err != nil { 127 | setupLog.Error(err, "could not create Kubernetes REST config") 128 | os.Exit(1) 129 | } 130 | 131 | utils.LoadEnv() 132 | 133 | setupLog.Info("starting manager") 134 | 135 | if err = mgr.Start(ctrl.SetupSignalHandler()); err != nil { 136 | setupLog.Error(err, "problem running manager") 137 | os.Exit(1) 138 | } 139 | } 140 | --------------------------------------------------------------------------------