├── .editorconfig ├── .github ├── .editorconfig └── workflows │ └── ci.yaml ├── .gitignore ├── .goreleaser.yml ├── .idea ├── misc.xml ├── modules.xml ├── terraform-provider-k8s.iml └── vcs.xml ├── LICENSE ├── Makefile ├── README.md ├── docs ├── index.md └── resources │ └── manifest.md ├── examples ├── .gitignore ├── 0.12 │ ├── example.tf │ ├── variables.tf │ └── versions.tf ├── fail │ ├── example.tf │ └── versions.tf └── manifests │ ├── crontab-crd.yaml │ ├── crontab-resource.yaml │ ├── my-configmap.yaml │ ├── nginx-deployment.yaml │ ├── nginx-ingress.yaml │ ├── nginx-namespace.yaml │ ├── nginx-pvc.yaml │ └── nginx-service.yaml ├── go.mod ├── go.sum ├── hack ├── .editorconfig ├── .terraformrc.tpl ├── kind.yaml ├── metallb-config.yaml ├── metallb-webhook-patch.yaml ├── setup-kind.sh ├── versions.tf └── versions012.tf ├── k8s ├── helpers.go ├── patch.go ├── provider.go ├── provider_test.go ├── resource_k8s_manifest.go └── resource_k8s_manifest_test.go ├── main.go └── test ├── .editorconfig ├── manifests ├── crontab-crd.yaml ├── crontab-resource.yaml ├── my-configmap.yaml ├── nginx-deployment.yaml ├── nginx-ingress.yaml ├── nginx-namespace.yaml ├── nginx-pvc-pod.yaml ├── nginx-pvc.yaml └── nginx-service.yaml └── terraform ├── main.tf ├── variables.tf └── versions.tf /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | end_of_line = lf 6 | indent_size = 4 7 | indent_style = space 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | 11 | [*.go] 12 | indent_style = tab 13 | 14 | [{Makefile, *.mk}] 15 | indent_style = tab 16 | -------------------------------------------------------------------------------- /.github/.editorconfig: -------------------------------------------------------------------------------- 1 | [{*.yml,*.yaml}] 2 | indent_size = 2 3 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | 9 | jobs: 10 | integration-test: 11 | name: Integration test 12 | runs-on: ubuntu-latest 13 | strategy: 14 | fail-fast: false 15 | matrix: 16 | kube: ["1.19", "1.20", "1.21", "1.22", "1.23", "1.24", "1.25"] 17 | terraform: ["0.12.31", "0.14.11", "0.15.5", "1.0.11", "1.1.9", "1.2.9"] # skip 0.13.7 for now 18 | 19 | steps: 20 | - name: Set up Go 21 | uses: actions/setup-go@v2 22 | with: 23 | go-version: 1.19 24 | 25 | - name: Checkout 26 | uses: actions/checkout@v2 27 | with: 28 | fetch-depth: 0 29 | 30 | # See https://github.com/kubernetes-sigs/kind/releases/tag/v0.11.1 31 | - name: Determine KinD node image version 32 | id: node_image 33 | run: | 34 | case ${{ matrix.kube }} in 35 | 1.19) 36 | NODE_IMAGE=kindest/node:v1.19.16@sha256:707469aac7e6805e52c3bde2a8a8050ce2b15decff60db6c5077ba9975d28b98 ;; 37 | 1.20) 38 | NODE_IMAGE=kindest/node:v1.20.15@sha256:d67de8f84143adebe80a07672f370365ec7d23f93dc86866f0e29fa29ce026fe ;; 39 | 1.21) 40 | NODE_IMAGE=kindest/node:v1.21.14@sha256:f9b4d3d1112f24a7254d2ee296f177f628f9b4c1b32f0006567af11b91c1f301 ;; 41 | 1.22) 42 | NODE_IMAGE=kindest/node:v1.22.13@sha256:4904eda4d6e64b402169797805b8ec01f50133960ad6c19af45173a27eadf959 ;; 43 | 1.23) 44 | NODE_IMAGE=kindest/node:v1.23.10@sha256:f047448af6a656fae7bc909e2fab360c18c487ef3edc93f06d78cdfd864b2d12 ;; 45 | 1.24) 46 | NODE_IMAGE=kindest/node:v1.24.4@sha256:adfaebada924a26c2c9308edd53c6e33b3d4e453782c0063dc0028bdebaddf98 ;; 47 | 1.25) 48 | NODE_IMAGE=kindest/node:v1.25.0@sha256:428aaa17ec82ccde0131cb2d1ca6547d13cf5fdabcc0bbecf749baa935387cbf ;; 49 | esac 50 | 51 | echo "::set-output name=image::$NODE_IMAGE" 52 | 53 | - name: Create KinD cluster 54 | uses: helm/kind-action@v1.3.0 55 | with: 56 | version: v0.15.0 57 | node_image: ${{ steps.node_image.outputs.image }} 58 | config: hack/kind.yaml 59 | 60 | - name: Configure cluster 61 | run: ./hack/setup-kind.sh 62 | 63 | - name: Test 64 | run: make TERRAFORM_VERSION=${{ matrix.terraform }} EXAMPLE_DIR=test/terraform test-integration 65 | 66 | acceptance-test: 67 | name: Acceptance test 68 | runs-on: ubuntu-latest 69 | strategy: 70 | fail-fast: false 71 | matrix: 72 | kube: ["1.19", "1.20", "1.21", "1.22", "1.23", "1.24", "1.25"] 73 | terraform: ["0.12.31", "0.13.7", "0.14.11", "0.15.5", "1.0.11", "1.1.9", "1.2.9"] 74 | 75 | steps: 76 | - name: Set up Go 77 | uses: actions/setup-go@v2 78 | with: 79 | go-version: 1.19 80 | 81 | - name: Set up Terraform 82 | uses: hashicorp/setup-terraform@v1 83 | with: 84 | terraform_version: ${{ matrix.terraform }} 85 | terraform_wrapper: false # https://github.com/hashicorp/terraform-plugin-sdk/issues/742 86 | 87 | - name: Checkout 88 | uses: actions/checkout@v2 89 | with: 90 | fetch-depth: 0 91 | 92 | # See https://github.com/kubernetes-sigs/kind/releases/tag/v0.11.1 93 | - name: Determine KinD node image version 94 | id: node_image 95 | run: | 96 | case ${{ matrix.kube }} in 97 | 1.19) 98 | NODE_IMAGE=kindest/node:v1.19.16@sha256:707469aac7e6805e52c3bde2a8a8050ce2b15decff60db6c5077ba9975d28b98 ;; 99 | 1.20) 100 | NODE_IMAGE=kindest/node:v1.20.15@sha256:d67de8f84143adebe80a07672f370365ec7d23f93dc86866f0e29fa29ce026fe ;; 101 | 1.21) 102 | NODE_IMAGE=kindest/node:v1.21.14@sha256:f9b4d3d1112f24a7254d2ee296f177f628f9b4c1b32f0006567af11b91c1f301 ;; 103 | 1.22) 104 | NODE_IMAGE=kindest/node:v1.22.13@sha256:4904eda4d6e64b402169797805b8ec01f50133960ad6c19af45173a27eadf959 ;; 105 | 1.23) 106 | NODE_IMAGE=kindest/node:v1.23.10@sha256:f047448af6a656fae7bc909e2fab360c18c487ef3edc93f06d78cdfd864b2d12 ;; 107 | 1.24) 108 | NODE_IMAGE=kindest/node:v1.24.4@sha256:adfaebada924a26c2c9308edd53c6e33b3d4e453782c0063dc0028bdebaddf98 ;; 109 | 1.25) 110 | NODE_IMAGE=kindest/node:v1.25.0@sha256:428aaa17ec82ccde0131cb2d1ca6547d13cf5fdabcc0bbecf749baa935387cbf ;; 111 | esac 112 | 113 | echo "::set-output name=image::$NODE_IMAGE" 114 | 115 | - name: Create KinD cluster 116 | uses: helm/kind-action@v1.3.0 117 | with: 118 | version: v0.15.0 119 | node_image: ${{ steps.node_image.outputs.image }} 120 | config: hack/kind.yaml 121 | 122 | - name: Configure cluster 123 | run: ./hack/setup-kind.sh 124 | 125 | - name: Test 126 | env: 127 | TF_ACC: 1 128 | run: go test -v ./... 129 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /bin/ 2 | /build/ 3 | /dist/ 4 | /vendor/ 5 | 6 | # IDE integration 7 | /.vscode/* 8 | !/.vscode/launch.json 9 | !/.vscode/tasks.json 10 | /.idea/* 11 | !/.idea/codeStyles/ 12 | !/.idea/copyright/ 13 | !/.idea/dataSources.xml 14 | !/.idea/*.iml 15 | !/.idea/externalDependencies.xml 16 | !/.idea/go.imports.xml 17 | !/.idea/modules.xml 18 | !/.idea/runConfigurations/ 19 | !/.idea/scopes/ 20 | !/.idea/sqldialects.xml 21 | 22 | terraform.tfstate 23 | terraform.tfstate.backup 24 | .terraform/ 25 | crash.log 26 | *.tf 27 | .terraformrc 28 | -------------------------------------------------------------------------------- /.goreleaser.yml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | builds: 5 | - env: 6 | - CGO_ENABLED=0 7 | goos: 8 | - freebsd 9 | - windows 10 | - linux 11 | - darwin 12 | goarch: 13 | - amd64 14 | - '386' 15 | - arm 16 | - arm64 17 | ignore: 18 | - goos: darwin 19 | goarch: '386' 20 | binary: '{{ .ProjectName }}_v{{ .Version }}' 21 | archives: 22 | - format: zip 23 | name_template: '{{ .ProjectName }}_{{ .Version }}_{{ .Os }}_{{ .Arch }}' 24 | checksum: 25 | name_template: '{{ .ProjectName }}_{{ .Version }}_SHA256SUMS' 26 | algorithm: sha256 27 | signs: 28 | - artifacts: checksum 29 | args: 30 | # if you are using this is a GitHub action or some other automated pipeline, you 31 | # need to pass the batch flag to indicate its not interactive. 32 | - "--batch" 33 | - "--local-user" 34 | - "{{ .Env.GPG_FINGERPRINT }}" # set this environment variable for your signing key 35 | - "--output" 36 | - "${signature}" 37 | - "--detach-sign" 38 | - "${artifact}" 39 | release: 40 | # Visit your project's GitHub Releases page to publish this release. 41 | draft: true 42 | changelog: 43 | skip: true 44 | brews: 45 | - tap: 46 | owner: banzaicloud 47 | name: homebrew-tap 48 | folder: Formula 49 | homepage: https://banzaicloud.com/ 50 | description: Kubernetes Terraform provider with support for raw manifests 51 | -------------------------------------------------------------------------------- /.idea/misc.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/terraform-provider-k8s.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2018 Eric Chiang 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | # A Self-Documenting Makefile: http://marmelab.com/blog/2016/02/29/auto-documented-makefile.html 2 | 3 | OS = $(shell uname | tr A-Z a-z) 4 | 5 | BINARY_NAME = terraform-provider-k8s 6 | 7 | # Build variables 8 | BUILD_DIR ?= build 9 | VERSION ?= $(shell git describe --tags --exact-match 2>/dev/null || git symbolic-ref -q --short HEAD) 10 | COMMIT_HASH ?= $(shell git rev-parse --short HEAD 2>/dev/null) 11 | BUILD_DATE ?= $(shell date +%FT%T%z) 12 | LDFLAGS += -X main.version=${VERSION} -X main.commitHash=${COMMIT_HASH} -X main.buildDate=${BUILD_DATE} 13 | export CGO_ENABLED ?= 0 14 | ifeq (${VERBOSE}, 1) 15 | ifeq ($(filter -v,${GOARGS}),) 16 | GOARGS += -v 17 | endif 18 | TEST_FORMAT = short-verbose 19 | endif 20 | 21 | export TF_CLI_CONFIG_FILE = ${PWD}/.terraformrc 22 | 23 | # Dependency versions 24 | GOTESTSUM_VERSION = 0.3.5 25 | GOLANGCI_VERSION = 1.17.1 26 | GORELEASER_VERSION = 0.113.0 27 | TERRAFORM_VERSION = 1.0.6 28 | 29 | GOLANG_VERSION = 1.13 30 | 31 | # Add the ability to override some variables 32 | # Use with care 33 | -include override.mk 34 | 35 | .PHONY: clean 36 | clean: ## Clean builds 37 | rm -rf ${BUILD_DIR}/ 38 | 39 | .PHONY: goversion 40 | goversion: 41 | ifneq (${IGNORE_GOLANG_VERSION_REQ}, 1) 42 | @printf "${GOLANG_VERSION}\n$$(go version | awk '{sub(/^go/, "", $$3);print $$3}')" | sort -t '.' -k 1,1 -k 2,2 -k 3,3 -g | head -1 | grep -q -E "^${GOLANG_VERSION}$$" || (printf "Required Go version is ${GOLANG_VERSION}\nInstalled: `go version`" && exit 1) 43 | endif 44 | 45 | .PHONY: build 46 | build: goversion ## Build a binary 47 | ifeq (${VERBOSE}, 1) 48 | go env 49 | endif 50 | 51 | go build ${GOARGS} -tags "${GOTAGS}" -ldflags "${LDFLAGS}" -o ${BUILD_DIR}/${BINARY_NAME} . 52 | 53 | .PHONY: test-integration 54 | test-integration: EXAMPLE_DIR ?= examples/0.12 55 | test-integration: build bin/terraform .terraformrc ## Execute integration tests 56 | ifneq (,$(findstring 0.12.,${TERRAFORM_VERSION})) 57 | cp build/terraform-provider-k8s . 58 | cp hack/versions012.tf ${EXAMPLE_DIR}/versions.tf 59 | bin/terraform init ${EXAMPLE_DIR} 60 | bin/terraform apply -auto-approve -input=false ${EXAMPLE_DIR} 61 | else ifneq (,$(findstring 0.13.,${TERRAFORM_VERSION})) 62 | cp build/terraform-provider-k8s . 63 | cp hack/versions012.tf ${EXAMPLE_DIR}/versions.tf 64 | bin/terraform init ${EXAMPLE_DIR} 65 | bin/terraform apply -auto-approve -input=false ${EXAMPLE_DIR} 66 | else 67 | mkdir -p build/registry.terraform.io/banzaicloud/k8s/99.99.99/${OS}_amd64 68 | cp build/terraform-provider-k8s build/registry.terraform.io/banzaicloud/k8s/99.99.99/${OS}_amd64/ 69 | cp hack/versions.tf ${EXAMPLE_DIR} 70 | bin/terraform -chdir=${EXAMPLE_DIR} init 71 | bin/terraform -chdir=${EXAMPLE_DIR} apply -auto-approve -input=false 72 | endif 73 | ${MAKE} test-integration-destroy EXAMPLE_DIR=${EXAMPLE_DIR} 74 | 75 | .PHONY: test-integration-destroy 76 | test-integration-destroy: EXAMPLE_DIR ?= examples/0.12 77 | test-integration-destroy: bin/terraform .terraformrc 78 | ifneq (,$(findstring 0.12.,${TERRAFORM_VERSION})) 79 | bin/terraform destroy -auto-approve ${EXAMPLE_DIR} 80 | rm terraform-provider-k8s 81 | else ifneq (,$(findstring 0.13.,${TERRAFORM_VERSION})) 82 | bin/terraform destroy -auto-approve ${EXAMPLE_DIR} 83 | rm terraform-provider-k8s 84 | else 85 | bin/terraform -chdir=${EXAMPLE_DIR} destroy -auto-approve 86 | rm -rf ${EXAMPLE_DIR}/{.terraform,.terraform.lock.hcl} 87 | endif 88 | 89 | bin/terraform: bin/terraform-${TERRAFORM_VERSION} 90 | @ln -sf terraform-${TERRAFORM_VERSION} bin/terraform 91 | bin/terraform-${TERRAFORM_VERSION}: 92 | @mkdir -p bin 93 | ifeq (${OS}, darwin) 94 | curl -sfL https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_darwin_amd64.zip > bin/terraform.zip 95 | endif 96 | ifeq (${OS}, linux) 97 | curl -sfL https://releases.hashicorp.com/terraform/${TERRAFORM_VERSION}/terraform_${TERRAFORM_VERSION}_linux_amd64.zip > bin/terraform.zip 98 | endif 99 | unzip -d bin bin/terraform.zip 100 | @mv bin/terraform $@ 101 | rm bin/terraform.zip 102 | 103 | .terraformrc: 104 | sed "s|PATH|$$PWD/build|" hack/.terraformrc.tpl > .terraformrc 105 | 106 | bin/goreleaser: bin/goreleaser-${GORELEASER_VERSION} 107 | @ln -sf goreleaser-${GORELEASER_VERSION} bin/goreleaser 108 | bin/goreleaser-${GORELEASER_VERSION}: 109 | @mkdir -p bin 110 | curl -sfL https://install.goreleaser.com/github.com/goreleaser/goreleaser.sh | bash -s -- -b ./bin/ v${GORELEASER_VERSION} 111 | @mv bin/goreleaser $@ 112 | 113 | .PHONY: release 114 | release: bin/goreleaser # Publish a release 115 | bin/goreleaser release 116 | 117 | .PHONY: list 118 | list: ## List all make targets 119 | @${MAKE} -pRrn : -f $(MAKEFILE_LIST) 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' | sort 120 | 121 | .PHONY: help 122 | .DEFAULT_GOAL := help 123 | help: 124 | @grep -h -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' 125 | 126 | # Variable outputting/exporting rules 127 | var-%: ; @echo $($*) 128 | varexport-%: ; @echo $*=$($*) 129 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Kubernetes Terraform Provider 2 | 3 | The k8s Terraform provider enables Terraform to deploy Kubernetes resources. Unlike the [official Kubernetes provider][kubernetes-provider] it handles raw manifests, leveraging [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) and the [Unstructured API](https://pkg.go.dev/github.com/kubernetes/apimachinery/pkg/apis/meta/v1/unstructured?tab=doc) directly to allow developers to work with any Kubernetes resource natively. 4 | 5 | This project is a hard fork of [ericchiang/terraform-provider-k8s](https://github.com/ericchiang/terraform-provider-k8s). 6 | 7 | ## Installation 8 | 9 | ### The Go Get way 10 | 11 | Use `go get` to install the provider: 12 | 13 | ``` 14 | go get -u github.com/banzaicloud/terraform-provider-k8s 15 | ``` 16 | 17 | Register the plugin in `~/.terraformrc` (see [Documentation](https://www.terraform.io/docs/commands/cli-config.html) for Windows users): 18 | 19 | ```hcl 20 | providers { 21 | k8s = "/$GOPATH/bin/terraform-provider-k8s" 22 | } 23 | ``` 24 | 25 | ### The Terraform Plugin way (enable versioning) 26 | 27 | Download a release from the [Release page](https://github.com/banzaicloud/terraform-provider-k8s/releases) and make sure the name matches the following convention: 28 | 29 | | OS | Version | Name | 30 | | ------- | ------- | --------------------------------- | 31 | | LINUX | 0.4.0 | terraform-provider-k8s_v0.4.0 | 32 | | | 0.3.0 | terraform-provider-k8s_v0.3.0 | 33 | | Windows | 0.4.0 | terraform-provider-k8s_v0.4.0.exe | 34 | | | 0.3.0 | terraform-provider-k8s_v0.3.0.exe | 35 | 36 | Install the plugin using [Terraform Third-party Plugin Documentation](https://www.terraform.io/docs/configuration/providers.html#third-party-plugins): 37 | 38 | | Operating system | User plugins directory | 39 | | ----------------- | ----------------------------- | 40 | | Windows | %APPDATA%\terraform.d\plugins | 41 | | All other systems | ~/.terraform.d/plugins | 42 | 43 | ## Usage 44 | 45 | The provider uses your default Kubernetes configuration by default, but it takes some optional configuration parameters, see the [Configuration](#configuration) section (these parameters are the same as for the [Kubernetes provider](https://www.terraform.io/docs/providers/kubernetes/index.html#authentication)). 46 | 47 | ```hcl 48 | terraform { 49 | required_providers { 50 | k8s = { 51 | version = ">= 0.8.0" 52 | source = "banzaicloud/k8s" 53 | } 54 | } 55 | } 56 | 57 | provider "k8s" { 58 | config_context = "prod-cluster" 59 | } 60 | ``` 61 | 62 | The `k8s` Terraform provider introduces a single Terraform resource, a `k8s_manifest`. The resource contains a `content` field, which contains a raw manifest in JSON or YAML format. 63 | 64 | ```hcl 65 | variable "replicas" { 66 | type = "string" 67 | default = 3 68 | } 69 | 70 | data "template_file" "nginx-deployment" { 71 | template = "${file("manifests/nginx-deployment.yaml")}" 72 | 73 | vars { 74 | replicas = "${var.replicas}" 75 | } 76 | } 77 | 78 | resource "k8s_manifest" "nginx-deployment" { 79 | content = "${data.template_file.nginx-deployment.rendered}" 80 | } 81 | 82 | # creating a second resource in the nginx namespace 83 | resource "k8s_manifest" "nginx-deployment" { 84 | content = "${data.template_file.nginx-deployment.rendered}" 85 | namespace = "nginx" 86 | } 87 | ``` 88 | 89 | In this case `manifests/nginx-deployment.yaml` is a templated deployment manifest. 90 | 91 | ```yaml 92 | apiVersion: apps/v1beta2 93 | kind: Deployment 94 | metadata: 95 | name: nginx-deployment 96 | labels: 97 | app: nginx 98 | spec: 99 | replicas: ${replicas} 100 | selector: 101 | matchLabels: 102 | app: nginx 103 | template: 104 | metadata: 105 | labels: 106 | app: nginx 107 | spec: 108 | containers: 109 | - name: nginx 110 | image: nginx:1.7.9 111 | ports: 112 | - containerPort: 80 113 | ``` 114 | 115 | The Kubernetes resources can then be managed through Terraform. 116 | 117 | ```terminal 118 | $ terraform apply 119 | # ... 120 | Apply complete! Resources: 1 added, 1 changed, 0 destroyed. 121 | $ kubectl get deployments 122 | NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE 123 | nginx-deployment 3 3 3 3 1m 124 | $ terraform apply -var 'replicas=5' 125 | # ... 126 | Apply complete! Resources: 0 added, 1 changed, 0 destroyed. 127 | $ kubectl get deployments 128 | NAME DESIRED CURRENT UP-TO-DATE AVAILABLE AGE 129 | nginx-deployment 5 5 5 3 3m 130 | $ terraform destroy -force 131 | # ... 132 | Destroy complete! Resources: 2 destroyed. 133 | $ kubectl get deployments 134 | No resources found. 135 | ``` 136 | 137 | **NOTE**: If the YAML formatted `content` contains multiple documents (separated by `---`) only the first non-empty document is going to be parsed. This is because Terraform is mostly designed to represent a single resource on the provider side with a Terraform resource: 138 | 139 | > resource types correspond to an infrastructure object type that is managed via a remote network API 140 | > -- [Terraform Documentation](https://www.terraform.io/docs/configuration/resources.html) 141 | 142 | You can workaround this easily with the following snippet (however we still suggest to use separate resources): 143 | 144 | ```hcl 145 | locals { 146 | resources = split("\n---\n", data.template_file.nginx.rendered) 147 | } 148 | 149 | resource "k8s_manifest" "nginx-deployment" { 150 | count = length(local.resources) 151 | 152 | content = local.resources[count.index] 153 | } 154 | ``` 155 | 156 | ## Helm workflow 157 | 158 | #### Requirements 159 | 160 | - Helm 2 or Helm 3 161 | 162 | Get a versioned chart into your source code and render it 163 | 164 | ##### Helm 2 165 | 166 | ``` shell 167 | helm fetch stable/nginx-ingress --version 1.24.4 --untardir charts --untar 168 | helm template --namespace nginx-ingress .\charts\nginx-ingress --output-dir manifests/ 169 | ``` 170 | 171 | ##### Helm 3 172 | 173 | ``` shell 174 | helm pull stable/nginx-ingress --version 1.24.4 --untardir charts --untar 175 | helm template --namespace nginx-ingress nginx-ingress .\charts\nginx-ingress --output-dir manifests/ 176 | ``` 177 | 178 | Apply the `main.tf` with the k8s provider 179 | 180 | ```hcl2 181 | # terraform 0.12.x 182 | locals { 183 | nginx-ingress_files = fileset(path.module, "manifests/nginx-ingress/templates/*.yaml") 184 | } 185 | 186 | data "local_file" "nginx-ingress_files_content" { 187 | for_each = local.nginx-ingress_files 188 | filename = each.value 189 | } 190 | 191 | resource "k8s_manifest" "nginx-ingress" { 192 | for_each = data.local_file.nginx-ingress_files_content 193 | content = each.value.content 194 | namespace = "nginx" 195 | } 196 | ``` 197 | 198 | ## Configuration 199 | 200 | There are generally two ways to configure the Kubernetes provider. 201 | 202 | ### File config 203 | 204 | The provider always first tries to load **a config file** from a given 205 | (or default) location. Depending on whether you have current context set 206 | this _may_ require `config_context_auth_info` and/or `config_context_cluster` 207 | and/or `config_context`. 208 | 209 | #### Setting default config context 210 | 211 | Here's an example for how to set default context and avoid all provider configuration: 212 | 213 | ``` 214 | kubectl config set-context default-system \ 215 | --cluster=chosen-cluster \ 216 | --user=chosen-user 217 | 218 | kubectl config use-context default-system 219 | ``` 220 | 221 | Read [more about `kubectl` in the official docs](https://kubernetes.io/docs/user-guide/kubectl-overview/). 222 | 223 | ### In-cluster service account token 224 | 225 | If no other configuration is specified, and when it detects it is running in a kubernetes pod, 226 | the provider will try to use the service account token from the `/var/run/secrets/kubernetes.io/serviceaccount/token` path. 227 | Detection of in-cluster execution is based on the sole availability both of the `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` environment variables, 228 | with non empty values. 229 | 230 | ```hcl 231 | provider "k8s" { 232 | load_config_file = "false" 233 | } 234 | ``` 235 | 236 | If you have any other static configuration setting specified in a config file or static configuration, in-cluster service account token will not be tried. 237 | 238 | ### Statically defined credentials 239 | 240 | An other way is **statically** define TLS certificate credentials: 241 | 242 | ```hcl 243 | provider "k8s" { 244 | load_config_file = "false" 245 | 246 | host = "https://104.196.242.174" 247 | 248 | client_certificate = "${file("~/.kube/client-cert.pem")}" 249 | client_key = "${file("~/.kube/client-key.pem")}" 250 | cluster_ca_certificate = "${file("~/.kube/cluster-ca-cert.pem")}" 251 | } 252 | ``` 253 | 254 | or username and password (HTTP Basic Authorization): 255 | 256 | ```hcl 257 | provider "k8s" { 258 | load_config_file = "false" 259 | 260 | host = "https://104.196.242.174" 261 | 262 | username = "username" 263 | password = "password" 264 | } 265 | ``` 266 | 267 | 268 | If you have **both** valid configuration in a config file and static configuration, the static one is used as override. 269 | i.e. any static field will override its counterpart loaded from the config. 270 | 271 | ## Argument Reference 272 | 273 | The following arguments are supported: 274 | 275 | * `host` - (Optional) The hostname (in form of URI) of Kubernetes master. Can be sourced from `KUBE_HOST`. 276 | * `username` - (Optional) The username to use for HTTP basic authentication when accessing the Kubernetes master endpoint. Can be sourced from `KUBE_USER`. 277 | * `password` - (Optional) The password to use for HTTP basic authentication when accessing the Kubernetes master endpoint. Can be sourced from `KUBE_PASSWORD`. 278 | * `insecure` - (Optional) Whether server should be accessed without verifying the TLS certificate. Can be sourced from `KUBE_INSECURE`. Defaults to `false`. 279 | * `client_certificate` - (Optional) PEM-encoded client certificate for TLS authentication. Can be sourced from `KUBE_CLIENT_CERT_DATA`. 280 | * `client_key` - (Optional) PEM-encoded client certificate key for TLS authentication. Can be sourced from `KUBE_CLIENT_KEY_DATA`. 281 | * `cluster_ca_certificate` - (Optional) PEM-encoded root certificates bundle for TLS authentication. Can be sourced from `KUBE_CLUSTER_CA_CERT_DATA`. 282 | * `config_path` - (Optional) Path to the kube config file. Can be sourced from `KUBE_CONFIG` or `KUBECONFIG`. Defaults to `~/.kube/config`. 283 | * `config_context` - (Optional) Context to choose from the config file. Can be sourced from `KUBE_CTX`. 284 | * `config_context_auth_info` - (Optional) Authentication info context of the kube config (name of the kubeconfig user, `--user` flag in `kubectl`). Can be sourced from `KUBE_CTX_AUTH_INFO`. 285 | * `config_context_cluster` - (Optional) Cluster context of the kube config (name of the kubeconfig cluster, `--cluster` flag in `kubectl`). Can be sourced from `KUBE_CTX_CLUSTER`. 286 | * `token` - (Optional) Token of your service account. Can be sourced from `KUBE_TOKEN`. 287 | * `load_config_file` - (Optional) By default the local config (~/.kube/config) is loaded when you use this provider. This option at false disables this behaviour which is desired when statically specifying the configuration or relying on in-cluster config. Can be sourced from `KUBE_LOAD_CONFIG_FILE`. 288 | * `exec` - (Optional) Configuration block to use an [exec-based credential plugin] (https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins), e.g. call an external command to receive user credentials. 289 | * `api_version` - (Required) API version to use when decoding the ExecCredentials resource, e.g. `client.authentication.k8s.io/v1beta1`. 290 | * `command` - (Required) Command to execute. 291 | * `args` - (Optional) List of arguments to pass when executing the plugin. 292 | * `env` - (Optional) Map of environment variables to set when executing the plugin. 293 | 294 | ## Release 295 | 296 | ```bash 297 | gpg --fingerprint $MY_EMAIL 298 | export GPG_FINGERPRINT="THEF FING ERPR INTO OFTH EPUB LICK EYOF YOU!" 299 | goreleaser release --rm-dist -p 2 300 | ``` 301 | 302 | ## Testing 303 | 304 | Create a [kind](https://kind.sigs.k8s.io) cluster with the attached configuration file: 305 | 306 | ```bash 307 | kind create cluster --config hack/kind.yaml 308 | ``` 309 | 310 | Once the cluster is running, run the setup script: 311 | 312 | ```bash 313 | ./hack/setup-kind.sh 314 | ``` 315 | 316 | Finally, run the integration test suite: 317 | 318 | ```bash 319 | make EXAMPLE_DIR=test/terraform test-integration 320 | ``` 321 | 322 | [kubernetes-provider]: https://www.terraform.io/docs/providers/kubernetes/index.html 323 | -------------------------------------------------------------------------------- /docs/index.md: -------------------------------------------------------------------------------- 1 | # k8s Provider 2 | 3 | The k8s Terraform provider enables Terraform to deploy Kubernetes resources. Unlike the [official Kubernetes provider][kubernetes-provider] it handles raw manifests, leveraging [controller-runtime](https://github.com/kubernetes-sigs/controller-runtime) and the [Unstructured API](https://pkg.go.dev/github.com/kubernetes/apimachinery/pkg/apis/meta/v1/unstructured?tab=doc) directly to allow developers to work with any Kubernetes resource natively. 4 | 5 | This project is a hard fork of [ericchiang/terraform-provider-k8s](https://github.com/ericchiang/terraform-provider-k8s). 6 | 7 | Use the navigation to the left to read about the available resources. 8 | 9 | ## Example Usage 10 | 11 | ```hcl 12 | terraform { 13 | required_providers { 14 | k8s = { 15 | version = ">= 0.8.0" 16 | source = "banzaicloud/k8s" 17 | } 18 | } 19 | } 20 | 21 | provider "k8s" { 22 | config_context = "prod-cluster" 23 | } 24 | ``` 25 | 26 | ## Stacking with managed Kubernetes cluster resources 27 | 28 | Terraform providers for various cloud providers feature resources to spin up managed Kubernetes clusters on services such as EKS, AKS and GKE. Such resources (or data-sources) will have attributes that expose the credentials needed for the Kubernetes provider to connect to these clusters. 29 | 30 | To use these credentials with the Kubernetes provider, they can be interpolated into the respective attributes of the Kubernetes provider configuration block. 31 | 32 | **IMPORTANT WARNING** 33 | *When using interpolation to pass credentials to the Kubernetes provider from other resources, these resources SHOULD NOT be created in the same `apply` operation where Kubernetes provider resources are also used. This will lead to intermittent and unpredictable errors which are hard to debug and diagnose. The root issue lies with the order in which Terraform itself evaluates the provider blocks vs. actual resources. Please refer to [this section of Terraform docs](https://www.terraform.io/docs/configuration/providers.html#provider-configuration) for further explanation.* 34 | 35 | The best-practice in this case is to ensure that the cluster itself and the Kubernetes provider resources are managed with separate `apply` operations. Data-sources can be used to convey values between the two stages as needed. 36 | 37 | ## Authentication 38 | 39 | There are generally two ways to configure the Kubernetes provider. 40 | 41 | ### File config 42 | 43 | The provider always first tries to load **a config file** from a given 44 | (or default) location. Depending on whether you have current context set 45 | this _may_ require `config_context_auth_info` and/or `config_context_cluster` 46 | and/or `config_context`. 47 | 48 | #### Setting default config context 49 | 50 | Here's an example for how to set default context and avoid all provider configuration: 51 | 52 | ``` 53 | kubectl config set-context default-system \ 54 | --cluster=chosen-cluster \ 55 | --user=chosen-user 56 | 57 | kubectl config use-context default-system 58 | ``` 59 | 60 | Read [more about `kubectl` in the official docs](https://kubernetes.io/docs/user-guide/kubectl-overview/). 61 | 62 | ### In-cluster service account token 63 | 64 | If no other configuration is specified, and when it detects it is running in a kubernetes pod, 65 | the provider will try to use the service account token from the `/var/run/secrets/kubernetes.io/serviceaccount/token` path. 66 | Detection of in-cluster execution is based on the sole availability both of the `KUBERNETES_SERVICE_HOST` and `KUBERNETES_SERVICE_PORT` environment variables, 67 | with non empty values. 68 | 69 | ```hcl 70 | provider "kubernetes" { 71 | load_config_file = "false" 72 | } 73 | ``` 74 | 75 | If you have any other static configuration setting specified in a config file or static configuration, in-cluster service account token will not be tried. 76 | 77 | ### Statically defined credentials 78 | 79 | An other way is **statically** define TLS certificate credentials: 80 | 81 | ```hcl 82 | provider "kubernetes" { 83 | load_config_file = "false" 84 | 85 | host = "https://104.196.242.174" 86 | 87 | client_certificate = "${file("~/.kube/client-cert.pem")}" 88 | client_key = "${file("~/.kube/client-key.pem")}" 89 | cluster_ca_certificate = "${file("~/.kube/cluster-ca-cert.pem")}" 90 | } 91 | ``` 92 | 93 | or username and password (HTTP Basic Authorization): 94 | 95 | ```hcl 96 | provider "kubernetes" { 97 | load_config_file = "false" 98 | 99 | host = "https://104.196.242.174" 100 | 101 | username = "username" 102 | password = "password" 103 | } 104 | ``` 105 | 106 | 107 | If you have **both** valid configuration in a config file and static configuration, the static one is used as override. 108 | i.e. any static field will override its counterpart loaded from the config. 109 | 110 | ## Argument Reference 111 | 112 | The following arguments are supported: 113 | 114 | * `host` - (Optional) The hostname (in form of URI) of Kubernetes master. Can be sourced from `KUBE_HOST`. 115 | * `username` - (Optional) The username to use for HTTP basic authentication when accessing the Kubernetes master endpoint. Can be sourced from `KUBE_USER`. 116 | * `password` - (Optional) The password to use for HTTP basic authentication when accessing the Kubernetes master endpoint. Can be sourced from `KUBE_PASSWORD`. 117 | * `insecure` - (Optional) Whether server should be accessed without verifying the TLS certificate. Can be sourced from `KUBE_INSECURE`. Defaults to `false`. 118 | * `client_certificate` - (Optional) PEM-encoded client certificate for TLS authentication. Can be sourced from `KUBE_CLIENT_CERT_DATA`. 119 | * `client_key` - (Optional) PEM-encoded client certificate key for TLS authentication. Can be sourced from `KUBE_CLIENT_KEY_DATA`. 120 | * `cluster_ca_certificate` - (Optional) PEM-encoded root certificates bundle for TLS authentication. Can be sourced from `KUBE_CLUSTER_CA_CERT_DATA`. 121 | * `config_path` - (Optional) Path to the kube config file. Can be sourced from `KUBE_CONFIG` or `KUBECONFIG`. Defaults to `~/.kube/config`. 122 | * `config_context` - (Optional) Context to choose from the config file. Can be sourced from `KUBE_CTX`. 123 | * `config_context_auth_info` - (Optional) Authentication info context of the kube config (name of the kubeconfig user, `--user` flag in `kubectl`). Can be sourced from `KUBE_CTX_AUTH_INFO`. 124 | * `config_context_cluster` - (Optional) Cluster context of the kube config (name of the kubeconfig cluster, `--cluster` flag in `kubectl`). Can be sourced from `KUBE_CTX_CLUSTER`. 125 | * `token` - (Optional) Token of your service account. Can be sourced from `KUBE_TOKEN`. 126 | * `load_config_file` - (Optional) By default the local config (~/.kube/config) is loaded when you use this provider. This option at false disables this behaviour which is desired when statically specifying the configuration or relying on in-cluster config. Can be sourced from `KUBE_LOAD_CONFIG_FILE`. 127 | * `exec` - (Optional) Configuration block to use an [exec-based credential plugin] (https://kubernetes.io/docs/reference/access-authn-authz/authentication/#client-go-credential-plugins), e.g. call an external command to receive user credentials. 128 | * `api_version` - (Required) API version to use when decoding the ExecCredentials resource, e.g. `client.authentication.k8s.io/v1beta1`. 129 | * `command` - (Required) Command to execute. 130 | * `args` - (Optional) List of arguments to pass when executing the plugin. 131 | * `env` - (Optional) Map of environment variables to set when executing the plugin. 132 | -------------------------------------------------------------------------------- /docs/resources/manifest.md: -------------------------------------------------------------------------------- 1 | # k8s_manifest Resource 2 | 3 | The `k8s_manifest` resource applies any kind of Kubernetes resources against the Kubernetes API server and waits until it gets provisioned (not supported for CRDs). 4 | 5 | ## Example Usage 6 | 7 | ```hcl 8 | provider "k8s" { 9 | config_context = "prod-cluster" 10 | } 11 | 12 | data "template_file" "nginx-deployment" { 13 | template = "${file("manifests/nginx-deployment.yaml")}" 14 | 15 | vars { 16 | replicas = "${var.replicas}" 17 | } 18 | } 19 | 20 | resource "k8s_manifest" "nginx-deployment" { 21 | content = "${data.template_file.nginx-deployment.rendered}" 22 | } 23 | 24 | # creating a second resource in the nginx namespace 25 | resource "k8s_manifest" "nginx-deployment" { 26 | content = "${data.template_file.nginx-deployment.rendered}" 27 | namespace = "nginx" 28 | } 29 | ``` 30 | 31 | **NOTE**: If the YAML formatted `content` contains multiple documents (separated by `---`) only the first non-empty document is going to be parsed. This is because Terraform is mostly designed to represent a single resource on the provider side with a Terraform resource: 32 | 33 | > resource types correspond to an infrastructure object type that is managed via a remote network API 34 | > -- [Terraform Documentation](https://www.terraform.io/docs/configuration/resources.html) 35 | 36 | You can workaround this easily with the following snippet (however we still suggest to use separate resources): 37 | 38 | ```hcl 39 | locals { 40 | resources = split("\n---\n", data.template_file.ngnix.rendered) 41 | } 42 | 43 | resource "k8s_manifest" "nginx-deployment" { 44 | count = length(local.resources) 45 | 46 | content = local.resources[count.index] 47 | } 48 | ``` 49 | 50 | ## Argument Reference 51 | 52 | The following arguments are supported: 53 | 54 | * `content` - (Required) Content defines the specification of manifest in YAML (or JSON format). 55 | * `namespace` - (Optional) Namespace defines the namespace of the resource to be created if not defined in the `content`. 56 | * `ignore_field` - (Optional) List of jq style path you don't want to update in your manifest. 57 | 58 | 59 | ## Import 60 | 61 | A resource can be imported using the namespace, groupVersion, kind, and name, e.g. 62 | 63 | ``` 64 | $ terraform import k8s_manifest.example namespace::groupVersion::kind::name 65 | ``` 66 | -------------------------------------------------------------------------------- /examples/.gitignore: -------------------------------------------------------------------------------- 1 | terraform.tfstate 2 | terraform.tfstate.backup 3 | .terraform/ 4 | terraform-provider-k8s 5 | -------------------------------------------------------------------------------- /examples/0.12/example.tf: -------------------------------------------------------------------------------- 1 | data "template_file" "my-configmap" { 2 | template = file("${path.module}/../manifests/my-configmap.yaml") 3 | 4 | vars = { 5 | greeting = var.greeting 6 | } 7 | } 8 | 9 | resource "k8s_manifest" "my-configmap" { 10 | content = data.template_file.my-configmap.rendered 11 | } 12 | 13 | data "template_file" "nginx-deployment" { 14 | template = file("${path.module}/../manifests/nginx-deployment.yaml") 15 | 16 | vars = { 17 | replicas = var.replicas 18 | } 19 | } 20 | 21 | resource "k8s_manifest" "nginx-deployment" { 22 | content = data.template_file.nginx-deployment.rendered 23 | } 24 | 25 | data "template_file" "nginx-namespace" { 26 | template = file("${path.module}/../manifests/nginx-namespace.yaml") 27 | } 28 | 29 | resource "k8s_manifest" "nginx-namespace" { 30 | content = data.template_file.nginx-namespace.rendered 31 | namespace = "kube-system" 32 | } 33 | 34 | resource "k8s_manifest" "nginx-deployment-with-namespace" { 35 | content = data.template_file.nginx-deployment.rendered 36 | namespace = "nginx" 37 | } 38 | 39 | data "template_file" "nginx-service" { 40 | template = file("${path.module}/../manifests/nginx-service.yaml") 41 | } 42 | 43 | resource "k8s_manifest" "nginx-service" { 44 | content = data.template_file.nginx-service.rendered 45 | namespace = "nginx" 46 | } 47 | 48 | data "template_file" "nginx-ingress" { 49 | template = file("${path.module}/../manifests/nginx-ingress.yaml") 50 | } 51 | 52 | resource "k8s_manifest" "nginx-ingress" { 53 | content = data.template_file.nginx-ingress.rendered 54 | namespace = "nginx" 55 | } 56 | 57 | data "template_file" "nginx-pvc" { 58 | template = file("${path.module}/../manifests/nginx-pvc.yaml") 59 | } 60 | 61 | resource "k8s_manifest" "nginx-pvc" { 62 | content = data.template_file.nginx-pvc.rendered 63 | namespace = "nginx" 64 | } 65 | 66 | resource "k8s_manifest" "crontab-crd" { 67 | content = file("${path.module}/../manifests/crontab-crd.yaml") 68 | } 69 | 70 | resource "k8s_manifest" "crontab-resource" { 71 | content = file("${path.module}/../manifests/crontab-resource.yaml") 72 | namespace = "nginx" 73 | depends_on = [k8s_manifest.crontab-crd] 74 | } 75 | -------------------------------------------------------------------------------- /examples/0.12/variables.tf: -------------------------------------------------------------------------------- 1 | variable "greeting" { 2 | type = string 3 | default = "Hello, world!" 4 | } 5 | 6 | variable "replicas" { 7 | type = string 8 | default = 3 9 | } 10 | 11 | -------------------------------------------------------------------------------- /examples/0.12/versions.tf: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | required_version = ">= 0.12" 4 | } 5 | -------------------------------------------------------------------------------- /examples/fail/example.tf: -------------------------------------------------------------------------------- 1 | 2 | resource "k8s_manifest" "nginx-deployment" { 3 | content = <<-EOT 4 | apiVersion: apps/v1 5 | kind: Deployment 6 | metadata: 7 | name: nginx-deployment 8 | labels: 9 | app: nginx 10 | spec: 11 | replicas: 2 12 | selector: 13 | matchLabels: 14 | app: nginx 15 | template: 16 | metadata: 17 | labels: 18 | app: nginx 19 | spec: 20 | containers: 21 | - name: nginx 22 | image: nginx:1.7.9 23 | ports: 24 | - containerPort: 80 25 | command: ["/bin/false"] 26 | EOT 27 | timeouts { 28 | create = "20s" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /examples/fail/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12" 3 | } 4 | -------------------------------------------------------------------------------- /examples/manifests/crontab-crd.yaml: -------------------------------------------------------------------------------- 1 | # Deprecated in v1.16 in favor of apiextensions.k8s.io/v1 2 | apiVersion: apiextensions.k8s.io/v1beta1 3 | kind: CustomResourceDefinition 4 | metadata: 5 | # name must match the spec fields below, and be in the form: . 6 | name: crontabs.stable.example.com 7 | spec: 8 | # group name to use for REST API: /apis// 9 | group: stable.example.com 10 | # list of versions supported by this CustomResourceDefinition 11 | versions: 12 | - name: v1 13 | # Each version can be enabled/disabled by Served flag. 14 | served: true 15 | # One and only one version must be marked as the storage version. 16 | storage: true 17 | # either Namespaced or Cluster 18 | scope: Namespaced 19 | names: 20 | # plural name to be used in the URL: /apis/// 21 | plural: crontabs 22 | # singular name to be used as an alias on the CLI and for display 23 | singular: crontab 24 | # kind is normally the CamelCased singular type. Your resource manifests use this. 25 | kind: CronTab 26 | # shortNames allow shorter string to match your resource on the CLI 27 | shortNames: 28 | - ct 29 | preserveUnknownFields: false 30 | validation: 31 | openAPIV3Schema: 32 | type: object 33 | properties: 34 | spec: 35 | type: object 36 | properties: 37 | cronSpec: 38 | type: string 39 | image: 40 | type: string 41 | replicas: 42 | type: integer 43 | -------------------------------------------------------------------------------- /examples/manifests/crontab-resource.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "stable.example.com/v1" 2 | kind: CronTab 3 | metadata: 4 | name: my-new-cron-object 5 | spec: 6 | cronSpec: "* * * * */5" 7 | image: my-awesome-cron-image 8 | -------------------------------------------------------------------------------- /examples/manifests/my-configmap.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | kind: ConfigMap 4 | apiVersion: v1 5 | metadata: 6 | name: my-configmap 7 | namespace: default 8 | data: 9 | greeting: ${greeting} 10 | -------------------------------------------------------------------------------- /examples/manifests/nginx-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: nginx-deployment 5 | labels: 6 | app: nginx 7 | spec: 8 | replicas: ${replicas} 9 | selector: 10 | matchLabels: 11 | app: nginx 12 | template: 13 | metadata: 14 | labels: 15 | app: nginx 16 | spec: 17 | containers: 18 | - name: nginx 19 | image: nginx:1.7.9 20 | ports: 21 | - containerPort: 80 22 | -------------------------------------------------------------------------------- /examples/manifests/nginx-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1beta1 2 | kind: Ingress 3 | metadata: 4 | name: nginx-ingress 5 | spec: 6 | rules: 7 | - http: 8 | paths: 9 | - path: / 10 | backend: 11 | serviceName: nginx-service 12 | servicePort: 80 13 | -------------------------------------------------------------------------------- /examples/manifests/nginx-namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: nginx 5 | -------------------------------------------------------------------------------- /examples/manifests/nginx-pvc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: nginx 5 | spec: 6 | accessModes: 7 | - ReadWriteOnce 8 | resources: 9 | requests: 10 | storage: 500Mi 11 | -------------------------------------------------------------------------------- /examples/manifests/nginx-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: nginx-service 5 | spec: 6 | ports: 7 | - name: http 8 | port: 80 9 | protocol: TCP 10 | selector: 11 | app: nginx 12 | type: LoadBalancer 13 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/banzaicloud/terraform-provider-k8s 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/evanphx/json-patch v4.12.0+incompatible 7 | github.com/hashicorp/terraform-plugin-sdk/v2 v2.21.0 8 | github.com/itchyny/gojq v0.12.9 9 | github.com/mitchellh/go-homedir v1.1.0 10 | github.com/mitchellh/mapstructure v1.5.0 11 | github.com/pkg/errors v0.9.1 12 | gopkg.in/yaml.v2 v2.4.0 13 | k8s.io/apimachinery v0.25.0 14 | k8s.io/client-go v0.25.0 15 | k8s.io/kubectl v0.25.0 16 | sigs.k8s.io/controller-runtime v0.13.0 17 | ) 18 | 19 | require ( 20 | cloud.google.com/go v0.104.0 // indirect 21 | cloud.google.com/go/compute v1.9.0 // indirect 22 | cloud.google.com/go/iam v0.4.0 // indirect 23 | cloud.google.com/go/storage v1.26.0 // indirect 24 | github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect 25 | github.com/Azure/go-autorest v14.2.0+incompatible // indirect 26 | github.com/Azure/go-autorest/autorest v0.11.28 // indirect 27 | github.com/Azure/go-autorest/autorest/adal v0.9.21 // indirect 28 | github.com/Azure/go-autorest/autorest/date v0.3.0 // indirect 29 | github.com/Azure/go-autorest/logger v0.2.1 // indirect 30 | github.com/Azure/go-autorest/tracing v0.6.0 // indirect 31 | github.com/MakeNowJust/heredoc v1.0.0 // indirect 32 | github.com/PuerkitoBio/purell v1.2.0 // indirect 33 | github.com/PuerkitoBio/urlesc v0.0.0-20170810143723-de5bf2ad4578 // indirect 34 | github.com/agext/levenshtein v1.2.3 // indirect 35 | github.com/apparentlymart/go-cidr v1.1.0 // indirect 36 | github.com/apparentlymart/go-textseg v1.0.0 // indirect 37 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 38 | github.com/aws/aws-sdk-go v1.44.93 // indirect 39 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect 40 | github.com/chai2010/gettext-go v1.0.2 // indirect 41 | github.com/davecgh/go-spew v1.1.1 // indirect 42 | github.com/emicklei/go-restful/v3 v3.9.0 // indirect 43 | github.com/evanphx/json-patch/v5 v5.6.0 // indirect 44 | github.com/exponent-io/jsonpath v0.0.0-20210407135951-1de76d718b3f // indirect 45 | github.com/fatih/camelcase v1.0.0 // indirect 46 | github.com/fatih/color v1.13.0 // indirect 47 | github.com/form3tech-oss/jwt-go v3.2.5+incompatible // indirect 48 | github.com/go-errors/errors v1.4.2 // indirect 49 | github.com/go-logr/logr v1.2.3 // indirect 50 | github.com/go-openapi/jsonpointer v0.19.5 // indirect 51 | github.com/go-openapi/jsonreference v0.20.0 // indirect 52 | github.com/go-openapi/swag v0.22.3 // indirect 53 | github.com/gogo/protobuf v1.3.2 // indirect 54 | github.com/golang-jwt/jwt/v4 v4.4.2 // indirect 55 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 56 | github.com/golang/protobuf v1.5.2 // indirect 57 | github.com/google/btree v1.1.2 // indirect 58 | github.com/google/gnostic v0.6.9 // indirect 59 | github.com/google/go-cmp v0.5.8 // indirect 60 | github.com/google/gofuzz v1.2.0 // indirect 61 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 // indirect 62 | github.com/google/uuid v1.3.0 // indirect 63 | github.com/googleapis/gax-go/v2 v2.5.1 // indirect 64 | github.com/googleapis/gnostic v0.5.5 // indirect 65 | github.com/gregjones/httpcache v0.0.0-20190611155906-901d90724c79 // indirect 66 | github.com/hashicorp/errwrap v1.1.0 // indirect 67 | github.com/hashicorp/go-checkpoint v0.5.0 // indirect 68 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 69 | github.com/hashicorp/go-cty v1.4.1-0.20200723130312-85980079f637 // indirect 70 | github.com/hashicorp/go-getter v1.6.2 // indirect 71 | github.com/hashicorp/go-hclog v1.3.0 // indirect 72 | github.com/hashicorp/go-multierror v1.1.1 // indirect 73 | github.com/hashicorp/go-plugin v1.4.5 // indirect 74 | github.com/hashicorp/go-safetemp v1.0.0 // indirect 75 | github.com/hashicorp/go-uuid v1.0.3 // indirect 76 | github.com/hashicorp/go-version v1.6.0 // indirect 77 | github.com/hashicorp/hc-install v0.4.0 // indirect 78 | github.com/hashicorp/hcl/v2 v2.14.0 // indirect 79 | github.com/hashicorp/logutils v1.0.0 // indirect 80 | github.com/hashicorp/terraform-exec v0.17.3 // indirect 81 | github.com/hashicorp/terraform-json v0.14.0 // indirect 82 | github.com/hashicorp/terraform-plugin-go v0.14.0 // indirect 83 | github.com/hashicorp/terraform-plugin-log v0.7.0 // indirect 84 | github.com/hashicorp/terraform-registry-address v0.0.0-20220623143253-7d51757b572c // indirect 85 | github.com/hashicorp/terraform-svchost v0.0.0-20200729002733-f050f53b9734 // indirect 86 | github.com/hashicorp/yamux v0.1.1 // indirect 87 | github.com/imdario/mergo v0.3.13 // indirect 88 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 89 | github.com/itchyny/timefmt-go v0.1.4 // indirect 90 | github.com/jmespath/go-jmespath v0.4.0 // indirect 91 | github.com/josharian/intern v1.0.0 // indirect 92 | github.com/json-iterator/go v1.1.12 // indirect 93 | github.com/jstemmer/go-junit-report v1.0.0 // indirect 94 | github.com/klauspost/compress v1.15.9 // indirect 95 | github.com/liggitt/tabwriter v0.0.0-20181228230101-89fcab3d43de // indirect 96 | github.com/mailru/easyjson v0.7.7 // indirect 97 | github.com/mattn/go-colorable v0.1.13 // indirect 98 | github.com/mattn/go-isatty v0.0.16 // indirect 99 | github.com/mitchellh/copystructure v1.2.0 // indirect 100 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 101 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 102 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 103 | github.com/moby/spdystream v0.2.0 // indirect 104 | github.com/moby/term v0.0.0-20220808134915-39b0c02b01ae // indirect 105 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 106 | github.com/modern-go/reflect2 v1.0.2 // indirect 107 | github.com/monochromegane/go-gitignore v0.0.0-20200626010858-205db1a8cc00 // indirect 108 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 109 | github.com/oklog/run v1.1.0 // indirect 110 | github.com/peterbourgon/diskv v2.0.1+incompatible // indirect 111 | github.com/pmezard/go-difflib v1.0.0 // indirect 112 | github.com/russross/blackfriday v1.6.0 // indirect 113 | github.com/spf13/cobra v1.5.0 // indirect 114 | github.com/spf13/pflag v1.0.5 // indirect 115 | github.com/stretchr/testify v1.8.0 // indirect 116 | github.com/ulikunitz/xz v0.5.10 // indirect 117 | github.com/vmihailenco/msgpack v4.0.4+incompatible // indirect 118 | github.com/vmihailenco/msgpack/v4 v4.3.12 // indirect 119 | github.com/vmihailenco/tagparser v0.1.2 // indirect 120 | github.com/xlab/treeprint v1.1.0 // indirect 121 | github.com/zclconf/go-cty v1.11.0 // indirect 122 | go.opencensus.io v0.23.0 // indirect 123 | go.starlark.net v0.0.0-20220817180228-f738f5508c12 // indirect 124 | golang.org/x/crypto v0.0.0-20220829220503-c86fa9a7ed90 // indirect 125 | golang.org/x/lint v0.0.0-20210508222113-6edffad5e616 // indirect 126 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4 // indirect 127 | golang.org/x/net v0.0.0-20220907135653-1e95f45603a7 // indirect 128 | golang.org/x/oauth2 v0.0.0-20220822191816-0ebed06d0094 // indirect 129 | golang.org/x/sys v0.0.0-20220907062415-87db552b00fd // indirect 130 | golang.org/x/term v0.0.0-20220722155259-a9ba230a4035 // indirect 131 | golang.org/x/text v0.3.7 // indirect 132 | golang.org/x/time v0.0.0-20220722155302-e5dcc9cfc0b9 // indirect 133 | golang.org/x/tools v0.1.12 // indirect 134 | golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect 135 | google.golang.org/api v0.95.0 // indirect 136 | google.golang.org/appengine v1.6.7 // indirect 137 | google.golang.org/genproto v0.0.0-20220902135211-223410557253 // indirect 138 | google.golang.org/grpc v1.49.0 // indirect 139 | google.golang.org/protobuf v1.28.1 // indirect 140 | gopkg.in/inf.v0 v0.9.1 // indirect 141 | gopkg.in/yaml.v3 v3.0.1 // indirect 142 | k8s.io/api v0.25.0 // indirect 143 | k8s.io/cli-runtime v0.25.0 // indirect 144 | k8s.io/component-base v0.25.0 // indirect 145 | k8s.io/klog/v2 v2.80.0 // indirect 146 | k8s.io/kube-openapi v0.0.0-20220803164354-a70c9af30aea // indirect 147 | k8s.io/utils v0.0.0-20220823124924-e9cbc92d1a73 // indirect 148 | sigs.k8s.io/json v0.0.0-20220713155537-f223a00ba0e2 // indirect 149 | sigs.k8s.io/kustomize/api v0.12.1 // indirect 150 | sigs.k8s.io/kustomize/kyaml v0.13.9 // indirect 151 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect 152 | sigs.k8s.io/yaml v1.3.0 // indirect 153 | ) 154 | -------------------------------------------------------------------------------- /hack/.editorconfig: -------------------------------------------------------------------------------- 1 | [{*.yml,*.yaml}] 2 | indent_size = 2 3 | -------------------------------------------------------------------------------- /hack/.terraformrc.tpl: -------------------------------------------------------------------------------- 1 | provider_installation { 2 | filesystem_mirror { 3 | path = "PATH" 4 | include = ["registry.terraform.io/banzaicloud/k8s"] 5 | } 6 | direct { 7 | exclude = ["registry.terraform.io/banzaicloud/k8s"] 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /hack/kind.yaml: -------------------------------------------------------------------------------- 1 | kind: Cluster 2 | apiVersion: kind.x-k8s.io/v1alpha4 3 | nodes: 4 | - role: control-plane 5 | kubeadmConfigPatches: 6 | - | 7 | kind: InitConfiguration 8 | nodeRegistration: 9 | kubeletExtraArgs: 10 | node-labels: "ingress-ready=true" 11 | extraPortMappings: 12 | - containerPort: 80 13 | hostPort: 2080 14 | protocol: TCP 15 | - containerPort: 443 16 | hostPort: 20443 17 | protocol: TCP 18 | -------------------------------------------------------------------------------- /hack/metallb-config.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: metallb.io/v1beta1 2 | kind: IPAddressPool 3 | metadata: 4 | name: docker 5 | namespace: metallb-system 6 | spec: 7 | addresses: 8 | - METALLB_IP_ADDRESS_RANGE 9 | --- 10 | apiVersion: metallb.io/v1beta1 11 | kind: L2Advertisement 12 | metadata: 13 | name: empty 14 | namespace: metallb-system 15 | -------------------------------------------------------------------------------- /hack/metallb-webhook-patch.yaml: -------------------------------------------------------------------------------- 1 | webhooks: 2 | - failurePolicy: Ignore 3 | name: bgppeersvalidationwebhook.metallb.io 4 | - failurePolicy: Ignore 5 | name: addresspoolvalidationwebhook.metallb.io 6 | - failurePolicy: Ignore 7 | name: bfdprofilevalidationwebhook.metallb.io 8 | - failurePolicy: Ignore 9 | name: bgpadvertisementvalidationwebhook.metallb.io 10 | - failurePolicy: Ignore 11 | name: communityvalidationwebhook.metallb.io 12 | - failurePolicy: Ignore 13 | name: ipaddresspoolvalidationwebhook.metallb.io 14 | - failurePolicy: Ignore 15 | name: l2advertisementvalidationwebhook.metallb.io 16 | -------------------------------------------------------------------------------- /hack/setup-kind.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Dependencies 4 | # - kind >=0.11 5 | # - k8s >= 1.19 6 | 7 | set -e 8 | 9 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" &> /dev/null && pwd )" 10 | 11 | METALLB_VERSION=v0.13.5 12 | INGRESS_VERSION=controller-v1.2.1 13 | 14 | # Fingers crossed this is at least a /24 range 15 | METALLB_IP_PREFIX_RANGE=$(docker network inspect kind --format '{{(index .IPAM.Config 0).Subnet}}' | sed -r 's/(.*).\/.*/\1/') 16 | METALLB_IP_ADDRESS_RANGE=$(echo "${METALLB_IP_PREFIX_RANGE}200-${METALLB_IP_PREFIX_RANGE}250" | sed "s/\./\\\./g") 17 | 18 | kubectl apply -f https://raw.githubusercontent.com/metallb/metallb/${METALLB_VERSION}/config/manifests/metallb-native.yaml 19 | kubectl wait --namespace metallb-system --for=condition=ready pod --selector=app=metallb --timeout=90s 20 | 21 | # Disable webhooks due to timeout issues 22 | # https://github.com/metallb/metallb/issues/1597 23 | # https://github.com/metallb/metallb/issues/1540 24 | kubectl patch validatingwebhookconfigurations.admissionregistration.k8s.io metallb-webhook-configuration --patch-file ${SCRIPT_DIR}/metallb-webhook-patch.yaml 25 | 26 | sed "s/METALLB_IP_ADDRESS_RANGE/${METALLB_IP_ADDRESS_RANGE}/" "${SCRIPT_DIR}/metallb-config.yaml" | kubectl apply -f - 27 | 28 | kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/${INGRESS_VERSION}/deploy/static/provider/kind/deploy.yaml 29 | kubectl wait --namespace ingress-nginx --for=condition=ready pod --selector=app.kubernetes.io/component=controller --timeout=90s 30 | 31 | # Patch the ingress class to make it defaul 32 | kubectl patch ingressclass nginx -p '{"metadata": {"annotations":{"ingressclass.kubernetes.io/is-default-class": "true"}}}' 33 | -------------------------------------------------------------------------------- /hack/versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12" 3 | 4 | required_providers { 5 | k8s = { 6 | source = "banzaicloud/k8s" 7 | version = ">= 0.0.1" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /hack/versions012.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 0.12" 3 | } 4 | -------------------------------------------------------------------------------- /k8s/helpers.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 8 | ) 9 | 10 | const idSeparator = "::" 11 | 12 | func idParts(id string) (string, string, string, string, error) { 13 | parts := strings.Split(id, idSeparator) 14 | if len(parts) != 4 { 15 | err := fmt.Errorf("unexpected ID format (%q), expected %q.", id, "namespace::groupVersion::kind::name") 16 | return "", "", "", "", err 17 | } 18 | 19 | return parts[0], parts[1], parts[2], parts[3], nil 20 | } 21 | 22 | func buildId(object *unstructured.Unstructured) string { 23 | return strings.Join( 24 | []string{ 25 | object.GetNamespace(), 26 | object.GroupVersionKind().GroupVersion().String(), 27 | object.GroupVersionKind().Kind, 28 | object.GetName(), 29 | }, 30 | idSeparator, 31 | ) 32 | } 33 | 34 | func expandStringSlice(s []interface{}) []string { 35 | result := make([]string, len(s), len(s)) 36 | for k, v := range s { 37 | // Handle the Terraform parser bug which turns empty strings in lists to nil. 38 | if v == nil { 39 | result[k] = "" 40 | } else { 41 | result[k] = v.(string) 42 | } 43 | } 44 | return result 45 | } 46 | -------------------------------------------------------------------------------- /k8s/patch.go: -------------------------------------------------------------------------------- 1 | // Copyright The Helm Authors. 2 | // Licensed under the Apache License, Version 2.0 (the "License"); 3 | // you may not use this file except in compliance with the License. 4 | // You may obtain a copy of the License at 5 | // http://www.apache.org/licenses/LICENSE-2.0 6 | // Unless required by applicable law or agreed to in writing, software 7 | // distributed under the License is distributed on an "AS IS" BASIS, 8 | // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 9 | // See the License for the specific language governing permissions and 10 | // limitations under the License. 11 | 12 | // Adapted from https://github.com/helm/helm/blob/master/pkg/kube/client.go 13 | // and https://github.com/helm/helm/blob/master/pkg/kube/converter.go 14 | 15 | package k8s 16 | 17 | import ( 18 | "context" 19 | "log" 20 | "sync" 21 | 22 | jsonpatch "github.com/evanphx/json-patch" 23 | "github.com/pkg/errors" 24 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 25 | "k8s.io/apimachinery/pkg/runtime" 26 | "k8s.io/apimachinery/pkg/types" 27 | "k8s.io/apimachinery/pkg/util/json" 28 | "k8s.io/apimachinery/pkg/util/strategicpatch" 29 | "k8s.io/client-go/kubernetes/scheme" 30 | "sigs.k8s.io/controller-runtime/pkg/client" 31 | ) 32 | 33 | var k8sNativeScheme *runtime.Scheme 34 | var k8sNativeSchemeOnce sync.Once 35 | 36 | func patch(c client.Client, target, original, current *unstructured.Unstructured) error { 37 | patch, patchType, err := createPatch(target, original, current) 38 | if err != nil { 39 | return errors.Wrap(err, "failed to create patch") 40 | } 41 | 42 | if patch == nil || string(patch) == "{}" { 43 | log.Printf("Looks like there are no changes for %s %q", target.GroupVersionKind().String(), target.GetName()) 44 | return nil 45 | } 46 | 47 | // send patch to server 48 | if err = c.Patch(context.TODO(), current, client.RawPatch(patchType, patch)); err != nil { 49 | return errors.Wrapf(err, "cannot patch %q with kind %s", target.GroupVersionKind().String(), target.GetName()) 50 | } 51 | return nil 52 | } 53 | 54 | func createPatch(target, original, current *unstructured.Unstructured) ([]byte, types.PatchType, error) { 55 | oldData, err := json.Marshal(current) 56 | if err != nil { 57 | return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing current configuration") 58 | } 59 | originalData, err := json.Marshal(original) 60 | if err != nil { 61 | return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing original configuration") 62 | } 63 | newData, err := json.Marshal(target) 64 | if err != nil { 65 | return nil, types.StrategicMergePatchType, errors.Wrap(err, "serializing target configuration") 66 | } 67 | 68 | versionedObject := convert(target) 69 | 70 | // Unstructured objects, such as CRDs, may not have an not registered error 71 | // returned from ConvertToVersion. Anything that's unstructured should 72 | // use the jsonpatch.CreateMergePatch. Strategic Merge Patch is not supported 73 | // on objects like CRDs. 74 | _, isUnstructured := versionedObject.(runtime.Unstructured) 75 | 76 | if isUnstructured { 77 | // fall back to generic JSON merge patch 78 | patch, err := jsonpatch.CreateMergePatch(oldData, newData) 79 | return patch, types.MergePatchType, err 80 | } 81 | 82 | patchMeta, err := strategicpatch.NewPatchMetaFromStruct(versionedObject) 83 | if err != nil { 84 | return nil, types.StrategicMergePatchType, errors.Wrap(err, "unable to create patch metadata from object") 85 | } 86 | 87 | patch, err := strategicpatch.CreateThreeWayMergePatch(originalData, newData, oldData, patchMeta, true) 88 | return patch, types.StrategicMergePatchType, err 89 | } 90 | 91 | func convert(obj *unstructured.Unstructured) runtime.Object { 92 | s := kubernetesNativeScheme() 93 | if obj, err := runtime.ObjectConvertor(s).ConvertToVersion(obj, obj.GroupVersionKind().GroupVersion()); err == nil { 94 | return obj 95 | } 96 | return obj 97 | } 98 | 99 | // kubernetesNativeScheme returns a clean *runtime.Scheme with _only_ Kubernetes 100 | // native resources added to it. This is required to break free of custom resources 101 | // that may have been added to scheme.Scheme due to Helm being used as a package in 102 | // combination with e.g. a versioned kube client. If we would not do this, the client 103 | // may attempt to perform e.g. a 3-way-merge strategy patch for custom resources. 104 | func kubernetesNativeScheme() *runtime.Scheme { 105 | k8sNativeSchemeOnce.Do(func() { 106 | k8sNativeScheme = runtime.NewScheme() 107 | scheme.AddToScheme(k8sNativeScheme) 108 | }) 109 | return k8sNativeScheme 110 | } 111 | -------------------------------------------------------------------------------- /k8s/provider.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "log" 7 | "net/http" 8 | "os" 9 | 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/logging" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 12 | "github.com/mitchellh/go-homedir" 13 | _ "k8s.io/client-go/plugin/pkg/client/auth" // to import all provider auths 14 | restclient "k8s.io/client-go/rest" 15 | "k8s.io/client-go/tools/clientcmd" 16 | clientcmdapi "k8s.io/client-go/tools/clientcmd/api" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | "sigs.k8s.io/controller-runtime/pkg/client/apiutil" 19 | ) 20 | 21 | func Provider() *schema.Provider { 22 | p := &schema.Provider{ 23 | Schema: map[string]*schema.Schema{ 24 | "host": { 25 | Type: schema.TypeString, 26 | Optional: true, 27 | DefaultFunc: schema.EnvDefaultFunc("KUBE_HOST", ""), 28 | Description: "The hostname (in form of URI) of Kubernetes master.", 29 | }, 30 | "username": { 31 | Type: schema.TypeString, 32 | Optional: true, 33 | DefaultFunc: schema.EnvDefaultFunc("KUBE_USER", ""), 34 | Description: "The username to use for HTTP basic authentication when accessing the Kubernetes master endpoint.", 35 | }, 36 | "password": { 37 | Type: schema.TypeString, 38 | Optional: true, 39 | DefaultFunc: schema.EnvDefaultFunc("KUBE_PASSWORD", ""), 40 | Description: "The password to use for HTTP basic authentication when accessing the Kubernetes master endpoint.", 41 | }, 42 | "insecure": { 43 | Type: schema.TypeBool, 44 | Optional: true, 45 | DefaultFunc: schema.EnvDefaultFunc("KUBE_INSECURE", false), 46 | Description: "Whether server should be accessed without verifying the TLS certificate.", 47 | }, 48 | "client_certificate": { 49 | Type: schema.TypeString, 50 | Optional: true, 51 | DefaultFunc: schema.EnvDefaultFunc("KUBE_CLIENT_CERT_DATA", ""), 52 | Description: "PEM-encoded client certificate for TLS authentication.", 53 | }, 54 | "client_key": { 55 | Type: schema.TypeString, 56 | Optional: true, 57 | DefaultFunc: schema.EnvDefaultFunc("KUBE_CLIENT_KEY_DATA", ""), 58 | Description: "PEM-encoded client certificate key for TLS authentication.", 59 | }, 60 | "cluster_ca_certificate": { 61 | Type: schema.TypeString, 62 | Optional: true, 63 | DefaultFunc: schema.EnvDefaultFunc("KUBE_CLUSTER_CA_CERT_DATA", ""), 64 | Description: "PEM-encoded root certificates bundle for TLS authentication.", 65 | }, 66 | "config_path": { 67 | Type: schema.TypeString, 68 | Optional: true, 69 | DefaultFunc: schema.MultiEnvDefaultFunc( 70 | []string{ 71 | "KUBE_CONFIG", 72 | "KUBECONFIG", 73 | }, 74 | "~/.kube/config"), 75 | Description: "Path to the kube config file, defaults to ~/.kube/config", 76 | }, 77 | "config_context": { 78 | Type: schema.TypeString, 79 | Optional: true, 80 | DefaultFunc: schema.EnvDefaultFunc("KUBE_CTX", ""), 81 | }, 82 | "config_context_auth_info": { 83 | Type: schema.TypeString, 84 | Optional: true, 85 | DefaultFunc: schema.EnvDefaultFunc("KUBE_CTX_AUTH_INFO", ""), 86 | Description: "", 87 | }, 88 | "config_context_cluster": { 89 | Type: schema.TypeString, 90 | Optional: true, 91 | DefaultFunc: schema.EnvDefaultFunc("KUBE_CTX_CLUSTER", ""), 92 | Description: "", 93 | }, 94 | "token": { 95 | Type: schema.TypeString, 96 | Optional: true, 97 | DefaultFunc: schema.EnvDefaultFunc("KUBE_TOKEN", ""), 98 | Description: "Token to authenticate an service account", 99 | }, 100 | "load_config_file": { 101 | Type: schema.TypeBool, 102 | Optional: true, 103 | DefaultFunc: schema.EnvDefaultFunc("KUBE_LOAD_CONFIG_FILE", true), 104 | Description: "Load local kubeconfig.", 105 | }, 106 | "exec": { 107 | Type: schema.TypeList, 108 | Optional: true, 109 | MaxItems: 1, 110 | Elem: &schema.Resource{ 111 | Schema: map[string]*schema.Schema{ 112 | "api_version": { 113 | Type: schema.TypeString, 114 | Required: true, 115 | }, 116 | "command": { 117 | Type: schema.TypeString, 118 | Required: true, 119 | }, 120 | "env": { 121 | Type: schema.TypeMap, 122 | Optional: true, 123 | Elem: &schema.Schema{Type: schema.TypeString}, 124 | }, 125 | "args": { 126 | Type: schema.TypeList, 127 | Optional: true, 128 | Elem: &schema.Schema{Type: schema.TypeString}, 129 | }, 130 | }, 131 | }, 132 | Description: "", 133 | }, 134 | }, 135 | 136 | ResourcesMap: map[string]*schema.Resource{ 137 | "k8s_manifest": resourceK8sManifest(), 138 | }, 139 | } 140 | 141 | p.ConfigureFunc = func(d *schema.ResourceData) (interface{}, error) { 142 | terraformVersion := p.TerraformVersion 143 | if terraformVersion == "" { 144 | // Terraform 0.12 introduced this field to the protocol 145 | // We can therefore assume that if it's missing it's 0.10 or 0.11 146 | terraformVersion = "0.11+compatible" 147 | } 148 | return providerConfigure(d, terraformVersion) 149 | } 150 | 151 | return p 152 | } 153 | 154 | type ProviderConfig struct { 155 | RuntimeClient client.Client 156 | } 157 | 158 | func providerConfigure(d *schema.ResourceData, terraformVersion string) (interface{}, error) { 159 | cfg := &restclient.Config{} 160 | var err error 161 | _, configSet := d.GetOk("host") 162 | if d.Get("load_config_file").(bool) { 163 | // Config file loading 164 | cfg, err = tryLoadingConfigFile(d) 165 | if err != nil { 166 | return nil, err 167 | } 168 | } else if !configSet { 169 | // Attempt to load in-cluster config 170 | cfg, err = restclient.InClusterConfig() 171 | if err != nil { 172 | // Fallback to standard config if we are not running inside a cluster 173 | if err == restclient.ErrNotInCluster { 174 | cfg = &restclient.Config{} 175 | } else { 176 | return nil, fmt.Errorf("Failed to configure: %s", err) 177 | } 178 | } 179 | } 180 | 181 | // Overriding with static configuration 182 | cfg.UserAgent = fmt.Sprintf("HashiCorp/1.0 Terraform/%s", terraformVersion) 183 | 184 | if v, ok := d.GetOk("host"); ok { 185 | cfg.Host = v.(string) 186 | } 187 | if v, ok := d.GetOk("username"); ok { 188 | cfg.Username = v.(string) 189 | } 190 | if v, ok := d.GetOk("password"); ok { 191 | cfg.Password = v.(string) 192 | } 193 | if v, ok := d.GetOk("insecure"); ok { 194 | cfg.Insecure = v.(bool) 195 | } 196 | if v, ok := d.GetOk("cluster_ca_certificate"); ok { 197 | cfg.CAData = bytes.NewBufferString(v.(string)).Bytes() 198 | } 199 | if v, ok := d.GetOk("client_certificate"); ok { 200 | cfg.CertData = bytes.NewBufferString(v.(string)).Bytes() 201 | } 202 | if v, ok := d.GetOk("client_key"); ok { 203 | cfg.KeyData = bytes.NewBufferString(v.(string)).Bytes() 204 | } 205 | if v, ok := d.GetOk("token"); ok { 206 | cfg.BearerToken = v.(string) 207 | } 208 | 209 | if v, ok := d.GetOk("exec"); ok { 210 | exec := &clientcmdapi.ExecConfig{} 211 | if spec, ok := v.([]interface{})[0].(map[string]interface{}); ok { 212 | exec.APIVersion = spec["api_version"].(string) 213 | exec.Command = spec["command"].(string) 214 | exec.Args = expandStringSlice(spec["args"].([]interface{})) 215 | for kk, vv := range spec["env"].(map[string]interface{}) { 216 | exec.Env = append(exec.Env, clientcmdapi.ExecEnvVar{Name: kk, Value: vv.(string)}) 217 | } 218 | } else { 219 | return nil, fmt.Errorf("Failed to parse exec") 220 | } 221 | cfg.ExecProvider = exec 222 | } 223 | 224 | if logging.IsDebugOrHigher() { 225 | log.Printf("[DEBUG] Enabling HTTP requests/responses tracing") 226 | cfg.WrapTransport = func(rt http.RoundTripper) http.RoundTripper { 227 | return logging.NewTransport("Kubernetes", rt) 228 | } 229 | } 230 | 231 | mapper, err := apiutil.NewDynamicRESTMapper(cfg, apiutil.WithLazyDiscovery) 232 | if err != nil { 233 | return nil, err 234 | } 235 | 236 | c, err := client.New(cfg, client.Options{ 237 | Mapper: mapper, 238 | }) 239 | if err != nil { 240 | return nil, fmt.Errorf("Failed to configure: %s", err) 241 | } 242 | 243 | return &ProviderConfig{ 244 | RuntimeClient: c, 245 | }, nil 246 | } 247 | 248 | func tryLoadingConfigFile(d *schema.ResourceData) (*restclient.Config, error) { 249 | path, err := homedir.Expand(d.Get("config_path").(string)) 250 | if err != nil { 251 | return nil, err 252 | } 253 | 254 | loader := &clientcmd.ClientConfigLoadingRules{ 255 | ExplicitPath: path, 256 | } 257 | 258 | overrides := &clientcmd.ConfigOverrides{} 259 | ctxSuffix := "; default context" 260 | 261 | ctx, ctxOk := d.GetOk("config_context") 262 | authInfo, authInfoOk := d.GetOk("config_context_auth_info") 263 | cluster, clusterOk := d.GetOk("config_context_cluster") 264 | if ctxOk || authInfoOk || clusterOk { 265 | ctxSuffix = "; overriden context" 266 | if ctxOk { 267 | overrides.CurrentContext = ctx.(string) 268 | ctxSuffix += fmt.Sprintf("; config ctx: %s", overrides.CurrentContext) 269 | log.Printf("[DEBUG] Using custom current context: %q", overrides.CurrentContext) 270 | } 271 | 272 | overrides.Context = clientcmdapi.Context{} 273 | if authInfoOk { 274 | overrides.Context.AuthInfo = authInfo.(string) 275 | ctxSuffix += fmt.Sprintf("; auth_info: %s", overrides.Context.AuthInfo) 276 | } 277 | if clusterOk { 278 | overrides.Context.Cluster = cluster.(string) 279 | ctxSuffix += fmt.Sprintf("; cluster: %s", overrides.Context.Cluster) 280 | } 281 | log.Printf("[DEBUG] Using overidden context: %#v", overrides.Context) 282 | } 283 | 284 | cc := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loader, overrides) 285 | cfg, err := cc.ClientConfig() 286 | if err != nil { 287 | if pathErr, ok := err.(*os.PathError); ok && os.IsNotExist(pathErr.Err) { 288 | log.Printf("[INFO] Unable to load config file as it doesn't exist at %q", path) 289 | return nil, nil 290 | } 291 | log.Printf("[WARN] Failed to load config (%s%s): %s", path, ctxSuffix, err) 292 | } 293 | 294 | log.Printf("[INFO] Successfully loaded config file (%s%s)", path, ctxSuffix) 295 | return cfg, nil 296 | } 297 | -------------------------------------------------------------------------------- /k8s/provider_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 8 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 9 | ) 10 | 11 | var testAccProvider *schema.Provider 12 | var testAccProviders map[string]*schema.Provider 13 | var testAccProviderFactories map[string]func() (*schema.Provider, error) 14 | 15 | func init() { 16 | testAccProvider = Provider() 17 | testAccProviders = map[string]*schema.Provider{ 18 | "k8s": testAccProvider, 19 | } 20 | testAccProviderFactories = map[string]func() (*schema.Provider, error){ 21 | "k8s": func() (*schema.Provider, error) { 22 | return testAccProvider, nil 23 | }, 24 | } 25 | } 26 | 27 | func TestProvider(t *testing.T) { 28 | if err := Provider().InternalValidate(); err != nil { 29 | t.Fatalf("err: %s", err) 30 | } 31 | } 32 | 33 | func TestProvider_impl(t *testing.T) { 34 | var _ *schema.Provider = Provider() 35 | } 36 | 37 | func testAccPreCheck(t *testing.T) { 38 | t.Helper() 39 | 40 | ctx := context.Background() 41 | 42 | diags := testAccProvider.Configure(ctx, terraform.NewResourceConfigRaw(nil)) 43 | if diags.HasError() { 44 | t.Fatal(diags[0].Summary) 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /k8s/resource_k8s_manifest.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "log" 8 | "strings" 9 | "time" 10 | 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/diag" 12 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 13 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema" 14 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/validation" 15 | "github.com/itchyny/gojq" 16 | "github.com/mitchellh/mapstructure" 17 | goyaml "gopkg.in/yaml.v2" 18 | apierrors "k8s.io/apimachinery/pkg/api/errors" 19 | "k8s.io/apimachinery/pkg/api/meta" 20 | metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" 21 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 22 | k8sschema "k8s.io/apimachinery/pkg/runtime/schema" 23 | "k8s.io/apimachinery/pkg/util/yaml" 24 | "k8s.io/kubectl/pkg/polymorphichelpers" 25 | "sigs.k8s.io/controller-runtime/pkg/client" 26 | ) 27 | 28 | func resourceK8sManifest() *schema.Resource { 29 | return &schema.Resource{ 30 | CreateContext: resourceK8sManifestCreate, 31 | ReadContext: resourceK8sManifestRead, 32 | UpdateContext: resourceK8sManifestUpdate, 33 | DeleteContext: resourceK8sManifestDelete, 34 | Importer: &schema.ResourceImporter{ 35 | StateContext: resourceK8sManifestImport, 36 | }, 37 | Schema: map[string]*schema.Schema{ 38 | "namespace": { 39 | Type: schema.TypeString, 40 | Optional: true, 41 | Sensitive: false, 42 | ForceNew: true, 43 | }, 44 | "content": { 45 | Type: schema.TypeString, 46 | Required: true, 47 | Sensitive: false, 48 | ValidateFunc: validation.StringIsNotEmpty, 49 | }, 50 | "override_content": { 51 | Type: schema.TypeString, 52 | Computed: true, 53 | }, 54 | "ignore_fields": { 55 | Type: schema.TypeList, 56 | Elem: &schema.Schema{Type: schema.TypeString}, 57 | Description: "List of jq style path you don't want to update.", 58 | Optional: true, 59 | }, 60 | "delete_cascade": { 61 | Type: schema.TypeBool, 62 | Optional: true, 63 | Sensitive: false, 64 | }, 65 | }, 66 | Timeouts: &schema.ResourceTimeout{ 67 | Create: schema.DefaultTimeout(5 * time.Minute), 68 | Update: schema.DefaultTimeout(5 * time.Minute), 69 | Delete: schema.DefaultTimeout(5 * time.Minute), 70 | }, 71 | } 72 | } 73 | 74 | func resourceK8sManifestCreate(ctx context.Context, d *schema.ResourceData, config interface{}) diag.Diagnostics { 75 | namespace := d.Get("namespace").(string) 76 | content := d.Get("content").(string) 77 | 78 | object, err := contentToObject(content) 79 | if err != nil { 80 | return diag.FromErr(err) 81 | } 82 | 83 | objectNamespace := object.GetNamespace() 84 | 85 | if namespace == "" && objectNamespace == "" { 86 | object.SetNamespace("default") 87 | } else if objectNamespace == "" { 88 | // TODO: which namespace should have a higher precedence? 89 | object.SetNamespace(namespace) 90 | } 91 | 92 | client := config.(*ProviderConfig).RuntimeClient 93 | 94 | log.Printf("[INFO] Creating new manifest: %#v", object) 95 | err = client.Create(ctx, object) 96 | if err != nil { 97 | return diag.FromErr(err) 98 | } 99 | 100 | // this must stand before the wait to avoid losing state on error 101 | d.SetId(buildId(object)) 102 | 103 | err = waitForReadyStatus(ctx, d, client, object, d.Timeout(schema.TimeoutCreate)) 104 | if err != nil { 105 | return diag.FromErr(err) 106 | } 107 | 108 | return resourceK8sManifestRead(ctx, d, config) 109 | } 110 | 111 | func waitForReadyStatus(ctx context.Context, d *schema.ResourceData, c client.Client, object *unstructured.Unstructured, timeout time.Duration) error { 112 | objectKey := client.ObjectKeyFromObject(object) 113 | 114 | createStateConf := &resource.StateChangeConf{ 115 | Pending: []string{ 116 | "pending", 117 | }, 118 | Target: []string{ 119 | "ready", 120 | }, 121 | Refresh: func() (interface{}, string, error) { 122 | err := c.Get(ctx, objectKey, object) 123 | if err != nil { 124 | log.Printf("[DEBUG] Received error: %#v", err) 125 | return nil, "error", err 126 | } 127 | 128 | log.Printf("[DEBUG] Received object: %#v", object) 129 | 130 | if s, ok := object.Object["status"]; ok { 131 | log.Printf("[DEBUG] Object has status: %#v", s) 132 | 133 | if statusViewer, err := polymorphichelpers.StatusViewerFor(object.GetObjectKind().GroupVersionKind().GroupKind()); err == nil { 134 | _, ready, err := statusViewer.Status(object, 0) 135 | if err != nil { 136 | return nil, "error", err 137 | } 138 | if ready { 139 | return object, "ready", nil 140 | } 141 | return object, "pending", nil 142 | } 143 | log.Printf("[DEBUG] Object has no rollout status viewer") 144 | 145 | var status status 146 | err = mapstructure.Decode(s, &status) 147 | if err != nil { 148 | log.Printf("[DEBUG] Received error on decode: %#v", err) 149 | return nil, "error", err 150 | } 151 | 152 | if status.ReadyReplicas != nil { 153 | if *status.ReadyReplicas > 0 { 154 | return object, "ready", nil 155 | } 156 | 157 | return object, "pending", nil 158 | } 159 | 160 | if status.Phase != nil { 161 | if *status.Phase == "Active" || *status.Phase == "Bound" || *status.Phase == "Running" || *status.Phase == "Ready" || *status.Phase == "Online" || *status.Phase == "Healthy" { 162 | return object, "ready", nil 163 | } 164 | 165 | return object, "pending", nil 166 | } 167 | 168 | if status.LoadBalancer != nil { 169 | // LoadBalancer status may be for an Ingress or a Service having type=LoadBalancer 170 | checkLoadBalancer := true 171 | if object.GetAPIVersion() == "v1" && object.GetKind() == "Service" { 172 | specInterface, ok := object.Object["spec"] 173 | if !ok { 174 | log.Printf("[DEBUG] Received error on decode: %#v", err) 175 | return nil, "error", err 176 | } 177 | spec, ok := specInterface.(map[string]interface{}) 178 | if !ok { 179 | log.Printf("[DEBUG] Received error on decode: %#v", err) 180 | return nil, "error", err 181 | } 182 | serviceType, ok := spec["type"] 183 | if !ok { 184 | log.Printf("[DEBUG] Received error on decode: %#v", err) 185 | return nil, "error", err 186 | } 187 | checkLoadBalancer = serviceType == "LoadBalancer" 188 | } 189 | if checkLoadBalancer { 190 | if len(*status.LoadBalancer) > 0 { 191 | return object, "ready", nil 192 | } 193 | return object, "pending", nil 194 | } 195 | } 196 | } 197 | 198 | return object, "ready", nil 199 | }, 200 | Timeout: timeout, 201 | Delay: 5 * time.Second, 202 | MinTimeout: 5 * time.Second, 203 | ContinuousTargetOccurence: 1, 204 | } 205 | 206 | _, err := createStateConf.WaitForState() 207 | if err != nil { 208 | return fmt.Errorf("Error waiting for resource (%s) to be created: %s", d.Id(), err) 209 | } 210 | 211 | return nil 212 | } 213 | 214 | type status struct { 215 | ReadyReplicas *int 216 | Phase *string 217 | LoadBalancer *map[string]interface{} 218 | } 219 | 220 | func resourceK8sManifestRead(ctx context.Context, d *schema.ResourceData, config interface{}) diag.Diagnostics { 221 | namespace, gv, kind, name, err := idParts(d.Id()) 222 | if err != nil { 223 | return diag.FromErr(err) 224 | } 225 | 226 | groupVersion, err := k8sschema.ParseGroupVersion(gv) 227 | if err != nil { 228 | log.Printf("[DEBUG] Invalid group version in resource ID: %#v", err) 229 | return diag.FromErr(err) 230 | } 231 | 232 | object := &unstructured.Unstructured{} 233 | object.SetGroupVersionKind(groupVersion.WithKind(kind)) 234 | object.SetNamespace(namespace) 235 | object.SetName(name) 236 | 237 | objectKey := client.ObjectKeyFromObject(object) 238 | 239 | client := config.(*ProviderConfig).RuntimeClient 240 | 241 | log.Printf("[INFO] Reading object %s", name) 242 | err = client.Get(ctx, objectKey, object) 243 | if err != nil { 244 | if apierrors.IsNotFound(err) { 245 | log.Printf("[INFO] Object missing: %#v", object) 246 | d.SetId("") 247 | return nil 248 | } 249 | if meta.IsNoMatchError(err) { 250 | log.Printf("[INFO] Object kind missing: %#v", object) 251 | d.SetId("") 252 | return nil 253 | } 254 | 255 | log.Printf("[DEBUG] Received error: %#v", err) 256 | return diag.FromErr(err) 257 | } 258 | log.Printf("[INFO] Received object: %#v", object) 259 | 260 | ignoreFields, hasIgnoreFields := d.GetOk("ignore_fields") 261 | if hasIgnoreFields { 262 | content := d.Get("content").(string) 263 | contentModified, err := excludeIgnoreFields(ignoreFields, content) 264 | if err != nil { 265 | return diag.FromErr(err) 266 | } 267 | 268 | contentYaml, err := json2Yaml(contentModified) 269 | if err != nil { 270 | return diag.FromErr(err) 271 | } 272 | 273 | d.Set("override_content", contentYaml) 274 | } 275 | 276 | // TODO: save metadata in terraform state 277 | 278 | return nil 279 | } 280 | 281 | func resourceK8sManifestUpdate(ctx context.Context, d *schema.ResourceData, config interface{}) diag.Diagnostics { 282 | var originalData string 283 | var newData string 284 | 285 | namespace, _, _, _, err := idParts(d.Id()) 286 | if err != nil { 287 | return diag.FromErr(err) 288 | } 289 | 290 | if d.HasChanges("content", "ignore_fields") { 291 | //originalDataRaw, newDataRaw := d.GetChange("content") 292 | newDataRaw := d.Get("content") 293 | originalDataRaw := d.Get("override_content") 294 | 295 | ignoreFields, hasIgnoreFields := d.GetOk("ignore_fields") 296 | if hasIgnoreFields { 297 | originalData, err = excludeIgnoreFields(ignoreFields, originalDataRaw.(string)) 298 | if err != nil { 299 | return diag.FromErr(err) 300 | } 301 | 302 | newData, err = excludeIgnoreFields(ignoreFields, newDataRaw.(string)) 303 | if err != nil { 304 | return diag.FromErr(err) 305 | } 306 | } 307 | 308 | log.Printf("[DEBUG] Original vs modified: %s %s", originalData, newData) 309 | modified, err := contentToObject(newData) 310 | if err != nil { 311 | return diag.FromErr(err) 312 | } 313 | 314 | original, err := contentToObject(originalData) 315 | if err != nil { 316 | return diag.FromErr(err) 317 | } 318 | 319 | objectNamespace := modified.GetNamespace() 320 | 321 | if namespace == "" && objectNamespace == "" { 322 | modified.SetNamespace("default") 323 | } else if objectNamespace == "" { 324 | // TODO: which namespace should have a higher precedence? 325 | modified.SetNamespace(namespace) 326 | } 327 | 328 | objectKey := client.ObjectKeyFromObject(modified) 329 | 330 | current := modified.DeepCopy() 331 | 332 | client := config.(*ProviderConfig).RuntimeClient 333 | 334 | err = client.Get(ctx, objectKey, current) 335 | if err != nil { 336 | log.Printf("[DEBUG] Received error: %#v", err) 337 | return diag.FromErr(err) 338 | } 339 | 340 | modified.SetResourceVersion(current.DeepCopy().GetResourceVersion()) 341 | 342 | current.SetResourceVersion("") 343 | original.SetResourceVersion("") 344 | 345 | if err := patch(config.(*ProviderConfig).RuntimeClient, modified, original, current); err != nil { 346 | log.Printf("[DEBUG] Received error: %#v", err) 347 | return diag.FromErr(err) 348 | } 349 | log.Printf("[INFO] Updated object: %#v", modified) 350 | 351 | err = waitForReadyStatus(ctx, d, client, modified, d.Timeout(schema.TimeoutCreate)) 352 | if err != nil { 353 | return diag.FromErr(err) 354 | } 355 | } 356 | 357 | return resourceK8sManifestRead(ctx, d, config) 358 | } 359 | 360 | func resourceK8sManifestDelete(ctx context.Context, d *schema.ResourceData, config interface{}) diag.Diagnostics { 361 | namespace, gv, kind, name, err := idParts(d.Id()) 362 | if err != nil { 363 | return diag.FromErr(err) 364 | } 365 | 366 | groupVersion, err := k8sschema.ParseGroupVersion(gv) 367 | if err != nil { 368 | log.Printf("[DEBUG] Invalid group version in resource ID: %#v", err) 369 | return diag.FromErr(err) 370 | } 371 | 372 | currentObject := &unstructured.Unstructured{} 373 | currentObject.SetGroupVersionKind(groupVersion.WithKind(kind)) 374 | currentObject.SetNamespace(namespace) 375 | currentObject.SetName(name) 376 | 377 | objectKey := client.ObjectKeyFromObject(currentObject) 378 | 379 | deleteCascade := d.Get("delete_cascade").(bool) 380 | deleteOptions := []client.DeleteOption{} 381 | if deleteCascade { 382 | deleteOptions = append(deleteOptions, client.PropagationPolicy(metav1.DeletePropagationForeground)) 383 | } 384 | 385 | client := config.(*ProviderConfig).RuntimeClient 386 | 387 | log.Printf("[INFO] Deleting object %s", name) 388 | err = client.Delete(ctx, currentObject, deleteOptions...) 389 | if err != nil { 390 | log.Printf("[DEBUG] Received error: %#v", err) 391 | return diag.FromErr(err) 392 | } 393 | 394 | createStateConf := &resource.StateChangeConf{ 395 | Pending: []string{ 396 | "deleting", 397 | }, 398 | Target: []string{ 399 | "deleted", 400 | }, 401 | Refresh: func() (interface{}, string, error) { 402 | err := client.Get(ctx, objectKey, currentObject) 403 | if err != nil { 404 | log.Printf("[INFO] error when deleting object %s: %+v", name, err) 405 | if apierrors.IsNotFound(err) { 406 | return currentObject, "deleted", nil 407 | } 408 | return nil, "error", err 409 | 410 | } 411 | return currentObject, "deleting", nil 412 | }, 413 | Timeout: d.Timeout(schema.TimeoutDelete), 414 | Delay: 5 * time.Second, 415 | MinTimeout: 5 * time.Second, 416 | ContinuousTargetOccurence: 1, 417 | } 418 | 419 | _, err = createStateConf.WaitForState() 420 | if err != nil { 421 | return diag.FromErr(fmt.Errorf("Error waiting for resource (%s) to be deleted: %s", d.Id(), err)) 422 | } 423 | 424 | log.Printf("[INFO] Deleted object: %#v", currentObject) 425 | 426 | return nil 427 | } 428 | 429 | func resourceK8sManifestImport(ctx context.Context, d *schema.ResourceData, config interface{}) ([]*schema.ResourceData, error) { 430 | namespace, gv, kind, name, err := idParts(d.Id()) 431 | if err != nil { 432 | return nil, err 433 | } 434 | 435 | groupVersion, err := k8sschema.ParseGroupVersion(gv) 436 | if err != nil { 437 | log.Printf("[DEBUG] Invalid group version in resource ID: %#v", err) 438 | return nil, err 439 | } 440 | 441 | object := &unstructured.Unstructured{} 442 | object.SetGroupVersionKind(groupVersion.WithKind(kind)) 443 | object.SetNamespace(namespace) 444 | object.SetName(name) 445 | 446 | objectKey := client.ObjectKeyFromObject(object) 447 | 448 | client := config.(*ProviderConfig).RuntimeClient 449 | 450 | err = client.Get(ctx, objectKey, object) 451 | if err != nil { 452 | log.Printf("[DEBUG] Received error: %#v", err) 453 | return nil, err 454 | } 455 | 456 | resource := schema.ResourceData{} 457 | resource.SetId(d.Id()) 458 | 459 | return []*schema.ResourceData{&resource}, nil 460 | } 461 | 462 | func contentToObject(content string) (*unstructured.Unstructured, error) { 463 | decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(content), 4096) 464 | 465 | var object *unstructured.Unstructured 466 | 467 | for { 468 | err := decoder.Decode(&object) 469 | if err != nil { 470 | return nil, fmt.Errorf("Failed to unmarshal manifest: %s", err) 471 | } 472 | 473 | if object != nil { 474 | return object, nil 475 | } 476 | } 477 | } 478 | 479 | func excludeIgnoreFields(ignoreFieldsRaw interface{}, content string) (string, error) { 480 | var contentModified []byte 481 | var ignoreFields []string 482 | 483 | for _, j := range ignoreFieldsRaw.([]interface{}) { 484 | ignoreFields = append(ignoreFields, j.(string)) 485 | } 486 | 487 | for _, i := range ignoreFields { 488 | query, err := gojq.Parse(fmt.Sprintf("del(%s)", i)) 489 | if err != nil { 490 | log.Printf("[DEBUG] Received error: %#v", err) 491 | return "", err 492 | } 493 | 494 | if len(contentModified) > 0 { 495 | d, err := yaml2GoData(string(contentModified)) 496 | if err != nil { 497 | log.Printf("[DEBUG] Received error: %#v", err) 498 | return "", err 499 | } 500 | 501 | v, _ := query.Run(d).Next() 502 | if err, ok := v.(error); ok { 503 | log.Printf("[DEBUG] Received error: %#v", err) 504 | return "", err 505 | } 506 | 507 | contentModified, err = gojq.Marshal(v) 508 | 509 | } else { 510 | d, err := yaml2GoData(content) 511 | if err != nil { 512 | log.Printf("[DEBUG] Received error: %#v", err) 513 | return "", err 514 | } 515 | 516 | v, _ := query.Run(d).Next() 517 | if err, ok := v.(error); ok { 518 | log.Printf("[DEBUG] !!!Received error: %#v", err) 519 | return "", err 520 | } 521 | 522 | contentModified, err = gojq.Marshal(v) 523 | } 524 | 525 | if err != nil { 526 | log.Printf("[DEBUG] Received error from jq: %#v", err) 527 | } 528 | } 529 | return string(contentModified), nil 530 | } 531 | 532 | func yaml2GoData(i string) (map[string]interface{}, error) { 533 | var body map[string]interface{} 534 | decoder := yaml.NewYAMLOrJSONDecoder(strings.NewReader(i), 4096) 535 | err := decoder.Decode(&body) 536 | 537 | return body, err 538 | } 539 | 540 | func json2Yaml(i string) (string, error) { 541 | var body interface{} 542 | err := json.Unmarshal([]byte(i), &body) 543 | if err != nil { 544 | return "", err 545 | } 546 | 547 | data, err := goyaml.Marshal(body) 548 | 549 | return string(data), err 550 | } 551 | -------------------------------------------------------------------------------- /k8s/resource_k8s_manifest_test.go: -------------------------------------------------------------------------------- 1 | package k8s 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "testing" 8 | 9 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/acctest" 10 | "github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource" 11 | "github.com/hashicorp/terraform-plugin-sdk/v2/terraform" 12 | "github.com/mitchellh/mapstructure" 13 | k8sapierrors "k8s.io/apimachinery/pkg/api/errors" 14 | "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" 15 | k8sschema "k8s.io/apimachinery/pkg/runtime/schema" 16 | "k8s.io/kubectl/pkg/polymorphichelpers" 17 | "sigs.k8s.io/controller-runtime/pkg/client" 18 | ) 19 | 20 | // TestAccK8sManifest_basic tests the basic functionality of the k8s_manifest resource. 21 | func TestAccK8sManifest_basic(t *testing.T) { 22 | name := fmt.Sprintf("tf-acc-test-%s", acctest.RandStringFromCharSet(10, acctest.CharSetAlphaNum)) 23 | 24 | resource.Test(t, resource.TestCase{ 25 | PreCheck: func() { testAccPreCheck(t) }, 26 | ProviderFactories: testAccProviderFactories, 27 | CheckDestroy: testAccCheckK8sManifestDestroy, 28 | Steps: []resource.TestStep{ 29 | { // First, create the resource. 30 | Config: testAccK8sManifest_basic(name), 31 | Check: resource.ComposeAggregateTestCheckFunc( 32 | testAccCheckK8sManifestReady("k8s_manifest.test"), 33 | ), 34 | }, 35 | { // Then modify it. 36 | Config: testAccK8sManifest_modified(name), 37 | Check: resource.ComposeAggregateTestCheckFunc( 38 | testAccCheckK8sManifestReady("k8s_manifest.test"), 39 | ), 40 | }, 41 | }, 42 | }) 43 | } 44 | 45 | // testAccK8sManifest_basic provides the terraform config 46 | // used to test basic functionality of the k8s_manifest resource. 47 | func testAccK8sManifest_basic(name string) string { 48 | return fmt.Sprintf(`resource "k8s_manifest" "test" { 49 | ignore_fields = [".nothing"] # Temporary workaround, see https://github.com/banzaicloud/terraform-provider-k8s/issues/84 50 | content = <. 5 | name: crontabs.stable.example.com 6 | spec: 7 | # group name to use for REST API: /apis// 8 | group: stable.example.com 9 | # list of versions supported by this CustomResourceDefinition 10 | versions: 11 | - name: v1 12 | # Each version can be enabled/disabled by Served flag. 13 | served: true 14 | # One and only one version must be marked as the storage version. 15 | storage: true 16 | schema: 17 | openAPIV3Schema: 18 | type: object 19 | properties: 20 | spec: 21 | type: object 22 | properties: 23 | cronSpec: 24 | type: string 25 | image: 26 | type: string 27 | replicas: 28 | type: integer 29 | # either Namespaced or Cluster 30 | scope: Namespaced 31 | names: 32 | # plural name to be used in the URL: /apis/// 33 | plural: crontabs 34 | # singular name to be used as an alias on the CLI and for display 35 | singular: crontab 36 | # kind is normally the CamelCased singular type. Your resource manifests use this. 37 | kind: CronTab 38 | # shortNames allow shorter string to match your resource on the CLI 39 | shortNames: 40 | - ct 41 | -------------------------------------------------------------------------------- /test/manifests/crontab-resource.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: "stable.example.com/v1" 2 | kind: CronTab 3 | metadata: 4 | name: my-new-cron-object 5 | spec: 6 | cronSpec: "* * * * */5" 7 | image: my-awesome-cron-image 8 | -------------------------------------------------------------------------------- /test/manifests/my-configmap.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | 3 | kind: ConfigMap 4 | apiVersion: v1 5 | metadata: 6 | name: my-configmap 7 | namespace: default 8 | data: 9 | greeting: ${greeting} 10 | -------------------------------------------------------------------------------- /test/manifests/nginx-deployment.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: apps/v1 2 | kind: Deployment 3 | metadata: 4 | name: nginx-deployment 5 | labels: 6 | app: nginx 7 | spec: 8 | replicas: ${replicas} 9 | selector: 10 | matchLabels: 11 | app: nginx 12 | template: 13 | metadata: 14 | labels: 15 | app: nginx 16 | spec: 17 | containers: 18 | - name: nginx 19 | image: nginx:1.7.9 20 | ports: 21 | - containerPort: 80 22 | -------------------------------------------------------------------------------- /test/manifests/nginx-ingress.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: networking.k8s.io/v1 2 | kind: Ingress 3 | metadata: 4 | name: nginx-ingress 5 | spec: 6 | rules: 7 | - http: 8 | paths: 9 | - pathType: ImplementationSpecific 10 | path: / 11 | backend: 12 | service: 13 | name: nginx-service 14 | port: 15 | number: 80 16 | -------------------------------------------------------------------------------- /test/manifests/nginx-namespace.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Namespace 3 | metadata: 4 | name: nginx 5 | -------------------------------------------------------------------------------- /test/manifests/nginx-pvc-pod.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Pod 3 | metadata: 4 | name: nginx-pvc-pod 5 | labels: 6 | app: nginx 7 | spec: 8 | containers: 9 | - name: nginx 10 | image: nginx:1.7.9 11 | volumes: 12 | - name: vol 13 | persistentVolumeClaim: 14 | claimName: nginx 15 | -------------------------------------------------------------------------------- /test/manifests/nginx-pvc.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: PersistentVolumeClaim 3 | metadata: 4 | name: nginx 5 | spec: 6 | accessModes: 7 | - ReadWriteOnce 8 | resources: 9 | requests: 10 | storage: 500Mi 11 | -------------------------------------------------------------------------------- /test/manifests/nginx-service.yaml: -------------------------------------------------------------------------------- 1 | apiVersion: v1 2 | kind: Service 3 | metadata: 4 | name: nginx-service 5 | spec: 6 | ports: 7 | - name: http 8 | port: 80 9 | protocol: TCP 10 | selector: 11 | app: nginx 12 | type: LoadBalancer 13 | -------------------------------------------------------------------------------- /test/terraform/main.tf: -------------------------------------------------------------------------------- 1 | data "template_file" "my-configmap" { 2 | template = file("${path.module}/../manifests/my-configmap.yaml") 3 | 4 | vars = { 5 | greeting = var.greeting 6 | } 7 | } 8 | 9 | resource "k8s_manifest" "my-configmap" { 10 | content = data.template_file.my-configmap.rendered 11 | } 12 | 13 | data "template_file" "nginx-deployment" { 14 | template = file("${path.module}/../manifests/nginx-deployment.yaml") 15 | 16 | vars = { 17 | replicas = var.replicas 18 | } 19 | } 20 | 21 | resource "k8s_manifest" "nginx-deployment" { 22 | content = data.template_file.nginx-deployment.rendered 23 | } 24 | 25 | data "template_file" "nginx-namespace" { 26 | template = file("${path.module}/../manifests/nginx-namespace.yaml") 27 | } 28 | 29 | resource "k8s_manifest" "nginx-namespace" { 30 | content = data.template_file.nginx-namespace.rendered 31 | namespace = "kube-system" 32 | } 33 | 34 | resource "k8s_manifest" "nginx-deployment-with-namespace" { 35 | content = data.template_file.nginx-deployment.rendered 36 | namespace = "nginx" 37 | } 38 | 39 | data "template_file" "nginx-service" { 40 | template = file("${path.module}/../manifests/nginx-service.yaml") 41 | } 42 | 43 | resource "k8s_manifest" "nginx-service" { 44 | content = data.template_file.nginx-service.rendered 45 | namespace = "nginx" 46 | } 47 | 48 | data "template_file" "nginx-ingress" { 49 | template = file("${path.module}/../manifests/nginx-ingress.yaml") 50 | } 51 | 52 | resource "k8s_manifest" "nginx-ingress" { 53 | content = data.template_file.nginx-ingress.rendered 54 | namespace = "nginx" 55 | } 56 | 57 | data "template_file" "nginx-pvc" { 58 | template = file("${path.module}/../manifests/nginx-pvc.yaml") 59 | } 60 | 61 | resource "k8s_manifest" "nginx-pvc" { 62 | content = data.template_file.nginx-pvc.rendered 63 | namespace = "nginx" 64 | } 65 | 66 | # Kind local storage provisioner doesn't work without binding the PVC 67 | resource "k8s_manifest" "nginx-pvc-pod" { 68 | content = file("${path.module}/../manifests/nginx-pvc-pod.yaml") 69 | namespace = "nginx" 70 | } 71 | 72 | resource "k8s_manifest" "crontab-crd" { 73 | content = file("${path.module}/../manifests/crontab-crd.yaml") 74 | } 75 | 76 | resource "k8s_manifest" "crontab-resource" { 77 | content = file("${path.module}/../manifests/crontab-resource.yaml") 78 | namespace = "nginx" 79 | depends_on = [k8s_manifest.crontab-crd] 80 | } 81 | -------------------------------------------------------------------------------- /test/terraform/variables.tf: -------------------------------------------------------------------------------- 1 | variable "greeting" { 2 | type = string 3 | default = "Hello, world!" 4 | } 5 | 6 | variable "replicas" { 7 | type = string 8 | default = 3 9 | } 10 | 11 | -------------------------------------------------------------------------------- /test/terraform/versions.tf: -------------------------------------------------------------------------------- 1 | 2 | terraform { 3 | required_version = ">= 0.12" 4 | } 5 | --------------------------------------------------------------------------------