├── .github ├── FUNDING.yml └── workflows │ ├── release.yml │ └── test.yml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── README.md ├── VERSION ├── funcs.go ├── go.mod ├── go.sum ├── gucci.go ├── gucci_test.go ├── test └── integration │ ├── fixtures │ ├── issue63.tpl │ ├── issue63.yaml │ ├── multifiles │ │ ├── first.yaml │ │ ├── second.yaml │ │ └── template.tpl │ ├── nesting.tpl │ ├── nesting_vars.yaml │ ├── precedence.tpl │ ├── precedence_vars.yaml │ ├── simple.tpl │ └── simple_vars.yaml │ ├── integration_suite_test.go │ └── integration_test.go ├── util.go └── util_test.go /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [noqcks] 2 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: 6 | - '*' 7 | 8 | permissions: 9 | contents: write # needed to write releases 10 | id-token: write # needed for keyless signing 11 | packages: write # needed for ghcr access 12 | 13 | jobs: 14 | test: 15 | runs-on: "ubuntu-latest" 16 | steps: 17 | - uses: actions/checkout@v3 18 | - name: Set up Go 19 | uses: actions/setup-go@v3 20 | with: 21 | go-version: 1.22.x 22 | - name: Install Ginkgo v2 23 | run: go install github.com/onsi/ginkgo/v2/ginkgo@latest 24 | - name: Test 25 | run: ginkgo ./... 26 | 27 | release: 28 | needs: [test] 29 | env: 30 | GO111MODULE: on 31 | name: Release 32 | runs-on: "ubuntu-latest" 33 | steps: 34 | - uses: actions/checkout@v3 35 | with: 36 | fetch-depth: 0 37 | 38 | - name: Version 39 | run: echo "VERSION=$(cat VERSION)" >> $GITHUB_ENV 40 | 41 | - name: Set up Go 42 | uses: actions/setup-go@v3 43 | with: 44 | go-version-file: 'go.mod' 45 | go-version: 1.22.x 46 | 47 | - name: Install syft 48 | run: curl -sSfL https://raw.githubusercontent.com/anchore/syft/main/install.sh | sh -s -- -b /usr/local/bin 49 | 50 | - name: Install cosign 51 | uses: sigstore/cosign-installer@v3.5.0 52 | 53 | - name: Run GoReleaser 54 | uses: goreleaser/goreleaser-action@v4 55 | with: 56 | distribution: goreleaser 57 | version: latest 58 | args: release --clean 59 | env: 60 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 61 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: push 4 | 5 | jobs: 6 | test: 7 | runs-on: "ubuntu-latest" 8 | steps: 9 | - uses: actions/checkout@v3 10 | - name: Set up Go 11 | uses: actions/setup-go@v3 12 | with: 13 | go-version: 1.22.x 14 | - name: Install Ginkgo v2 15 | run: go install github.com/onsi/ginkgo/v2/ginkgo@latest 16 | - name: Test 17 | run: ginkgo ./... 18 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | dist/ 3 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | # yaml-language-server: $schema=https://goreleaser.com/static/schema.json 2 | before: 3 | hooks: 4 | # You may remove this if you don't use go modules. 5 | - go mod tidy 6 | # you may remove this if you don't need go generate 7 | - go generate ./... 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | - windows 14 | - darwin 15 | goarch: 16 | - amd64 17 | - arm64 18 | mod_timestamp: '{{ .CommitTimestamp }}' 19 | ldflags: 20 | - -X main.AppVersion={{.Env.VERSION}} -w -extldflags static 21 | 22 | archives: 23 | - format: binary 24 | name_template: "{{ .ProjectName }}-v{{ .Version }}-{{ .Os }}-{{ .Arch }}" 25 | 26 | checksum: 27 | name_template: 'checksums.txt' 28 | 29 | gomod: 30 | proxy: true 31 | 32 | source: 33 | enabled: true 34 | 35 | sboms: 36 | - artifacts: archive 37 | - id: source 38 | artifacts: source 39 | 40 | signs: 41 | - cmd: cosign 42 | env: 43 | - COSIGN_EXPERIMENTAL=1 44 | certificate: '${artifact}.pem' 45 | args: 46 | - sign-blob 47 | - '--output-certificate=${certificate}' 48 | - '--output-signature=${signature}' 49 | - '${artifact}' 50 | - "--yes" # needed on cosign 2.0.0+ 51 | artifacts: checksum 52 | output: true 53 | 54 | snapshot: 55 | name_template: "{{ incpatch .Tag }}-next" 56 | 57 | changelog: 58 | sort: asc 59 | filters: 60 | exclude: 61 | - '^docs:' 62 | - '^test:' 63 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2025 Benji Visser 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining 4 | a copy of this software and associated documentation files (the 5 | "Software"), to deal in the Software without restriction, including 6 | without limitation the rights to use, copy, modify, merge, publish, 7 | distribute, sublicense, and/or sell copies of the Software, and to 8 | permit persons to whom the Software is furnished to do so, subject to 9 | the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be 12 | included in all copies or substantial portions of the Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 15 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 16 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 17 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 18 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 19 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 20 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Gucci 2 | 3 | A simple CLI templating tool written in golang. 4 | 5 | [![GitHub version](https://badge.fury.io/gh/noqcks%2Fgucci.svg)](https://badge.fury.io/gh/noqcks%2Fgucci) 6 | 7 | ## Installation 8 | 9 | If you have `go` installed: 10 | 11 | ``` 12 | $ go get github.com/noqcks/gucci 13 | ``` 14 | 15 | Or you can just download the binary and move it into your `PATH`: 16 | 17 | ``` 18 | VERSION=1.8.0 19 | wget -q "https://github.com/noqcks/gucci/releases/download/v${VERSION}/gucci-v${VERSION}-darwin-amd64" 20 | chmod +x gucci-v${VERSION}-darwin-amd64 21 | mv gucci-v${VERSION}-darwin-amd64 /usr/local/bin/gucci 22 | ``` 23 | 24 | ## Use 25 | 26 | ### Locating Templates 27 | 28 | `gucci` can locate a template in multiple ways. 29 | 30 | #### File 31 | 32 | Pass the template file path as the first argument: 33 | 34 | ``` 35 | $ gucci template.tpl > template.out 36 | ``` 37 | 38 | #### Stdin 39 | 40 | Supply the template through standard input: 41 | 42 | ```bash 43 | $ gucci 44 | Start typing stuff {{ print "here" }} 45 | ^d 46 | Start typing stuff here 47 | ``` 48 | 49 | Via piping: 50 | 51 | ```bash 52 | $ echo '{{ html "" }}' | gucci 53 | ``` 54 | 55 | ### Supplying Variable Inputs 56 | 57 | `gucci` can receive variables for use in templates in the following ways (in order of lowest to highest precedence): 58 | 59 | - A JSON or YAML file 60 | - Environment variables 61 | - Variable command options 62 | 63 | #### Variables File 64 | 65 | Given an example variables file: 66 | 67 | ```yaml 68 | # vars.yaml 69 | hosts: 70 | - name: bastion 71 | - name: app 72 | ``` 73 | 74 | Pass it into `gucci` with `-f` or `--vars-file`: 75 | 76 | ```bash 77 | $ gucci -f vars.yaml template.tpl 78 | ``` 79 | 80 | Multiple variables files can be provided, and will be merged in the order specified (later files override values from earlier files): 81 | 82 | ```bash 83 | $ gucci -f base_vars.yaml -f override_vars.yaml template.tpl 84 | ``` 85 | 86 | #### Environment Variables 87 | 88 | Here, `MY_HOST` is available to the template: 89 | 90 | ```bash 91 | $ export MY_HOST=localhost 92 | $ gucci template.tpl 93 | ``` 94 | 95 | #### Variable Options 96 | 97 | Pass variable options into `gucci` with `-s` or `--set-var`, which can be repeated: 98 | 99 | ```bash 100 | $ gucci -s foo.bar=baz template.tpl 101 | ``` 102 | 103 | Variable option keys are split on the `.` character, and nested such that 104 | the above example would equate to the following yaml variable input: 105 | 106 | ```yaml 107 | foo: 108 | bar: baz 109 | ``` 110 | 111 | ## Templating 112 | 113 | ### Options 114 | 115 | Existing [golang templating options](https://golang.org/pkg/text/template/#Template.Option) can be used for templating. 116 | 117 | If no option is specified, the `missingkey=error` option will be used (execution stops immediately with an error if a 118 | key used in the template is not present in the supplied values). 119 | 120 | One might want a different value for `missingkey` when using conditionals and having keys that won't be 121 | used at all. 122 | 123 | For instance, given the following template, containing two docker-compose services `service1` and `service2`: 124 | 125 | ```tpl 126 | # template.tpl 127 | version: "3.8" 128 | 129 | services: 130 | {{- if .service1 }} 131 | service1: 132 | image: {{ .service1.image }} 133 | restart: "always" 134 | ports: {{ toYaml .service1.ports | nindent 6}} 135 | {{- end }} 136 | {{- if .service2 }} 137 | service2: 138 | image: {{ .service2.image }} 139 | restart: "unless-stopped" 140 | ports: {{ toYaml .service2.ports | nindent 6}} 141 | {{- end }} 142 | ``` 143 | 144 | And imagine a scenario where whe only need `service2`. By using the following values file: 145 | 146 | ```yaml 147 | # values.yaml 148 | service2: 149 | image: "myservice:latest" 150 | ports: 151 | - "80" 152 | - "443" 153 | ``` 154 | 155 | And using a different `missingkey=error`, we can actually get the desired result without having to define the values 156 | for `service1`: 157 | 158 | ```shell 159 | $ gucci -o missingkey=zero -f values.yaml template.tpl 160 | version: "3.8" 161 | 162 | services: 163 | service2: 164 | image: myservice:latest 165 | restart: "unless-stopped" 166 | ports: 167 | - "80" 168 | - "443" 169 | ``` 170 | 171 | ### GoLang Functions 172 | 173 | All of the existing [golang templating functions](https://golang.org/pkg/text/template/#hdr-Functions) are available for use. 174 | 175 | ### Sprig Functions 176 | 177 | gucci ships with the [sprig templating functions library](http://masterminds.github.io/sprig/) offering a wide variety of template helpers. 178 | 179 | ### Built In Functions 180 | 181 | Furthermore, this tool also includes custom functions: 182 | 183 | - `shell`: For arbitrary shell commands 184 | 185 | ``` 186 | {{ shell "echo hello world" }} 187 | ``` 188 | 189 | and 190 | 191 | ``` 192 | # guest: world 193 | {{ shell "echo hello " .guest }} 194 | ``` 195 | 196 | Both produce: 197 | 198 | ``` 199 | hello world 200 | ``` 201 | 202 | - `toYaml`: Print items in YAML format 203 | 204 | ``` 205 | {{ $myList := list "a" "b" "c" }} 206 | {{ toYaml $myList }} 207 | ``` 208 | 209 | Produces: 210 | 211 | ``` 212 | - a 213 | - b 214 | - c 215 | ``` 216 | 217 | ### Example 218 | 219 | **NOTE**: gucci reads and makes available all environment variables. 220 | 221 | For example a var $LOCALHOST = 127.0.0.1 222 | 223 | gucci template.tpl > template.conf 224 | 225 | ``` 226 | # template.tpl 227 | {{ .LOCALHOST }} 228 | ``` 229 | 230 | `gucci template.tpl > template.conf` --> 231 | 232 | ``` 233 | # template.conf 234 | 127.0.0.1 235 | ``` 236 | 237 | simple enough! 238 | 239 | For an iteration example, you have $BACKENDS=server1.com,server2.com 240 | 241 | ``` 242 | # template.tpl 243 | {{ range split .BACKENDS "," }} 244 | server {{ . }} 245 | {{ end }} 246 | ``` 247 | 248 | `gucci template.tpl > template.conf` --> 249 | 250 | ``` 251 | # template.conf 252 | server server1.com 253 | server server2.com 254 | ``` 255 | 256 | ## Testing 257 | 258 | Setup: 259 | 260 | ```bash 261 | go get github.com/onsi/ginkgo/v2/ginkgo 262 | go get github.com/onsi/gomega 263 | ``` 264 | 265 | Run tests: 266 | 267 | ```bash 268 | ginkgo ./... 269 | ``` 270 | -------------------------------------------------------------------------------- /VERSION: -------------------------------------------------------------------------------- 1 | 1.6.13 -------------------------------------------------------------------------------- /funcs.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "os/exec" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/Masterminds/sprig/v3" 12 | "github.com/pkg/errors" 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | func getFuncMap(t *template.Template) template.FuncMap { 17 | f := sprig.TxtFuncMap() 18 | 19 | f["include"] = include(t) 20 | f["shell"] = shell 21 | f["toYaml"] = toYaml 22 | 23 | f["toJson"] = toJson 24 | f["mustToJson"] = mustToJson 25 | 26 | return f 27 | } 28 | 29 | // toJson converts a value to JSON with special handling for map[interface{}]interface{} 30 | // This function overrides Sprig's toJson function to properly handle YAML-parsed data. 31 | // When working with templates, we often load data from YAML files which creates maps with 32 | // interface{} keys, but JSON only supports string keys. This implementation ensures that 33 | // data loaded from YAML (or other sources) with non-string keys is properly converted 34 | // before JSON serialization, preventing common "json: unsupported type" errors when 35 | // templates try to convert complex nested structures to JSON. 36 | func toJson(v interface{}) (string, error) { 37 | // Convert any map[interface{}]interface{} to map[string]interface{} 38 | jsonCompatible := convertToJSONCompatible(v) 39 | data, err := json.Marshal(jsonCompatible) 40 | if err != nil { 41 | return "", errors.Wrap(err, "error calling toJson") 42 | } 43 | return string(data), nil 44 | } 45 | 46 | // mustToJson is like toJson but panics on error 47 | // we need to override this because sprig's mustToJson doesn't handle 48 | // map[interface{}]interface{} which is what we get from yaml 49 | func mustToJson(v interface{}) string { 50 | s, err := toJson(v) 51 | if err != nil { 52 | panic(err) 53 | } 54 | return s 55 | } 56 | 57 | // convertToJSONCompatible converts YAML parsed data (with interface{} keys) 58 | // to data with only string keys for JSON compatibility 59 | func convertToJSONCompatible(v interface{}) interface{} { 60 | switch v := v.(type) { 61 | case map[interface{}]interface{}: 62 | // Convert map with interface{} keys to map with string keys 63 | result := make(map[string]interface{}) 64 | for k, v := range v { 65 | result[fmt.Sprintf("%v", k)] = convertToJSONCompatible(v) 66 | } 67 | return result 68 | case []interface{}: 69 | // Convert each item in the slice 70 | for i, item := range v { 71 | v[i] = convertToJSONCompatible(item) 72 | } 73 | } 74 | return v 75 | } 76 | 77 | func include(t *template.Template) func(templateName string, vars ...interface{}) (string, error) { 78 | return func(templateName string, vars ...interface{}) (string, error) { 79 | if len(vars) > 1 { 80 | return "", errors.New(fmt.Sprintf("Call to include may pass zero or one vars structure, got %v.", len(vars))) 81 | } 82 | buf := bytes.NewBuffer(nil) 83 | included := t.Lookup(templateName) 84 | if included == nil { 85 | return "", errors.New(fmt.Sprintf("No such template '%v' found while calling 'include'.", templateName)) 86 | } 87 | 88 | if err := included.ExecuteTemplate(buf, templateName, vars[0]); err != nil { 89 | return "", err 90 | } 91 | return buf.String(), nil 92 | } 93 | } 94 | 95 | func shell(cmd ...string) (string, error) { 96 | out, err := exec.Command("bash", "-c", strings.Join(cmd[:], "")).Output() 97 | output := strings.TrimSpace(string(out)) 98 | if err != nil { 99 | return "", errors.Wrap(err, "Issue running command: "+output) 100 | } 101 | 102 | return output, nil 103 | } 104 | 105 | func toYaml(v interface{}) (string, error) { 106 | data, err := yaml.Marshal(v) 107 | if err != nil { 108 | return "", errors.Wrap(err, "Issue marsahling yaml") 109 | } 110 | return string(data), nil 111 | } 112 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/noqcks/gucci 2 | 3 | go 1.23.0 4 | 5 | toolchain go1.24.1 6 | 7 | require ( 8 | github.com/Masterminds/sprig/v3 v3.3.0 9 | github.com/imdario/mergo v0.3.16 10 | github.com/onsi/ginkgo/v2 v2.23.0 11 | github.com/onsi/gomega v1.36.2 12 | github.com/pkg/errors v0.9.1 13 | github.com/urfave/cli v1.22.16 14 | gopkg.in/yaml.v2 v2.4.0 15 | ) 16 | 17 | require ( 18 | dario.cat/mergo v1.0.1 // indirect 19 | github.com/Masterminds/goutils v1.1.1 // indirect 20 | github.com/Masterminds/semver/v3 v3.3.1 // indirect 21 | github.com/cpuguy83/go-md2man/v2 v2.0.6 // indirect 22 | github.com/go-logr/logr v1.4.2 // indirect 23 | github.com/go-task/slim-sprig/v3 v3.0.0 // indirect 24 | github.com/google/go-cmp v0.6.0 // indirect 25 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect 26 | github.com/google/uuid v1.6.0 // indirect 27 | github.com/huandu/xstrings v1.5.0 // indirect 28 | github.com/mitchellh/copystructure v1.2.0 // indirect 29 | github.com/mitchellh/reflectwalk v1.0.2 // indirect 30 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 31 | github.com/shopspring/decimal v1.4.0 // indirect 32 | github.com/spf13/cast v1.7.1 // indirect 33 | golang.org/x/crypto v0.36.0 // indirect 34 | golang.org/x/net v0.35.0 // indirect 35 | golang.org/x/sys v0.31.0 // indirect 36 | golang.org/x/text v0.23.0 // indirect 37 | golang.org/x/tools v0.30.0 // indirect 38 | gopkg.in/yaml.v3 v3.0.1 // indirect 39 | ) 40 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= 2 | dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= 3 | github.com/BurntSushi/toml v1.4.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= 4 | github.com/Masterminds/goutils v1.1.1 h1:5nUrii3FMTL5diU80unEVvNevw1nH4+ZV4DSLVJLSYI= 5 | github.com/Masterminds/goutils v1.1.1/go.mod h1:8cTjp+g8YejhMuvIA5y2vz3BpJxksy863GQaJW2MFNU= 6 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 7 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 8 | github.com/Masterminds/sprig/v3 v3.3.0 h1:mQh0Yrg1XPo6vjYXgtf5OtijNAKJRNcTdOOGZe3tPhs= 9 | github.com/Masterminds/sprig/v3 v3.3.0/go.mod h1:Zy1iXRYNqNLUolqCpL4uhk6SHUMAOSCzdgBfDb35Lz0= 10 | github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 11 | github.com/cpuguy83/go-md2man/v2 v2.0.6 h1:XJtiaUW6dEEqVuZiMTn1ldk455QWwEIsMIJlo5vtkx0= 12 | github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= 13 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 15 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 16 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= 17 | github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0= 18 | github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY= 19 | github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= 20 | github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1vB6EwHI= 21 | github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= 22 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 23 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 24 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad h1:a6HEuzUHeKH6hwfN/ZoQgRgVIWFJljSWa/zetS2WTvg= 25 | github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad/go.mod h1:vavhavw2zAxS5dIdcRluK6cSGGPlZynqzFM8NdvU144= 26 | github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= 27 | github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= 28 | github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI= 29 | github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= 30 | github.com/imdario/mergo v0.3.16 h1:wwQJbIsHYGMUyLSPrEq1CT16AhnhNJQ51+4fdHUnCl4= 31 | github.com/imdario/mergo v0.3.16/go.mod h1:WBLT9ZmE3lPoWsEzCh9LPo3TiwVN+ZKEjmz+hD27ysY= 32 | github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= 33 | github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= 34 | github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= 35 | github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 36 | github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw= 37 | github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s= 38 | github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ= 39 | github.com/mitchellh/reflectwalk v1.0.2/go.mod h1:mSTlrgnPZtwu0c4WaC2kGObEpuNDbx0jmZXqmk4esnw= 40 | github.com/onsi/ginkgo/v2 v2.23.0 h1:FA1xjp8ieYDzlgS5ABTpdUDB7wtngggONc8a7ku2NqQ= 41 | github.com/onsi/ginkgo/v2 v2.23.0/go.mod h1:zXTP6xIp3U8aVuXN8ENK9IXRaTjFnpVB9mGmaSRvxnM= 42 | github.com/onsi/gomega v1.36.2 h1:koNYke6TVk6ZmnyHrCXba/T/MoLBXFjeC1PtvYgw0A8= 43 | github.com/onsi/gomega v1.36.2/go.mod h1:DdwyADRjrc825LhMEkD76cHR5+pUnjhUN8GlHlRPHzY= 44 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 45 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 46 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 47 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 48 | github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= 49 | github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= 50 | github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= 51 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 52 | github.com/shopspring/decimal v1.4.0 h1:bxl37RwXBklmTi0C79JfXCEBD1cqqHt0bbgBAGFp81k= 53 | github.com/shopspring/decimal v1.4.0/go.mod h1:gawqmDU56v4yIKSwfBSFip1HdCCXN8/+DMd9qYNcwME= 54 | github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y= 55 | github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= 56 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 57 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 58 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 59 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 60 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 61 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 62 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 63 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 64 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 65 | github.com/urfave/cli v1.22.16 h1:MH0k6uJxdwdeWQTwhSO42Pwr4YLrNLwBtg1MRgTqPdQ= 66 | github.com/urfave/cli v1.22.16/go.mod h1:EeJR6BKodywf4zciqrdw6hpCPk68JO9z5LazXZMn5Po= 67 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 68 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 69 | golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= 70 | golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 71 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 72 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 73 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 74 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 75 | golang.org/x/tools v0.30.0 h1:BgcpHewrV5AUp2G9MebG4XPFI1E2W41zU1SaqVA9vJY= 76 | golang.org/x/tools v0.30.0/go.mod h1:c347cR/OJfw5TI+GfX7RUPNMdDRRbjvYTS0jPyvsVtY= 77 | google.golang.org/protobuf v1.36.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk= 78 | google.golang.org/protobuf v1.36.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= 79 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 80 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 81 | gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= 82 | gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= 83 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 84 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 85 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 86 | -------------------------------------------------------------------------------- /gucci.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "log" 7 | "os" 8 | "text/template" 9 | 10 | "github.com/imdario/mergo" 11 | 12 | "github.com/urfave/cli" 13 | ) 14 | 15 | var logger = log.New(os.Stderr, "", 0) 16 | 17 | const ( 18 | flagSetVar = "s" 19 | flagSetVarLong = flagSetVar + ",set-var" 20 | 21 | flagVarsFile = "f" 22 | flagVarsFileLong = flagVarsFile + ",vars-file" 23 | 24 | flagSetOpt = "o" 25 | flagSetOptLong = flagSetOpt + ",tpl-opt" 26 | ) 27 | 28 | var ( 29 | AppVersion = "0.0.0-dev.0" // Injected 30 | ) 31 | 32 | func main() { 33 | app := cli.NewApp() 34 | app.Name = "gucci" 35 | app.Usage = "simple CLI Go lang templating" 36 | app.UsageText = app.Name + " [options] [template]" 37 | app.Version = AppVersion 38 | 39 | app.Flags = []cli.Flag{ 40 | cli.StringSliceFlag{ 41 | Name: flagSetVarLong, 42 | Usage: "A `KEY=VALUE` pair variable", 43 | }, 44 | cli.StringSliceFlag{ 45 | Name: flagVarsFileLong, 46 | Usage: "A json or yaml `FILE` from which to read variables (can be specified multiple times)", 47 | }, 48 | cli.StringSliceFlag{ 49 | Name: flagSetOptLong, 50 | Usage: "A template option (`KEY=VALUE`) to be applied", 51 | Value: &cli.StringSlice{"missingkey=error"}, 52 | }, 53 | } 54 | 55 | app.Action = func(c *cli.Context) error { 56 | tplPath := c.Args().First() 57 | vars, err := loadVariables(c) 58 | if err != nil { 59 | return cli.NewExitError(err, 1) 60 | } 61 | err = run(tplPath, vars, c.StringSlice(flagSetOpt)) 62 | if err != nil { 63 | return cli.NewExitError(err, 1) 64 | } 65 | return nil 66 | } 67 | app.Run(os.Args) 68 | } 69 | 70 | func loadInputVarsFile(c *cli.Context) (map[string]interface{}, error) { 71 | vars := make(map[string]interface{}) 72 | 73 | varsFiles := c.StringSlice(flagVarsFile) 74 | for _, varsFilePath := range varsFiles { 75 | if varsFilePath != "" { 76 | v, err := loadVarsFile(varsFilePath) 77 | if err != nil { 78 | return nil, err 79 | } 80 | 81 | err = mergo.Merge(&vars, v, mergo.WithOverride) 82 | if err != nil { 83 | return nil, err 84 | } 85 | } 86 | } 87 | 88 | return vars, nil 89 | } 90 | 91 | func loadInputVarsOptions(c *cli.Context) (map[string]interface{}, error) { 92 | 93 | vars := make(map[string]interface{}) 94 | 95 | for _, varStr := range c.StringSlice(flagSetVar) { 96 | key, val := getKeyVal(varStr) 97 | varMap := keyValToMap(key, val) 98 | 99 | err := mergo.Merge(&vars, varMap, mergo.WithOverride) 100 | if err != nil { 101 | return nil, err 102 | } 103 | } 104 | 105 | return vars, nil 106 | } 107 | 108 | func loadVariables(c *cli.Context) (map[string]interface{}, error) { 109 | 110 | vars, err := loadInputVarsFile(c) 111 | if err != nil { 112 | return nil, err 113 | } 114 | 115 | envVars := env() 116 | err = mergo.Merge(&vars, envVars, mergo.WithOverride) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | optVars, err := loadInputVarsOptions(c) 122 | if err != nil { 123 | return nil, err 124 | } 125 | 126 | err = mergo.Merge(&vars, optVars, mergo.WithOverride) 127 | if err != nil { 128 | return nil, err 129 | } 130 | 131 | return vars, nil 132 | } 133 | 134 | func executeTemplate(valuesIn map[string]interface{}, out io.Writer, tpl *template.Template, opt []string) error { 135 | tpl.Option(opt...) 136 | err := tpl.Execute(out, valuesIn) 137 | if err != nil { 138 | return fmt.Errorf("Failed to parse standard input: %v", err) 139 | } 140 | return nil 141 | } 142 | 143 | func run(tplPath string, vars map[string]interface{}, tplOpt []string) error { 144 | tpl, err := loadTemplateFileOrStdin(tplPath) 145 | if err != nil { 146 | return err 147 | } 148 | 149 | err = executeTemplate(vars, os.Stdout, tpl, tplOpt) 150 | if err != nil { 151 | return err 152 | } 153 | 154 | return nil 155 | } 156 | 157 | func logError(msg string, err error) { 158 | logger.Println(msg) 159 | logger.Println(err.Error()) 160 | } 161 | -------------------------------------------------------------------------------- /gucci_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | var testVarMap = map[string]interface{}{ 11 | "TEST": "green", 12 | } 13 | 14 | func TestSub(t *testing.T) { 15 | tpl := `{{ .TEST }}` 16 | if err := runTest(tpl, "green"); err != nil { 17 | t.Error(err) 18 | } 19 | } 20 | 21 | func TestFuncIncludeNoVars(t *testing.T) { 22 | tpl := `{{ define "a" }}Hi Jane{{ end }}{{ include "a" . }}` 23 | if err := runTest(tpl, "Hi Jane"); err != nil { 24 | t.Error(err) 25 | } 26 | } 27 | 28 | func TestFuncIncludeWithVars(t *testing.T) { 29 | tpl := `{{ define "a" }}Hi {{ .name }}{{ end }}{{ $_ := set . "name" "John" }}{{ include "a" . }}` 30 | if err := runTest(tpl, "Hi John"); err != nil { 31 | t.Error(err) 32 | } 33 | } 34 | 35 | func TestFuncIncludePipe(t *testing.T) { 36 | tpl := `{{ define "a" }}Hi Jane{{ end }}{{ include "a" . | indent 2 }}` 37 | if err := runTest(tpl, " Hi Jane"); err != nil { 38 | t.Error(err) 39 | } 40 | } 41 | 42 | func TestFuncIncludeTooManyVars(t *testing.T) { 43 | tpl := `{{ define "a" }}Hi {{ .name }}{{ end }}{{ $_ := set . "name" "John" }}{{ include "a" . . }}` 44 | if err := runTest(tpl, ""); err == nil { 45 | t.Error("expected error to many vars") 46 | } 47 | } 48 | 49 | func TestFuncIncludeBadName(t *testing.T) { 50 | tpl := `{{ define "a" }}Hi Jane{{ end }}{{ include "b" . }}` 51 | if err := runTest(tpl, ""); err == nil { 52 | t.Error("expected error bad template name") 53 | } 54 | } 55 | 56 | func TestFuncShell(t *testing.T) { 57 | tpl := `{{ shell "echo hello" }}` 58 | if err := runTest(tpl, "hello"); err != nil { 59 | t.Error(err) 60 | } 61 | } 62 | 63 | func TestFuncShellArguments(t *testing.T) { 64 | tpl := `{{ shell "echo " "hello" "world"}}` 65 | if err := runTest(tpl, "helloworld"); err != nil { 66 | t.Error(err) 67 | } 68 | } 69 | 70 | func TestFuncShellError(t *testing.T) { 71 | tpl := `{{ shell "non-existent" }}` 72 | if err := runTest(tpl, ""); err == nil { 73 | t.Error("expected error missing") 74 | } 75 | } 76 | 77 | func TestFuncShellDetailedError(t *testing.T) { 78 | tpl := `{{ shell "echo saboteur ; exit 1" }}` 79 | err := runTest(tpl, "") 80 | if !strings.Contains(err.Error(), "saboteur") { 81 | t.Error("expected stdout in error missing. actual: ", err) 82 | } 83 | } 84 | 85 | func TestFuncShellPipe(t *testing.T) { 86 | tpl := `{{ shell "echo foo | grep foo" }}` 87 | if err := runTest(tpl, "foo"); err != nil { 88 | t.Error(err) 89 | } 90 | } 91 | 92 | func TestFuncToYaml(t *testing.T) { 93 | tpl := `{{ list "a" "b" "c" | toYaml }}` 94 | if err := runTest(tpl, "- a\n- b\n- c\n"); err != nil { 95 | t.Error(err) 96 | } 97 | } 98 | 99 | func runTest(str, expect string) error { 100 | tpl, err := loadTemplateString("test", str) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | var b bytes.Buffer 106 | err = executeTemplate(testVarMap, &b, tpl, []string{}) 107 | if err != nil { 108 | return err 109 | } 110 | if b.String() != expect { 111 | return fmt.Errorf("Expected '%s', got '%s'", expect, b.String()) 112 | } 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /test/integration/fixtures/issue63.tpl: -------------------------------------------------------------------------------- 1 | {{- $l := list $.testTwo $.testTwo -}} 2 | {{- range $i := $l }} 3 | {{- range $j := $i }} 4 | V ({{ kindOf $j }}): {{ $j | mustToJson | nindent 2 }} 5 | {{- end }} 6 | {{- end }} 7 | 8 | {{- /* Test case for numeric and boolean keys */ -}} 9 | Numeric Keys (toJson): {{ $.testThree.numericKeys | toJson }} 10 | Boolean Keys (mustToJson): {{ $.testThree.booleanKeys | mustToJson }} 11 | 12 | {{- /* Test case for deeply nested structures */ -}} 13 | Deeply Nested (toJson): {{ $.testThree.deeplyNested | toJson }} 14 | 15 | {{- /* Test case for mixed array types */ -}} 16 | Mixed Array (mustToJson): {{ $.testThree.mixedArray | mustToJson }} 17 | 18 | {{- /* Test the entire structure */ -}} 19 | Complete testThree (toJson): {{ $.testThree | toJson }} -------------------------------------------------------------------------------- /test/integration/fixtures/issue63.yaml: -------------------------------------------------------------------------------- 1 | testOne: 2 | - name: common 3 | type: common-use-stuff 4 | version: "1.0" 5 | - name: shovel 6 | type: "" 7 | version: "2.0.0" 8 | - name: gardener 9 | type: "" 10 | version: "3.1.0" 11 | 12 | testTwo: 13 | - name: common 14 | repository: "some stuff for common use" 15 | version: "3.1.0" 16 | - name: shovel 17 | version: "2.1.0" 18 | - name: gardener 19 | version: "3.2.0" 20 | - name: chauncey 21 | version: "3.9.0" 22 | 23 | testThree: 24 | # Nested structure with mixed values 25 | nestedMap: 26 | stringKey: "string value" 27 | numericKey: 12345 28 | booleanValue: true 29 | nullValue: null 30 | listValue: 31 | - "item1" 32 | - "item2" 33 | objectValue: 34 | subKey1: "nested value 1" 35 | subKey2: "nested value 2" 36 | 37 | # Map with numeric keys (this is what causes the interface{} key issue) 38 | numericKeys: 39 | 1: "first value" 40 | 2: "second value" 41 | 3: 42 | subKey: "nested under numeric key" 43 | 44 | # Map with boolean keys (another problematic case) 45 | booleanKeys: 46 | true: "value for true" 47 | false: "value for false" 48 | 49 | # Deeply nested structure to test recursion 50 | deeplyNested: 51 | level1: 52 | level2: 53 | level3: 54 | level4: 55 | 5: "five levels deep with numeric key" 56 | array: 57 | - name: "deep array item" 58 | properties: 59 | 6: "numeric key in deep array item" 60 | 61 | # Mixed array types 62 | mixedArray: 63 | - "string item" 64 | - 123 65 | - true 66 | - null 67 | - key1: "value1" 68 | key2: "value2" 69 | - [1, 2, 3] -------------------------------------------------------------------------------- /test/integration/fixtures/multifiles/first.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | A: from_first 3 | B: from_first 4 | C: from_first 5 | FIRST_ONLY: exists -------------------------------------------------------------------------------- /test/integration/fixtures/multifiles/second.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | B: from_second 3 | C: from_second 4 | SECOND_ONLY: exists -------------------------------------------------------------------------------- /test/integration/fixtures/multifiles/template.tpl: -------------------------------------------------------------------------------- 1 | A={{.A}} 2 | B={{.B}} 3 | C={{.C}} 4 | FIRST_ONLY={{.FIRST_ONLY}} 5 | SECOND_ONLY={{.SECOND_ONLY}} -------------------------------------------------------------------------------- /test/integration/fixtures/nesting.tpl: -------------------------------------------------------------------------------- 1 | {{ .foo.bar.baz }} 2 | -------------------------------------------------------------------------------- /test/integration/fixtures/nesting_vars.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | foo: 3 | bar: 4 | baz: yep 5 | -------------------------------------------------------------------------------- /test/integration/fixtures/precedence.tpl: -------------------------------------------------------------------------------- 1 | A={{ .A }} 2 | B={{ .B }} 3 | C={{ .C }} 4 | -------------------------------------------------------------------------------- /test/integration/fixtures/precedence_vars.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | A: from_file 3 | B: from_file 4 | C: from_file 5 | -------------------------------------------------------------------------------- /test/integration/fixtures/simple.tpl: -------------------------------------------------------------------------------- 1 | text {{ .FOO }} text 2 | -------------------------------------------------------------------------------- /test/integration/fixtures/simple_vars.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | FOO: bar 3 | -------------------------------------------------------------------------------- /test/integration/integration_suite_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "path/filepath" 7 | "reflect" 8 | "runtime" 9 | "strings" 10 | "testing" 11 | 12 | . "github.com/onsi/ginkgo/v2" 13 | . "github.com/onsi/gomega" 14 | "github.com/onsi/gomega/gexec" 15 | ) 16 | 17 | var gucciPath string 18 | 19 | // For determining package name 20 | type Noop struct{} 21 | 22 | func TestIntegration(t *testing.T) { 23 | RegisterFailHandler(Fail) 24 | RunSpecs(t, "Integration Suite") 25 | } 26 | 27 | func getGucciPackage() string { 28 | thisPkg := reflect.TypeOf(Noop{}).PkgPath() 29 | parts := strings.Split(thisPkg, "/") 30 | return strings.Join(parts[0:len(parts)-2], "/") 31 | } 32 | 33 | var _ = SynchronizedBeforeSuite(func() []byte { 34 | binPath, err := gexec.Build(getGucciPackage()) 35 | Expect(err).NotTo(HaveOccurred()) 36 | return []byte(binPath) 37 | }, func(data []byte) { 38 | gucciPath = string(data) 39 | }) 40 | 41 | func RunWithError(gucciCmd *exec.Cmd, expectedError int) *gexec.Session { 42 | return runWithExitCode(gucciCmd, expectedError) 43 | } 44 | 45 | func Run(gucciCmd *exec.Cmd) *gexec.Session { 46 | return runWithExitCode(gucciCmd, 0) 47 | } 48 | 49 | func runWithExitCode(gucciCmd *exec.Cmd, expectedError int) *gexec.Session { 50 | session, err := gexec.Start(gucciCmd, GinkgoWriter, GinkgoWriter) 51 | Expect(err).NotTo(HaveOccurred()) 52 | Eventually(session).Should(gexec.Exit(expectedError)) 53 | return session 54 | } 55 | 56 | func FixturePath(fixture string) string { 57 | _, basedir, _, ok := runtime.Caller(0) 58 | if !ok { 59 | // Don't assert here because it can be called outside of an It() 60 | panic(fmt.Errorf("Fixture not found: %s", fixture)) 61 | } 62 | 63 | f := filepath.Join(basedir, "../fixtures/", fixture) 64 | return f 65 | } 66 | -------------------------------------------------------------------------------- /test/integration/integration_test.go: -------------------------------------------------------------------------------- 1 | package integration_test 2 | 3 | import ( 4 | "os" 5 | "os/exec" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | ) 10 | 11 | var _ = Describe("gucci", func() { 12 | 13 | Describe("template source", func() { 14 | 15 | It("reads stdin", func() { 16 | gucciCmd := exec.Command(gucciPath) 17 | 18 | tpl, err := os.Open(FixturePath("simple.tpl")) 19 | defer tpl.Close() 20 | Expect(err).NotTo(HaveOccurred()) 21 | gucciCmd.Stdin = tpl 22 | 23 | session := RunWithError(gucciCmd, 1) 24 | 25 | Expect(string(session.Err.Contents())).To(Equal("Failed to parse standard input: template: -:1:8: executing \"-\" at <.FOO>: map has no entry for key \"FOO\"\n")) 26 | }) 27 | 28 | It("loads file", func() { 29 | gucciCmd := exec.Command(gucciPath, FixturePath("simple.tpl")) 30 | 31 | session := RunWithError(gucciCmd, 1) 32 | 33 | Expect(string(session.Err.Contents())).To(Equal("Failed to parse standard input: template: simple.tpl:1:8: executing \"simple.tpl\" at <.FOO>: map has no entry for key \"FOO\"\n")) 34 | }) 35 | 36 | }) 37 | 38 | Describe("variable source", func() { 39 | 40 | It("reads env vars", func() { 41 | gucciCmd := exec.Command(gucciPath, FixturePath("simple.tpl")) 42 | gucciCmd.Env = []string{ 43 | "FOO=bar", 44 | } 45 | 46 | session := Run(gucciCmd) 47 | 48 | Expect(string(session.Out.Contents())).To(Equal("text bar text\n")) 49 | }) 50 | 51 | It("loads vars file", func() { 52 | gucciCmd := exec.Command(gucciPath, 53 | "-f", FixturePath("simple_vars.yaml"), 54 | FixturePath("simple.tpl")) 55 | 56 | session := Run(gucciCmd) 57 | 58 | Expect(string(session.Out.Contents())).To(Equal("text bar text\n")) 59 | }) 60 | 61 | It("loads multiple vars files", func() { 62 | gucciCmd := exec.Command(gucciPath, 63 | "-f", FixturePath("precedence_vars.yaml"), 64 | "-f", FixturePath("simple_vars.yaml"), 65 | FixturePath("simple.tpl")) 66 | 67 | session := Run(gucciCmd) 68 | 69 | Expect(string(session.Out.Contents())).To(Equal("text bar text\n")) 70 | }) 71 | 72 | It("uses vars options", func() { 73 | gucciCmd := exec.Command(gucciPath, 74 | "-s", "FOO=bar", 75 | FixturePath("simple.tpl")) 76 | 77 | session := Run(gucciCmd) 78 | 79 | Expect(string(session.Out.Contents())).To(Equal("text bar text\n")) 80 | }) 81 | }) 82 | 83 | Describe("variable precedence", func() { 84 | 85 | It("should override variables sources", func() { 86 | gucciCmd := exec.Command(gucciPath, 87 | "-s", "C=from_opt", 88 | "-f", FixturePath("precedence_vars.yaml"), 89 | FixturePath("precedence.tpl")) 90 | gucciCmd.Env = []string{ 91 | "B=from_env", 92 | "C=from_env", 93 | } 94 | 95 | session := Run(gucciCmd) 96 | 97 | Expect(string(session.Out.Contents())).To(Equal("A=from_file\nB=from_env\nC=from_opt\n")) 98 | }) 99 | 100 | }) 101 | 102 | Describe("variable nesting", func() { 103 | 104 | It("should nest file variables", func() { 105 | gucciCmd := exec.Command(gucciPath, 106 | "-f", FixturePath("nesting_vars.yaml"), 107 | FixturePath("nesting.tpl")) 108 | 109 | session := Run(gucciCmd) 110 | 111 | Expect(string(session.Out.Contents())).To(Equal("yep\n")) 112 | }) 113 | 114 | It("should nest option variables", func() { 115 | gucciCmd := exec.Command(gucciPath, 116 | "-s", "foo.bar.baz=yep", 117 | FixturePath("nesting.tpl")) 118 | 119 | session := Run(gucciCmd) 120 | 121 | Expect(string(session.Out.Contents())).To(Equal("yep\n")) 122 | }) 123 | 124 | }) 125 | 126 | Describe("toJson and mustToJson functions", func() { 127 | It("should handle map[interface {}]interface {} in toJson and mustToJson", func() { 128 | gucciCmd := exec.Command(gucciPath, 129 | "-f", FixturePath("issue63.yaml"), 130 | FixturePath("issue63.tpl")) 131 | 132 | session := Run(gucciCmd) 133 | 134 | Expect(session.ExitCode()).To(Equal(0)) 135 | }) 136 | }) 137 | 138 | Describe("multiple vars files", func() { 139 | It("should load variables from multiple files", func() { 140 | gucciCmd := exec.Command(gucciPath, 141 | "-f", FixturePath("multifiles/first.yaml"), 142 | "-f", FixturePath("multifiles/second.yaml"), 143 | FixturePath("multifiles/template.tpl")) 144 | 145 | session := Run(gucciCmd) 146 | 147 | output := string(session.Out.Contents()) 148 | Expect(output).To(ContainSubstring("A=from_first")) 149 | Expect(output).To(ContainSubstring("B=from_second")) 150 | Expect(output).To(ContainSubstring("C=from_second")) 151 | Expect(output).To(ContainSubstring("FIRST_ONLY=exists")) 152 | Expect(output).To(ContainSubstring("SECOND_ONLY=exists")) 153 | }) 154 | 155 | It("should respect file order for precedence", func() { 156 | // Second file specified last should override first file 157 | gucciCmd := exec.Command(gucciPath, 158 | "-f", FixturePath("multifiles/first.yaml"), 159 | "-f", FixturePath("multifiles/second.yaml"), 160 | FixturePath("multifiles/template.tpl")) 161 | 162 | session := Run(gucciCmd) 163 | 164 | Expect(string(session.Out.Contents())).To(ContainSubstring("B=from_second")) 165 | 166 | // First file specified last should override second file 167 | gucciCmd = exec.Command(gucciPath, 168 | "-f", FixturePath("multifiles/second.yaml"), 169 | "-f", FixturePath("multifiles/first.yaml"), 170 | FixturePath("multifiles/template.tpl")) 171 | 172 | session = Run(gucciCmd) 173 | 174 | Expect(string(session.Out.Contents())).To(ContainSubstring("B=from_first")) 175 | }) 176 | 177 | It("should still respect option variables over file variables", func() { 178 | gucciCmd := exec.Command(gucciPath, 179 | "-f", FixturePath("multifiles/first.yaml"), 180 | "-f", FixturePath("multifiles/second.yaml"), 181 | "-s", "C=from_opt", 182 | FixturePath("multifiles/template.tpl")) 183 | 184 | session := Run(gucciCmd) 185 | 186 | Expect(string(session.Out.Contents())).To(ContainSubstring("C=from_opt")) 187 | }) 188 | }) 189 | 190 | }) 191 | -------------------------------------------------------------------------------- /util.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | "text/template" 12 | 13 | "gopkg.in/yaml.v2" 14 | ) 15 | 16 | func env() map[string]interface{} { 17 | env := make(map[string]interface{}) 18 | for _, i := range os.Environ() { 19 | key, val := getKeyVal(i) 20 | env[key] = val 21 | } 22 | return env 23 | } 24 | 25 | func getKeyVal(item string) (key, val string) { 26 | splits := strings.Split(item, "=") 27 | key = splits[0] 28 | val = strings.Join(splits[1:], "=") 29 | return key, val 30 | } 31 | 32 | func loadTemplateFile(tplFile string) (*template.Template, error) { 33 | tplName := filepath.Base(tplFile) 34 | tpl := template.New(tplName) 35 | _, err := tpl.Funcs(getFuncMap(tpl)).ParseFiles(tplFile) 36 | if err != nil { 37 | return nil, fmt.Errorf("Error parsing template(s): %v", err) 38 | } 39 | return tpl, nil 40 | } 41 | 42 | func loadTemplateStream(name string, in io.Reader) (*template.Template, error) { 43 | tplBytes, err := ioutil.ReadAll(in) 44 | if err != nil { 45 | return nil, fmt.Errorf("Error reading template(s): %v", err) 46 | } 47 | tplStr := string(tplBytes) 48 | return loadTemplateString(name, tplStr) 49 | } 50 | 51 | func loadTemplateString(name, s string) (*template.Template, error) { 52 | tpl := template.New(name) 53 | tpl, err := tpl.Funcs(getFuncMap(tpl)).Parse(s) 54 | if err != nil { 55 | return nil, fmt.Errorf("Error parsing template(s): %v", err) 56 | } 57 | return tpl, nil 58 | } 59 | 60 | func loadTemplateFileOrStdin(f string) (*template.Template, error) { 61 | var tpl *template.Template 62 | if f == "" { 63 | t, err := loadTemplateStream("-", os.Stdin) 64 | if err != nil { 65 | return nil, err 66 | } 67 | tpl = t 68 | } else { 69 | t, err := loadTemplateFile(f) 70 | if err != nil { 71 | return nil, err 72 | } 73 | tpl = t 74 | } 75 | return tpl, nil 76 | } 77 | 78 | func isJsonFile(path string) bool { 79 | path = strings.ToLower(path) 80 | return strings.HasSuffix(path, "json") 81 | } 82 | 83 | func isYamlFile(path string) bool { 84 | path = strings.ToLower(path) 85 | return strings.HasSuffix(path, "yaml") || 86 | strings.HasSuffix(path, "yml") 87 | } 88 | 89 | func loadVarsFile(path string) (map[string]interface{}, error) { 90 | var result map[string]interface{} 91 | var err error 92 | 93 | content, err := ioutil.ReadFile(path) 94 | if err != nil { 95 | return nil, err 96 | } 97 | 98 | if isJsonFile(path) { 99 | result, err = unmarshalJsonFile(content) 100 | } else if isYamlFile(path) { 101 | result, err = unmarshalYamlFile(content) 102 | } else { 103 | err = fmt.Errorf("unsupported variables file type: %s", path) 104 | } 105 | 106 | if err != nil { 107 | return nil, err 108 | } 109 | 110 | return result, err 111 | } 112 | 113 | func unmarshalJsonFile(content []byte) (map[string]interface{}, error) { 114 | var vars map[string]interface{} 115 | err := json.Unmarshal(content, &vars) 116 | if err != nil { 117 | return nil, err 118 | } 119 | return vars, nil 120 | } 121 | 122 | func unmarshalYamlFile(content []byte) (map[string]interface{}, error) { 123 | var vars map[string]interface{} 124 | err := yaml.Unmarshal(content, &vars) 125 | if err != nil { 126 | return nil, err 127 | } 128 | return vars, nil 129 | } 130 | 131 | func keyValToMap(key, val string) map[string]interface{} { 132 | parts := strings.Split(key, ".") 133 | 134 | // Reverse order 135 | for i, j := 0, len(parts)-1; i < j; i, j = i+1, j-1 { 136 | parts[i], parts[j] = parts[j], parts[i] 137 | } 138 | 139 | m := map[string]interface{}{ 140 | parts[0]: val, 141 | } 142 | 143 | for _, part := range parts[1:] { 144 | m = map[string]interface{}{ 145 | part: m, 146 | } 147 | } 148 | 149 | return m 150 | } 151 | -------------------------------------------------------------------------------- /util_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "reflect" 6 | "testing" 7 | ) 8 | 9 | func TestGetKeyVal(t *testing.T) { 10 | tests := []struct { 11 | in, k, v string 12 | }{ 13 | {"k=v", "k", "v"}, 14 | {"kv", "kv", ""}, 15 | {"=kv", "", "kv"}, 16 | } 17 | for _, tt := range tests { 18 | k, v := getKeyVal(tt.in) 19 | if k != tt.k || v != tt.v { 20 | t.Errorf("broken behavior. Expected: %#v Got: %v %v", tt, k, v) 21 | } 22 | } 23 | } 24 | 25 | func TestEnv(t *testing.T) { 26 | os.Setenv("k", "v") 27 | envs := env() 28 | if v, ok := envs["k"]; !ok || (ok && v != "v") { 29 | t.Errorf("broken behavior. Expected: %v. Got: %v", "v", v) 30 | } 31 | } 32 | 33 | func TestKeyValToMap(t *testing.T) { 34 | tests := []struct { 35 | key string 36 | value string 37 | expected map[string]interface{} 38 | }{ 39 | { 40 | "foo", 41 | "bar", 42 | map[string]interface{}{ 43 | "foo": "bar", 44 | }, 45 | }, 46 | { 47 | "foo1.foo2", 48 | "bar", 49 | map[string]interface{}{ 50 | "foo1": map[string]interface{}{ 51 | "foo2": "bar", 52 | }, 53 | }, 54 | }, 55 | { 56 | "foo1.foo2.foo3", 57 | "bar", 58 | map[string]interface{}{ 59 | "foo1": map[string]interface{}{ 60 | "foo2": map[string]interface{}{ 61 | "foo3": "bar", 62 | }, 63 | }, 64 | }, 65 | }, 66 | } 67 | for _, test := range tests { 68 | r := keyValToMap(test.key, test.value) 69 | if !reflect.DeepEqual(r, test.expected) { 70 | t.Errorf("broken behavior. Expected: %v. Got: %v", test.expected, r) 71 | } 72 | } 73 | } 74 | --------------------------------------------------------------------------------