├── .go-version
├── CODEOWNERS
├── .gitignore
├── Dockerfile
├── .github
└── workflows
│ ├── test.yaml
│ └── lint.yaml
├── go.mod
├── CHANGELOG.md
├── LICENSE
├── Makefile
├── go.sum
├── README.md
├── contrib
└── hashicorp
│ └── terraform
│ ├── format_test.go
│ └── format.go
├── tfk8s.go
└── tfk8s_test.go
/.go-version:
--------------------------------------------------------------------------------
1 | 1.19
2 |
--------------------------------------------------------------------------------
/CODEOWNERS:
--------------------------------------------------------------------------------
1 | * @jrhouston
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | release/
2 | scratch/
3 | tfk8s
4 |
--------------------------------------------------------------------------------
/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM golang:1.19-alpine as build
2 | WORKDIR /build
3 | COPY go.* ./
4 | RUN go mod download
5 | COPY . .
6 | RUN apk --no-cache add make
7 | RUN CGO_ENABLED=0 make build
8 |
9 | FROM scratch
10 | COPY --from=build /build/tfk8s /bin/tfk8s
11 | ENTRYPOINT ["/bin/tfk8s"]
12 |
--------------------------------------------------------------------------------
/.github/workflows/test.yaml:
--------------------------------------------------------------------------------
1 | name: "Unit Tests"
2 |
3 | on:
4 | push:
5 | branches:
6 | - main
7 | pull_request:
8 | branches:
9 | - main
10 |
11 | jobs:
12 | unit_test:
13 | runs-on: ubuntu-latest
14 | steps:
15 | - uses: actions/checkout@v3
16 | - uses: actions/setup-go@v3
17 | with:
18 | go-version-file: '.go-version'
19 | cache: true
20 | - name: test
21 | run: make test
22 |
--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
1 | module github.com/jrhouston/tfk8s
2 |
3 | go 1.19
4 |
5 | require (
6 | github.com/spf13/pflag v1.0.3
7 | github.com/stretchr/testify v1.5.1
8 | github.com/zclconf/go-cty v1.8.0
9 | sigs.k8s.io/yaml v1.1.0
10 | )
11 |
12 | require (
13 | github.com/davecgh/go-spew v1.1.1 // indirect
14 | github.com/google/go-cmp v0.5.2 // indirect
15 | github.com/pmezard/go-difflib v1.0.0 // indirect
16 | golang.org/x/text v0.3.8 // indirect
17 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
18 | gopkg.in/yaml.v2 v2.2.8 // indirect
19 | )
20 |
--------------------------------------------------------------------------------
/.github/workflows/lint.yaml:
--------------------------------------------------------------------------------
1 | name: lint
2 |
3 | permissions:
4 | contents: read
5 |
6 | on:
7 | push:
8 | branches:
9 | - main
10 | pull_request:
11 | branches:
12 | - main
13 |
14 | jobs:
15 | golangci:
16 | name: lint
17 | runs-on: ubuntu-latest
18 | timeout-minutes: 5
19 | steps:
20 | - uses: actions/checkout@v3
21 | - uses: actions/setup-go@v3
22 | with:
23 | go-version: '1.19'
24 | cache: true
25 | - name: golangci-lint
26 | uses: golangci/golangci-lint-action@v3
27 | with:
28 | version: v1.48
29 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | # 0.1.10
2 |
3 | - Fix generation field not being stripped when using --strip
4 |
5 | # 0.1.9
6 |
7 | - Fix comma placement when formatting multi-line string values inside lists
8 |
9 | # 0.1.8
10 |
11 | - Support resources using generateName
12 | - Add option to remove quotes from object/map keys when not needed
13 | - Match nested interpolation when escaping shell
14 |
15 | # 0.1.7
16 |
17 | - Escape shell vars in HCL output
18 |
19 | # 0.1.6
20 |
21 | - Fix crash when trying to use List resources
22 | - Catch panics and print friendly error message
23 |
24 | # 0.1.5
25 |
26 | - Remove dependency on terraform in go.mod
27 |
28 | # 0.1.4
29 |
30 | - Fix empty YAML crash (#21)
31 |
32 | # 0.1.3
33 |
34 | - Ignore empty documents
35 |
36 | # 0.1.2
37 |
38 | - Add heredoc syntax for multiline strings (#14)
39 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License
2 |
3 | Copyright (c) 2020 John Houston
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 | .PHONY: build docker docker-push release install test clean
2 |
3 | VERSION := 0.1.10
4 | DOCKER_IMAGE_NAME := jrhouston/tfk8s
5 |
6 | build:
7 | go build -ldflags "-X main.toolVersion=${VERSION}"
8 |
9 | docker:
10 | docker build -t ${DOCKER_IMAGE_NAME}:${VERSION} .
11 |
12 | docker-push: docker
13 | docker push ${DOCKER_IMAGE_NAME}:${VERSION}
14 |
15 | release: clean
16 | mkdir -p release/
17 | # FIXME use gox for this
18 | GOOS=linux GOOARCH=386 go build -ldflags "-X main.toolVersion=${VERSION}" -o release/tfk8s_${VERSION}_linux_386
19 | zip -j release/tfk8s_${VERSION}_linux_386.zip release/tfk8s_${VERSION}_linux_386
20 | GOOS=linux GOOARCH=amd64 go build -ldflags "-X main.toolVersion=${VERSION}" -o release/tfk8s_${VERSION}_linux_amd64
21 | zip -j release/tfk8s_${VERSION}_linux_amd64.zip release/tfk8s_${VERSION}_linux_amd64
22 | GOOS=linux GOOARCH=arm go build -ldflags "-X main.toolVersion=${VERSION}" -o release/tfk8s_${VERSION}_linux_arm
23 | zip -j release/tfk8s_${VERSION}_linux_arm.zip release/tfk8s_${VERSION}_linux_arm
24 | GOOS=darwin GOOARCH=amd64 go build -ldflags "-X main.toolVersion=${VERSION}" -o release/tfk8s_${VERSION}_darwin_amd64
25 | zip -j release/tfk8s_${VERSION}_darwin_amd64.zip release/tfk8s_${VERSION}_darwin_amd64
26 | GOOS=windows GOOARCH=amd64 go build -ldflags "-X main.toolVersion=${VERSION}" -o release/tfk8s_${VERSION}_windows_amd64
27 | zip -j release/tfk8s_${VERSION}_windows_amd64.zip release/tfk8s_${VERSION}_windows_amd64
28 | GOOS=windows GOOARCH=386 go build -ldflags "-X main.toolVersion=${VERSION}" -o release/tfk8s_${VERSION}_windows_386
29 | zip -j release/tfk8s_${VERSION}_windows_386.zip release/tfk8s_${VERSION}_windows_386
30 |
31 | install:
32 | go install -ldflags "-X main.toolVersion=${VERSION}"
33 |
34 | test:
35 | go test -v ./...
36 |
37 | clean:
38 | rm -rf release/*
39 |
--------------------------------------------------------------------------------
/go.sum:
--------------------------------------------------------------------------------
1 | github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
2 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
3 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
4 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
5 | github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
6 | github.com/golang/protobuf v1.3.4/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
7 | github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
8 | github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
9 | github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
10 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI=
11 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
12 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
13 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE=
14 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
15 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
16 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
17 | github.com/spf13/pflag v1.0.3 h1:zPAT6CGy6wXeQ7NtTnaTerfKOsV6V6F8agHXFiazDkg=
18 | github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
19 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
20 | github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
21 | github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
22 | github.com/vmihailenco/msgpack/v4 v4.3.12/go.mod h1:gborTTJjAo/GWTqqRjrLCn9pgNN+NXzzngzBKDPIqw4=
23 | github.com/vmihailenco/tagparser v0.1.1/go.mod h1:OeAg3pn3UbLjkWt+rN9oFYB6u/cQgqMEUPoW2WPyhdI=
24 | github.com/zclconf/go-cty v1.8.0 h1:s4AvqaeQzJIu3ndv4gVIhplVD0krU+bgrcLSVUnaWuA=
25 | github.com/zclconf/go-cty v1.8.0/go.mod h1:vVKLxnk3puL4qRAv72AO+W99LUD4da90g3uUAzyuvAk=
26 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
27 | golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
28 | golang.org/x/net v0.0.0-20200301022130-244492dfa37a/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
29 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
30 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
31 | golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
32 | golang.org/x/text v0.3.5/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
33 | golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
34 | golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
35 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
36 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
37 | google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
38 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
39 | gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
40 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
41 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
42 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
43 | gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
44 | gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
45 | sigs.k8s.io/yaml v1.1.0 h1:4A07+ZFc2wgJwo8YNlQpr1rVlgUDlxXHhPJciaPY5gs=
46 | sigs.k8s.io/yaml v1.1.0/go.mod h1:UJmg0vDUVViEyp3mgSv9WPwZCDxu4rQW1olrI1uml+o=
47 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | tfk8s [](https://goreportcard.com/report/github.com/jrhouston/tfk8s) 
2 |
3 | ---
4 |
5 | 
6 |
7 | `tfk8s` is a tool that makes it easier to work with the [Terraform Kubernetes Provider](https://github.com/hashicorp/terraform-provider-kubernetes).
8 |
9 | If you want to copy examples from the Kubernetes documentation or migrate existing YAML manifests and use them with Terraform without having to convert YAML to HCL by hand, this tool is for you.
10 |
11 | - [Demo](#demo)
12 | - [Features](#features)
13 | - [Install](#install)
14 | - [Usage](#usage)
15 | - [Examples](#examples)
16 | - [Create Terraform configuration from YAML files](#create-terraform-configuration-from-yaml-files)
17 | - [Use with kubectl to output maps instead of YAML](#use-with-kubectl-to-output-maps-instead-of-yaml)
18 | - [Convert a Helm chart to Terraform](#convert-a-helm-chart-to-terraform)
19 | - [Convert a directory tree of manifests to Terraform](#convert-a-directory-tree-of-manifests-to-terraform)
20 |
21 | ## Demo
22 |
23 | [
](https://asciinema.org/a/jSmyAg4Ar6EcwKCTCXN8iAJM2)
24 |
25 | ## Features
26 |
27 | - Convert a YAML file containing multiple manifests
28 | - Strip out server side fields when piping `kubectl get $R -o yaml | tfk8s --strip`
29 |
30 | ## Install
31 |
32 | ```
33 | go install github.com/jrhouston/tfk8s@latest
34 | ```
35 |
36 | Alternatively, clone this repo and run:
37 |
38 | ```
39 | make install
40 | ```
41 |
42 | If Go's bin directory is not in your `PATH` you will need to add it:
43 |
44 | ```
45 | export PATH=$PATH:$(go env GOPATH)/bin
46 | ```
47 |
48 | Or you can install via [brew](https://formulae.brew.sh/formula/tfk8s) for macOS/Linux:
49 |
50 | ```
51 | brew install tfk8s
52 | ```
53 |
54 | On macOS, you can also install via [MacPorts](https://www.macports.org):
55 |
56 | ```
57 | sudo port install tfk8s
58 | ```
59 |
60 | ## Usage
61 |
62 | ```
63 | Usage of tfk8s:
64 | -f, --file string Input file containing Kubernetes YAML manifests (default "-")
65 | -M, --map-only Output only an HCL map structure
66 | -o, --output string Output file to write Terraform config (default "-")
67 | -p, --provider provider Provider alias to populate the provider attribute
68 | -s, --strip Strip out server side fields - use if you are piping from kubectl get
69 | -Q, --strip-key-quotes Strip out quotes from HCL map keys unless they are required.
70 | -V, --version Show tool version
71 | ```
72 |
73 | ## Examples
74 |
75 | ### Create Terraform configuration from YAML files
76 |
77 | ```
78 | tfk8s -f input.yaml -o output.tf
79 | ```
80 |
81 | or, using pipes:
82 | ```
83 | cat input.yaml | tfk8s > output.tf
84 | ```
85 |
86 | **input.yaml**:
87 | ```yaml
88 | ---
89 | apiVersion: v1
90 | kind: ConfigMap
91 | metadata:
92 | name: test
93 | data:
94 | TEST: test
95 | ```
96 |
97 | ✨✨ magically becomes ✨✨
98 |
99 | **output.tf**:
100 | ```hcl
101 | resource "kubernetes_manifest" "configmap_test" {
102 | manifest = {
103 | "apiVersion" = "v1"
104 | "data" = {
105 | "TEST" = "test"
106 | }
107 | "kind" = "ConfigMap"
108 | "metadata" = {
109 | "name" = "test"
110 | }
111 | }
112 | }
113 | ```
114 |
115 | ### Use with kubectl to output maps instead of YAML
116 |
117 | ```
118 | kubectl get ns default -o yaml | tfk8s -M
119 | ```
120 | ```hcl
121 | {
122 | "apiVersion" = "v1"
123 | "kind" = "Namespace"
124 | "metadata" = {
125 | "creationTimestamp" = "2020-05-02T15:01:32Z"
126 | "name" = "default"
127 | "resourceVersion" = "147"
128 | "selfLink" = "/api/v1/namespaces/default"
129 | "uid" = "6ac3424c-07a4-4a69-86ae-cc7a4ae72be3"
130 | }
131 | "spec" = {
132 | "finalizers" = [
133 | "kubernetes",
134 | ]
135 | }
136 | "status" = {
137 | "phase" = "Active"
138 | }
139 | }
140 | ```
141 |
142 | ### Convert a Helm chart to Terraform
143 |
144 | You can use `helm template` to generate a manifest from the chart, then pipe it into tfk8s:
145 |
146 | ```
147 | helm template ./chart-path -f values.yaml | tfk8s
148 | ```
149 |
150 | ## Convert a directory tree of manifests to Terraform
151 |
152 | You can use `tfk8s` in conjunction with `find` to convert an entire directory recursively:
153 |
154 | ```bash
155 | find dirname/ -name '*.yaml' -type f -exec sh -c 'tfk8s -f {} -o $(echo {} | sed "s/\.[^.]*$//").tf' \;
156 | ```
157 |
--------------------------------------------------------------------------------
/contrib/hashicorp/terraform/format_test.go:
--------------------------------------------------------------------------------
1 | // NOTE this file was lifted verbatim from internal/repl in the terraform project
2 | // because the FormatValue function became internal in v1.0.0
3 |
4 | package terraform
5 |
6 | import (
7 | "fmt"
8 | "testing"
9 |
10 | "github.com/zclconf/go-cty/cty"
11 | )
12 |
13 | func TestFormatValue(t *testing.T) {
14 | tests := []struct {
15 | Val cty.Value
16 | Want string
17 | }{
18 | {
19 | cty.NullVal(cty.DynamicPseudoType),
20 | `null`,
21 | },
22 | {
23 | cty.NullVal(cty.String),
24 | `tostring(null)`,
25 | },
26 | {
27 | cty.NullVal(cty.Number),
28 | `tonumber(null)`,
29 | },
30 | {
31 | cty.NullVal(cty.Bool),
32 | `tobool(null)`,
33 | },
34 | {
35 | cty.NullVal(cty.List(cty.String)),
36 | `tolist(null) /* of string */`,
37 | },
38 | {
39 | cty.NullVal(cty.Set(cty.Number)),
40 | `toset(null) /* of number */`,
41 | },
42 | {
43 | cty.NullVal(cty.Map(cty.Bool)),
44 | `tomap(null) /* of bool */`,
45 | },
46 | {
47 | cty.NullVal(cty.Object(map[string]cty.Type{"a": cty.Bool})),
48 | `null /* object */`, // Ideally this would display the full object type, including its attributes
49 | },
50 | {
51 | cty.UnknownVal(cty.DynamicPseudoType),
52 | `(known after apply)`,
53 | },
54 | {
55 | cty.StringVal(""),
56 | `""`,
57 | },
58 | {
59 | cty.StringVal("hello"),
60 | `"hello"`,
61 | },
62 | {
63 | cty.StringVal("hello\nworld"),
64 | `< 0 {
99 | operator = "<<-"
100 | }
101 |
102 | delimiter := defaultDelimiter
103 |
104 | OUTER:
105 | for {
106 | // Check if any of the lines are in conflict with the delimiter. The
107 | // parser allows leading and trailing whitespace, so we must remove it
108 | // before comparison.
109 | for _, line := range lines {
110 | // If the delimiter matches a line, extend it and start again
111 | if strings.TrimSpace(line) == delimiter {
112 | delimiter = delimiter + "_"
113 | continue OUTER
114 | }
115 | }
116 |
117 | // None of the lines match the delimiter, so we're ready
118 | break
119 | }
120 |
121 | // Write the heredoc, with indentation as appropriate.
122 | var buf strings.Builder
123 |
124 | buf.WriteString(operator)
125 | buf.WriteString(delimiter)
126 | for _, line := range lines {
127 | buf.WriteByte('\n')
128 | buf.WriteString(strings.Repeat(" ", indent))
129 | buf.WriteString(line)
130 | }
131 | buf.WriteByte('\n')
132 | buf.WriteString(strings.Repeat(" ", indent))
133 | buf.WriteString(delimiter)
134 |
135 | return buf.String(), true
136 | }
137 |
138 | func formatMappingValue(v cty.Value, indent int, stripKeyQuotes bool) string {
139 | var buf strings.Builder
140 | count := 0
141 | buf.WriteByte('{')
142 | indent += 2
143 | for it := v.ElementIterator(); it.Next(); {
144 | count++
145 | k, v := it.Element()
146 | buf.WriteByte('\n')
147 | buf.WriteString(strings.Repeat(" ", indent))
148 | key := FormatValue(k, indent, stripKeyQuotes)
149 | if stripKeyQuotes {
150 | // they can be unquoted if it starts with a letter
151 | // and only contains alphanumeric characeters, dashes, and underlines
152 | m := regexp.MustCompile(`^"[A-Za-z][0-9A-Za-z-_]+"$`)
153 | if m.MatchString(key) {
154 | key = key[1 : len(key)-1]
155 | }
156 | }
157 | buf.WriteString(key)
158 | buf.WriteString(" = ")
159 | buf.WriteString(FormatValue(v, indent, stripKeyQuotes))
160 | }
161 | indent -= 2
162 | if count > 0 {
163 | buf.WriteByte('\n')
164 | buf.WriteString(strings.Repeat(" ", indent))
165 | }
166 | buf.WriteByte('}')
167 | return buf.String()
168 | }
169 |
170 | func formatSequenceValue(v cty.Value, indent int, stripKeyQuotes bool) string {
171 | var buf strings.Builder
172 | count := 0
173 | buf.WriteByte('[')
174 | indent += 2
175 | for it := v.ElementIterator(); it.Next(); {
176 | count++
177 | _, v := it.Element()
178 | buf.WriteByte('\n')
179 | buf.WriteString(strings.Repeat(" ", indent))
180 | formattedValue := FormatValue(v, indent, stripKeyQuotes)
181 | buf.WriteString(formattedValue)
182 | if strings.HasSuffix(formattedValue, defaultDelimiter) {
183 | // write an additional newline if the value was a multiline string
184 | buf.WriteByte('\n')
185 | buf.WriteString(strings.Repeat(" ", indent))
186 | }
187 | buf.WriteByte(',')
188 | }
189 | indent -= 2
190 | if count > 0 {
191 | buf.WriteByte('\n')
192 | buf.WriteString(strings.Repeat(" ", indent))
193 | }
194 | buf.WriteByte(']')
195 | return buf.String()
196 | }
197 |
--------------------------------------------------------------------------------
/tfk8s.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "bytes"
5 | "fmt"
6 | "io"
7 | "os"
8 | "regexp"
9 | "runtime/debug"
10 | "strings"
11 |
12 | flag "github.com/spf13/pflag"
13 |
14 | cty "github.com/zclconf/go-cty/cty"
15 | ctyjson "github.com/zclconf/go-cty/cty/json"
16 |
17 | yaml "sigs.k8s.io/yaml"
18 |
19 | "github.com/jrhouston/tfk8s/contrib/hashicorp/terraform"
20 | )
21 |
22 | // toolVersion is the version that gets printed when you run --version
23 | var toolVersion string
24 |
25 | // resourceType is the type of Terraform resource
26 | var resourceType = "kubernetes_manifest"
27 |
28 | // ignoreMetadata is the list of metadata fields to strip
29 | // when --strip is supplied
30 | var ignoreMetadata = []string{
31 | "creationTimestamp",
32 | "resourceVersion",
33 | "selfLink",
34 | "uid",
35 | "managedFields",
36 | "finalizers",
37 | "generation",
38 | }
39 |
40 | // ignoreAnnotations is the list of annotations to strip
41 | // when --strip is supplied
42 | var ignoreAnnotations = []string{
43 | "kubectl.kubernetes.io/last-applied-configuration",
44 | }
45 |
46 | // stripServerSideFields removes fields that have been added on the
47 | // server side after the resource was created such as the status field
48 | func stripServerSideFields(doc cty.Value) cty.Value {
49 | m := doc.AsValueMap()
50 |
51 | // strip server-side metadata
52 | metadata := m["metadata"].AsValueMap()
53 | for _, f := range ignoreMetadata {
54 | delete(metadata, f)
55 | }
56 | if v, ok := metadata["annotations"]; ok {
57 | annotations := v.AsValueMap()
58 | for _, a := range ignoreAnnotations {
59 | delete(annotations, a)
60 | }
61 | if len(annotations) == 0 {
62 | delete(metadata, "annotations")
63 | } else {
64 | metadata["annotations"] = cty.ObjectVal(annotations)
65 | }
66 | }
67 | if ns, ok := metadata["namespace"]; ok && ns.AsString() == "default" {
68 | delete(metadata, "namespace")
69 | }
70 | m["metadata"] = cty.ObjectVal(metadata)
71 |
72 | // strip finalizer from spec
73 | if v, ok := m["spec"]; ok {
74 | mm := v.AsValueMap()
75 | delete(mm, "finalizers")
76 | m["spec"] = cty.ObjectVal(mm)
77 | }
78 |
79 | // strip status field
80 | delete(m, "status")
81 |
82 | return cty.ObjectVal(m)
83 | }
84 |
85 | // snakify converts "a-String LIKE this" to "a_string_like_this"
86 | func snakify(s string) string {
87 | re := regexp.MustCompile(`\W`)
88 | return strings.ToLower(re.ReplaceAllString(s, "_"))
89 | }
90 |
91 | // escape incidences of ${} with $${} to prevent Terraform trying to interpolate them
92 | func escapeShellVars(s string) string {
93 | r := regexp.MustCompile(`(\${.*?)`)
94 | return r.ReplaceAllString(s, `$$$1`)
95 | }
96 |
97 | // yamlToHCL converts a single YAML document Terraform HCL
98 | func yamlToHCL(
99 | doc cty.Value, providerAlias string,
100 | stripServerSide bool, mapOnly bool, stripKeyQuotes bool) (string, error) {
101 | m := doc.AsValueMap()
102 | docs := []cty.Value{doc}
103 | if strings.HasSuffix(m["kind"].AsString(), "List") {
104 | docs = m["items"].AsValueSlice()
105 | }
106 |
107 | hcl := ""
108 | for i, doc := range docs {
109 | mm := doc.AsValueMap()
110 | kind := mm["kind"].AsString()
111 | metadata := mm["metadata"].AsValueMap()
112 | var namespace string
113 | if v, ok := metadata["namespace"]; ok {
114 | namespace = v.AsString()
115 | }
116 |
117 | var name string
118 | if n, ok := metadata["name"]; ok {
119 | name = n.AsString()
120 | } else if n, ok := metadata["generateName"]; ok {
121 | name = n.AsString()
122 | if name[len(name)-1] == '-' {
123 | name = name[:len(name)-1]
124 | }
125 | }
126 |
127 | resourceName := kind
128 | if namespace != "" && namespace != "default" {
129 | resourceName = resourceName + "_" + namespace
130 | }
131 | resourceName = resourceName + "_" + name
132 | resourceName = snakify(resourceName)
133 |
134 | if stripServerSide {
135 | doc = stripServerSideFields(doc)
136 | }
137 | s := terraform.FormatValue(doc, 0, stripKeyQuotes)
138 | s = escapeShellVars(s)
139 |
140 | if mapOnly {
141 | hcl += fmt.Sprintf("%v\n", s)
142 | } else {
143 | hcl += fmt.Sprintf("resource %q %q {\n", resourceType, resourceName)
144 | if providerAlias != "" {
145 | hcl += fmt.Sprintf(" provider = %v\n\n", providerAlias)
146 | }
147 | hcl += fmt.Sprintf(" manifest = %v\n", strings.ReplaceAll(s, "\n", "\n "))
148 | hcl += "}\n"
149 | }
150 | if i != len(docs)-1 {
151 | hcl += "\n"
152 | }
153 | }
154 |
155 | return hcl, nil
156 | }
157 |
158 | var yamlSeparator = "\n---"
159 |
160 | // YAMLToTerraformResources takes a file containing one or more Kubernetes configs
161 | // and converts it to resources that can be used by the Terraform Kubernetes Provider
162 | //
163 | // FIXME this function has too many arguments now, use functional options instead
164 | func YAMLToTerraformResources(
165 | r io.Reader, providerAlias string, stripServerSide bool,
166 | mapOnly bool, stripKeyQuotes bool) (string, error) {
167 | hcl := ""
168 |
169 | buf := bytes.Buffer{}
170 | _, err := buf.ReadFrom(r)
171 | if err != nil {
172 | return "", err
173 | }
174 |
175 | count := 0
176 | manifest := buf.String()
177 | docs := strings.Split(manifest, yamlSeparator)
178 | for _, doc := range docs {
179 | if strings.TrimSpace(doc) == "" {
180 | // some manifests have empty documents
181 | continue
182 | }
183 |
184 | var b []byte
185 | b, err = yaml.YAMLToJSON([]byte(doc))
186 | if err != nil {
187 | return "", err
188 | }
189 |
190 | t, err := ctyjson.ImpliedType(b)
191 | if err != nil {
192 | return "", err
193 | }
194 |
195 | doc, err := ctyjson.Unmarshal(b, t)
196 | if err != nil {
197 | return "", err
198 | }
199 |
200 | if doc.IsNull() {
201 | // skip empty YAML docs
202 | continue
203 | }
204 |
205 | if !doc.Type().IsObjectType() {
206 | return "", fmt.Errorf("the manifest must be a YAML document")
207 | }
208 |
209 | formatted, err := yamlToHCL(doc, providerAlias, stripServerSide, mapOnly, stripKeyQuotes)
210 | if err != nil {
211 | return "", fmt.Errorf("error converting YAML to HCL: %s", err)
212 | }
213 |
214 | if count > 0 {
215 | hcl += "\n"
216 | }
217 | hcl += formatted
218 | count++
219 | }
220 |
221 | return hcl, nil
222 | }
223 |
224 | func capturePanic() {
225 | if r := recover(); r != nil {
226 | fmt.Printf(
227 | "panic: %s\n\n%s\n\n"+
228 | "⚠️ Oh no! Looks like your manifest caused tfk8s to crash.\n\n"+
229 | "Please open a GitHub issue and include your manifest YAML with the stack trace above,\n"+
230 | "or ping me on slack and I'll try and fix it!\n\n"+
231 | "GitHub: https://github.com/jrhouston/tfk8s/issues\n"+
232 | "Slack: #terraform-providers on https://kubernetes.slack.com\n\n"+
233 | "- Thanks, @jrhouston\n\n",
234 | r, debug.Stack())
235 | }
236 | }
237 |
238 | func main() {
239 | defer capturePanic()
240 |
241 | infile := flag.StringP("file", "f", "-", "Input file containing Kubernetes YAML manifests")
242 | outfile := flag.StringP("output", "o", "-", "Output file to write Terraform config")
243 | providerAlias := flag.StringP("provider", "p", "", "Provider alias to populate the `provider` attribute")
244 | stripServerSide := flag.BoolP("strip", "s", false, "Strip out server side fields - use if you are piping from kubectl get")
245 | version := flag.BoolP("version", "V", false, "Show tool version")
246 | mapOnly := flag.BoolP("map-only", "M", false, "Output only an HCL map structure")
247 | stripKeyQuotes := flag.BoolP("strip-key-quotes", "Q", false, "Strip out quotes from HCL map keys unless they are required.")
248 | flag.Parse()
249 |
250 | if *version {
251 | fmt.Println(toolVersion)
252 | os.Exit(0)
253 | }
254 |
255 | var file *os.File
256 | if *infile == "-" {
257 | file = os.Stdin
258 | } else {
259 | var err error
260 | file, err = os.Open(*infile)
261 | if err != nil {
262 | fmt.Fprintf(os.Stderr, "error: %s\r\n", err.Error())
263 | os.Exit(1)
264 | }
265 | }
266 |
267 | hcl, err := YAMLToTerraformResources(
268 | file, *providerAlias, *stripServerSide, *mapOnly, *stripKeyQuotes)
269 | if err != nil {
270 | fmt.Fprintf(os.Stderr, "error: %s\r\n", err.Error())
271 | os.Exit(1)
272 | }
273 |
274 | if *outfile == "-" {
275 | fmt.Print(hcl)
276 | } else {
277 | err := os.WriteFile(*outfile, []byte(hcl), 0644)
278 | if err != nil {
279 | fmt.Fprintf(os.Stderr, "error: %s\r\n", err.Error())
280 | os.Exit(1)
281 | }
282 | }
283 | }
284 |
--------------------------------------------------------------------------------
/tfk8s_test.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | import (
4 | "strings"
5 | "testing"
6 |
7 | "github.com/stretchr/testify/assert"
8 | )
9 |
10 | func TestYAMLToTerraformResourcesSingle(t *testing.T) {
11 | yaml := `---
12 | apiVersion: v1
13 | kind: ConfigMap
14 | metadata:
15 | name: test
16 | data:
17 | TEST: test`
18 |
19 | r := strings.NewReader(yaml)
20 | output, err := YAMLToTerraformResources(r, "", false, false, false)
21 |
22 | if err != nil {
23 | t.Fatal("Converting to HCL failed:", err)
24 | }
25 |
26 | expected := `
27 | resource "kubernetes_manifest" "configmap_test" {
28 | manifest = {
29 | "apiVersion" = "v1"
30 | "data" = {
31 | "TEST" = "test"
32 | }
33 | "kind" = "ConfigMap"
34 | "metadata" = {
35 | "name" = "test"
36 | }
37 | }
38 | }`
39 |
40 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
41 | }
42 |
43 | func TestYAMLToTerraformResourcesGenerateName(t *testing.T) {
44 | yaml := `---
45 | apiVersion: v1
46 | kind: ConfigMap
47 | metadata:
48 | generateName: test-name-
49 | data:
50 | TEST: test`
51 |
52 | r := strings.NewReader(yaml)
53 | output, err := YAMLToTerraformResources(r, "", false, false, false)
54 |
55 | if err != nil {
56 | t.Fatal("Converting to HCL failed:", err)
57 | }
58 |
59 | expected := `
60 | resource "kubernetes_manifest" "configmap_test_name" {
61 | manifest = {
62 | "apiVersion" = "v1"
63 | "data" = {
64 | "TEST" = "test"
65 | }
66 | "kind" = "ConfigMap"
67 | "metadata" = {
68 | "generateName" = "test-name-"
69 | }
70 | }
71 | }`
72 |
73 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
74 | }
75 |
76 | func TestYAMLToTerraformResourcesEscapeShell(t *testing.T) {
77 | yaml := `---
78 | apiVersion: v1
79 | kind: ConfigMap
80 | metadata:
81 | name: test
82 | data:
83 | SCRIPT: |
84 | echo "Hello, ${USER} your homedir is ${HOME}"
85 | echo "\${SHELL_ESCAPE${TF_ESCAPE}}"`
86 |
87 | r := strings.NewReader(yaml)
88 | output, err := YAMLToTerraformResources(r, "", false, false, false)
89 |
90 | if err != nil {
91 | t.Fatal("Converting to HCL failed:", err)
92 | }
93 |
94 | expected := `
95 | resource "kubernetes_manifest" "configmap_test" {
96 | manifest = {
97 | "apiVersion" = "v1"
98 | "data" = {
99 | "SCRIPT" = <<-EOT
100 | echo "Hello, $${USER} your homedir is $${HOME}"
101 | echo "\$${SHELL_ESCAPE$${TF_ESCAPE}}"
102 | EOT
103 | }
104 | "kind" = "ConfigMap"
105 | "metadata" = {
106 | "name" = "test"
107 | }
108 | }
109 | }`
110 |
111 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
112 | }
113 |
114 | func TestYAMLToTerraformResourcesMultiple(t *testing.T) {
115 | yaml := `---
116 | apiVersion: v1
117 | kind: ConfigMap
118 | metadata:
119 | name: one
120 | data:
121 | TEST: one
122 | ---
123 | # this empty
124 | # document
125 | # should be
126 | # skipped
127 | ---
128 | apiVersion: v1
129 | kind: ConfigMap
130 | metadata:
131 | name: two
132 | data:
133 | TEST: two`
134 |
135 | r := strings.NewReader(yaml)
136 | output, err := YAMLToTerraformResources(r, "", false, false, false)
137 |
138 | if err != nil {
139 | t.Fatal("Converting to HCL failed:", err)
140 | }
141 |
142 | expected := `
143 | resource "kubernetes_manifest" "configmap_one" {
144 | manifest = {
145 | "apiVersion" = "v1"
146 | "data" = {
147 | "TEST" = "one"
148 | }
149 | "kind" = "ConfigMap"
150 | "metadata" = {
151 | "name" = "one"
152 | }
153 | }
154 | }
155 |
156 | resource "kubernetes_manifest" "configmap_two" {
157 | manifest = {
158 | "apiVersion" = "v1"
159 | "data" = {
160 | "TEST" = "two"
161 | }
162 | "kind" = "ConfigMap"
163 | "metadata" = {
164 | "name" = "two"
165 | }
166 | }
167 | }`
168 |
169 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
170 | }
171 |
172 | func TestYAMLToTerraformResourcesList(t *testing.T) {
173 | yaml := `---
174 | apiVersion: v1
175 | kind: ConfigMapList
176 | items:
177 | - apiVersion: v1
178 | kind: ConfigMap
179 | metadata:
180 | name: one
181 | data:
182 | TEST: one
183 | - apiVersion: v1
184 | kind: ConfigMap
185 | metadata:
186 | name: two
187 | data:
188 | TEST: two
189 | - apiVersion: v1
190 | kind: ConfigMap
191 | metadata:
192 | name: two
193 | namespace: othernamespace
194 | data:
195 | TEST: two
196 | `
197 |
198 | r := strings.NewReader(yaml)
199 | output, err := YAMLToTerraformResources(r, "", false, false, false)
200 |
201 | if err != nil {
202 | t.Fatal("Converting to HCL failed:", err)
203 | }
204 |
205 | expected := `
206 | resource "kubernetes_manifest" "configmap_one" {
207 | manifest = {
208 | "apiVersion" = "v1"
209 | "data" = {
210 | "TEST" = "one"
211 | }
212 | "kind" = "ConfigMap"
213 | "metadata" = {
214 | "name" = "one"
215 | }
216 | }
217 | }
218 |
219 | resource "kubernetes_manifest" "configmap_two" {
220 | manifest = {
221 | "apiVersion" = "v1"
222 | "data" = {
223 | "TEST" = "two"
224 | }
225 | "kind" = "ConfigMap"
226 | "metadata" = {
227 | "name" = "two"
228 | }
229 | }
230 | }
231 |
232 | resource "kubernetes_manifest" "configmap_othernamespace_two" {
233 | manifest = {
234 | "apiVersion" = "v1"
235 | "data" = {
236 | "TEST" = "two"
237 | }
238 | "kind" = "ConfigMap"
239 | "metadata" = {
240 | "name" = "two"
241 | "namespace" = "othernamespace"
242 | }
243 | }
244 | }`
245 |
246 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
247 | }
248 |
249 | func TestYAMLToTerraformResourcesProviderAlias(t *testing.T) {
250 | yaml := `---
251 | apiVersion: v1
252 | kind: ConfigMap
253 | metadata:
254 | name: test
255 | data:
256 | TEST: test`
257 |
258 | r := strings.NewReader(yaml)
259 | output, err := YAMLToTerraformResources(r, "kubernetes-alpha", false, false, false)
260 |
261 | if err != nil {
262 | t.Fatal("Converting to HCL failed:", err)
263 | }
264 |
265 | expected := `
266 | resource "kubernetes_manifest" "configmap_test" {
267 | provider = kubernetes-alpha
268 |
269 | manifest = {
270 | "apiVersion" = "v1"
271 | "data" = {
272 | "TEST" = "test"
273 | }
274 | "kind" = "ConfigMap"
275 | "metadata" = {
276 | "name" = "test"
277 | }
278 | }
279 | }`
280 |
281 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
282 | }
283 |
284 | func TestYAMLToTerraformResourcesProviderServerSideStrip(t *testing.T) {
285 | yaml := `---
286 | apiVersion: v1
287 | data:
288 | TEST: test
289 | kind: ConfigMap
290 | metadata:
291 | annotations:
292 | kubectl.kubernetes.io/last-applied-configuration: |
293 | {"apiVersion":"v1","data":{"TEST":"prod"},"test":"ConfigMap","metadata":{"annotations":{},"name":"test","namespace":"default"}}
294 | creationTimestamp: "2020-04-30T20:34:59Z"
295 | name: test
296 | namespace: default
297 | resourceVersion: "677134"
298 | selfLink: /api/v1/namespaces/default/configmaps/test
299 | uid: bea6500b-0637-4d2d-b726-e0bda0b595dd
300 | generation: 1
301 | finalizers:
302 | - test`
303 |
304 | r := strings.NewReader(yaml)
305 | output, err := YAMLToTerraformResources(r, "", true, false, false)
306 |
307 | if err != nil {
308 | t.Fatal("Converting to HCL failed:", err)
309 | }
310 |
311 | expected := `
312 | resource "kubernetes_manifest" "configmap_test" {
313 | manifest = {
314 | "apiVersion" = "v1"
315 | "data" = {
316 | "TEST" = "test"
317 | }
318 | "kind" = "ConfigMap"
319 | "metadata" = {
320 | "name" = "test"
321 | }
322 | }
323 | }`
324 |
325 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
326 | }
327 |
328 | func TestYAMLToTerraformResourcesMapOnly(t *testing.T) {
329 | yaml := `---
330 | apiVersion: v1
331 | data:
332 | TEST: test
333 | kind: ConfigMap
334 | metadata:
335 | name: test
336 | namespace: default
337 | resourceVersion: "677134"
338 | selfLink: /api/v1/namespaces/default/configmaps/test
339 | uid: bea6500b-0637-4d2d-b726-e0bda0b595dd`
340 |
341 | r := strings.NewReader(yaml)
342 | output, err := YAMLToTerraformResources(r, "", true, true, false)
343 |
344 | if err != nil {
345 | t.Fatal("Converting to HCL failed:", err)
346 | }
347 |
348 | expected := `{
349 | "apiVersion" = "v1"
350 | "data" = {
351 | "TEST" = "test"
352 | }
353 | "kind" = "ConfigMap"
354 | "metadata" = {
355 | "name" = "test"
356 | }
357 | }`
358 |
359 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
360 | }
361 |
362 | func TestYAMLToTerraformResourcesEmptyDocSkip(t *testing.T) {
363 | yaml := `---
364 | apiVersion: v1
365 | data:
366 | TEST: test
367 | kind: ConfigMap
368 | metadata:
369 | name: test
370 | namespace: default
371 | resourceVersion: "677134"
372 | selfLink: /api/v1/namespaces/default/configmaps/test
373 | uid: bea6500b-0637-4d2d-b726-e0bda0b595dd
374 | ---
375 |
376 | ---
377 | apiVersion: v1
378 | data:
379 | TEST: test
380 | kind: ConfigMap
381 | metadata:
382 | name: test2
383 | namespace: default
384 | resourceVersion: "677134"
385 | selfLink: /api/v1/namespaces/default/configmaps/test
386 | uid: bea6500b-0637-4d2d-b726-e0bda0b595dd`
387 |
388 | r := strings.NewReader(yaml)
389 | output, err := YAMLToTerraformResources(r, "", true, false, false)
390 |
391 | if err != nil {
392 | t.Fatal("Converting to HCL failed:", err)
393 | }
394 |
395 | expected := `resource "kubernetes_manifest" "configmap_test" {
396 | manifest = {
397 | "apiVersion" = "v1"
398 | "data" = {
399 | "TEST" = "test"
400 | }
401 | "kind" = "ConfigMap"
402 | "metadata" = {
403 | "name" = "test"
404 | }
405 | }
406 | }
407 |
408 | resource "kubernetes_manifest" "configmap_test2" {
409 | manifest = {
410 | "apiVersion" = "v1"
411 | "data" = {
412 | "TEST" = "test"
413 | }
414 | "kind" = "ConfigMap"
415 | "metadata" = {
416 | "name" = "test2"
417 | }
418 | }
419 | }
420 | `
421 |
422 | assert.Equal(t, strings.TrimSpace(expected), strings.TrimSpace(output))
423 | }
424 |
--------------------------------------------------------------------------------