diff --git a/.github/workflows/pr-flow.yml b/.github/workflows/pr-flow.yml new file mode 100644 index 0000000..117c8f5 --- /dev/null +++ b/.github/workflows/pr-flow.yml @@ -0,0 +1,32 @@ +name: Continuous Integration - PR +on: + pull_request: + +jobs: + test-app: + name: Test Application + runs-on: ubuntu-latest + steps: + - name: Clone repository + uses: actions/checkout@v3 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: 1.21 + - name: Test application + run: go test ./... + - name: Dry-run goreleaser application + uses: goreleaser/goreleaser-action@v4 + with: + distribution: goreleaser + version: latest + args: release --snapshot --skip-publish --clean + - name: Create temporary download for this PR for 1d + uses: actions/upload-artifact@v3 + with: + name: downloads + path: dist/ + if-no-files-found: error + retention-days: 1 diff --git a/.github/workflows/ci.yml b/.github/workflows/push-flow.yml similarity index 79% rename from .github/workflows/ci.yml rename to .github/workflows/push-flow.yml index bd67dc3..26ce7f2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/push-flow.yml @@ -1,18 +1,20 @@ name: Continuous Integration on: push: - pull_request: jobs: test-app: + name: Test Application runs-on: ubuntu-latest steps: - name: Clone repository uses: actions/checkout@v3.1.0 + with: + fetch-depth: 0 - name: Set up Go uses: actions/setup-go@v4 with: - go-version: 1.19 + go-version: 1.21 - name: Test application run: go test ./... - name: Compile application diff --git a/.goreleaser.yml b/.goreleaser.yml index 040dd96..d65540c 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -16,9 +16,15 @@ builds: ldflags: - -s -w -X main.version={{.Version}} -extldflags "-static" archives: - - replacements: - 386: i386 - amd64: x86_64 + - id: foo + name_template: >- + {{- .ProjectName }}_ + {{- .Version }}_ + {{- .Os }}_ + {{- if eq .Arch "amd64" }}x86_64 + {{- else if eq .Arch "386" }}i386 + {{- else }}{{ .Arch }}{{ end }} + {{- if .Arm }}v{{ .Arm }}{{ end -}} checksum: name_template: 'checksums.txt' snapshot: diff --git a/README.md b/README.md index d3e729d..400e3a3 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![Downloads](https://img.shields.io/github/downloads/patrickdappollonio/tgen/total?color=blue&logo=github&style=flat-square)](https://github.com/patrickdappollonio/tgen/releases) - `tgen` is a simple CLI application that allows you to write a template file and then use the power of Go Templates to generate an output (which is) outputted to `stdout`. Besides the Go Template engine itself, `tgen` contains a few extra utility functions to assist you when writing templates. See below for a description of each. You can also use the `--help` (or `-h`) to see the available set of options. The only flag required is the file to process, and everything else is optional. @@ -15,7 +14,7 @@ Usage: Flags: -e, --environment string an optional environment file to use (key=value formatted) to perform replacements - -f, --file string the template file to process + -f, --file string the template file to process, or "-" to read from stdin -d, --delimiter string template delimiter (default "{{}}") -x, --execute string a raw template to execute directly, without providing --file -v, --values string a file containing values to use for the template, a la Helm @@ -25,132 +24,68 @@ Flags: --version version for tgen ``` -### Environment file +## Usage -`tgen` supports an optional environment variable collection in a file but it's a pretty basic implementation of a simple key/value pair. The environment file works by finding lines that aren't empty or preceded by a pound `#` -- since they're treated as comments -- and then tries to find at least one equal (`=`) sign. If it can find at least one, all values on the left side of the equal sign become the key and the contents on the right side become the value. If the same line has more than one equal, only the first one is honored and all remaining ones become part of the value. +You can use `tgen`: -There's no support for Bash interpolation or multiline values. If this is needed, consider using a YAML values file instead. +* By reading environment variables from the environment or a key-value file +* By reading variables from a YAML values file -#### Example +While working with it, `tgen` supports a "strict" mode, where if a variable (either environment or from a values file) is used in the template but not set, it will fail the template generation. -Consider the following template, named `template.txt`: +## Examples -```handlebars -The dog licked the {{ env "element" }} and everyone laughed. -``` +### Simple template -And the following environment file, named `contents.env`: +Using a template file and an environment file, you can generate a template as follows: ```bash -element=Oil -``` +$ cat template.txt +The dog licked the {{ env "element" }} and everyone laughed. -After being passed to `tgen`, the output becomes: +$ cat contents.env +element=Oil -```bash $ tgen -e contents.env -f template.txt The dog licked the Oil and everyone laughed. ``` -Using the inline mode to execute a template, you can also call the program as such (note the use of single-quotes since in Go, strings are always double-quoted) which will yield the same result: - -```bash -$ tgen -x '{{ env "element" }}' -e contents.env -The dog licked the Oil and everyone laughed. -``` - -Do note as well that using single quotes for the template allows you to prevent any bash special parsing logic that your terminal might have. - -### Helm-style values - -`tgen` can be used to generate templates, in a very similar way as `helm` can be used. However, do note that `tgen`'s intention is not to replace `helm` since it can't handle application lifecycle the way `helm` does, however, it can do a great job generating resources with very similar code. - -Consider the following example of creating a Kubernetes secret for a `tls.crt` file -- in real environments, you'll also need the key, but for the sake of this example, it has been omitted. - -Checking the files in the folder: - -```bash -tree . -``` - -```text -. -├── secret.yaml -└── tls.crt - -0 directories, 2 files -``` +### Inline mode -We have a `secret.yaml` which includes `tgen` templating notation: +You can skip the template file altogether and use the inline mode to execute a template directly: ```bash -cat secret.yaml -``` +$ cat contents.env +element=Oil -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: secret-tls -type: kubernetes.io/tls -data: - tls.crt: | {{ readfile "tls.crt" | b64enc | nindent 4 }} +$ tgen -e contents.env -x 'The dog licked the {{ env "element" }} and everyone laughed.' +The dog licked the Oil and everyone laughed. ``` -The last line includes the following logic: - -* Reads the `tls.crt` file from the current directory where `tgen` is run -* Takes the contents of the file and converts it to `base64` -- required by Kubernetes secrets -* Then indents with 4 spaces, starting with a new line +### Helm-style values -To generate the output, we can now run `tgen`: +While `tgen` Helm-like support is currently limited to values, it allows for a powerful way to generate templates. Consider the following example: ```bash -tgen -f secret.yaml -``` - -And the output looks like this: - -```yaml -apiVersion: v1 -kind: Secret -metadata: - name: secret-tls -type: kubernetes.io/tls -data: - tls.crt: | - Rk9PQkFSQkFaCg== -``` +$ cat template.txt +The dog licked the {{ .element }} and everyone laughed. -This output can be then passed to Kubernetes as follows: +$ cat values.yaml +element: Oil -``` -tgen -f secret.yaml | kubectl apply -f - -``` - -Do keep in mind though your DevOps requirements in terms of keeping a copy of your YAML files, rendered. Additionally, the `readfile` function is akin to `helm`'s `.Files`, with the exception that **you can read any file the `tgen` binary has access**, including potentially sensitive files such as `/etc/passwd`. If this is a concern, please run `tgen` in a CI/CD environment or where access to these resources is limited. - -You can also use a `values.yaml` file like Helm. `tgen` will allow you to read values from the values file as `.variable` or `.Values.variable`. The latter is the same as Helm's `.Values.variable` and the former is a shortcut to `.Values.variable` for convenience. Consider the following YAML values file: - -```yaml -name: Patrick -``` - -And the following template: - -```handlebars -Hello, my name is {{ .name }}. +$ tgen -v values.yaml -f template.txt +The dog licked the Oil and everyone laughed. ``` -Running `tgen` with the values file will yield the following output: +In the last function call, if your file is named `values.yaml`, you can omit it calling it directly and instead use: ```bash -$ tgen -f template.yaml -v values.yaml -Hello, my name is Patrick. +$ tgen --with-values -f template.txt +The dog licked the Oil and everyone laughed. ``` -If your values file is called `values.yaml`, you also have the handy shortcut of simply specifying `--with-values` and `tgen` will automatically include the values file from the current working directory. +For more details, see the ["Kubernetes and Helm-style values" documentation page](docs/helm-style-values.md). -### Template functions +## Template functions See [template functions](docs/functions.md) for a list of all the functions available. diff --git a/command.go b/command.go index cb21989..d6ef4b8 100644 --- a/command.go +++ b/command.go @@ -15,7 +15,7 @@ func command(w io.Writer, c conf) error { // Read template from "-x" or "--execute" flag if c.stdinTemplateFile != "" { - tg.setTemplate("-", c.stdinTemplateFile) + tg.setTemplate(os.Stdin.Name(), c.stdinTemplateFile) } // Read template file (either from "--file" or stdin) @@ -23,7 +23,7 @@ func command(w io.Writer, c conf) error { var err error switch pathToOpen { case "-": - err = tg.loadTemplateFile("", os.Stdin) + err = tg.loadTemplateFile(os.Stdin.Name(), os.Stdin) default: err = tg.loadTemplatePath(pathToOpen) } diff --git a/docs/functions.md b/docs/functions.md index 79d82bc..a858713 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -13,6 +13,7 @@ - [`readfile`, `readlocalfile`](#readfile-readlocalfile) - [`linebyline`, `lbl`](#linebyline-lbl) - [`after`, `skip`](#after-skip) + - [`required`](#required) All examples below have been generated using `-x` -- or `--execute`, which allows passing a template as argument rather than reading a file. In either case, whether the template file -- with `-f` or `--file` -- or the template argument is used, all functions are available. @@ -91,6 +92,8 @@ Both `env` and `envdefault` are case insensitive -- either `"home"` or `"HOME"` When `--strict` mode is enabled, if `env` is called with a environment variable name with no value set or set to empty, the application will exit with error. Useful if you must receive a value or fail a CI build, for example. +Consider the following example reading these environment variables: + ```bash $ tgen -x '{{ env "user" }}' patrick @@ -99,16 +102,22 @@ $ tgen -x '{{ env "USER" }}' patrick ``` +Then trying to read a nonexistent environment variable with `--strict` mode enabled: + ```bash -$ tgen -x '{{ env "foobar" }}' -s -Error: strict mode on: environment variable not found: $FOOBAR +$ tgen -x '{{ env "foobar" }}' --strict +Error: evaluating /dev/stdin:1:3: strict mode on: environment variable not found: $FOOBAR ``` +And bypassing strict mode by setting a default value: + ```bash -$ tgen -x '{{ envdefault "SQL_HOST" "sql.example.com" }}' +$ tgen -x '{{ envdefault "SQL_HOST" "sql.example.com" }}' --strict sql.example.com ``` +For custom messages, [consider using `required` instead](#required). + ### `rndstring` Generates a random string of a given length: @@ -199,3 +208,19 @@ $ tgen -x '{{ after 2 (seq 5) }}' $ tgen -x '{{ seq 5 | after 2 }}' [3 4 5] ``` + +### `required` + +Returns an error if the value is empty. Useful to ensure a value is provided, and if not, fail the template generation. + +```bash +$ tgen -x '{{ env "foo" | required "environment variable \"foo\" is required" }}' +Error: evaluating /dev/stdin:1:15: environment variable "foo" is required +``` + +Note that you can also use `--strict` mode to achieve a similar result. The difference between `--strict` and `required` is that `required` works anywhere: not just on missing YAML value keys or environment variables. Here's another example: + +```bash +$ tgen -x '{{ "" | required "Value must be set" }}' +Error: evaluating /dev/stdin:1:8: Value must be set +``` diff --git a/docs/helm-style-values.md b/docs/helm-style-values.md new file mode 100644 index 0000000..b0d44e7 --- /dev/null +++ b/docs/helm-style-values.md @@ -0,0 +1,87 @@ +# Kubernetes & Helm-style values + +Consider the following example of creating a Kubernetes secret for a `tls.crt` file -- in real environments, you'll also need the key, but for the sake of this example, it has been omitted. + +Checking the files in the folder: + +```bash +tree . +``` + +```text +. +├── secret.yaml +└── tls.crt + +0 directories, 2 files +``` + +We have a `secret.yaml` which includes `tgen` templating notation: + +```bash +cat secret.yaml +``` + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: secret-tls +type: kubernetes.io/tls +data: + tls.crt: | {{ readfile "tls.crt" | b64enc | nindent 4 }} +``` + +The last line includes the following logic: + +* Reads the `tls.crt` file from the current directory where `tgen` is run +* Takes the contents of the file and converts it to `base64` -- required by Kubernetes secrets +* Then indents with 4 spaces, starting with a new line + +To generate the output, we can now run `tgen`: + +```bash +tgen -f secret.yaml +``` + +And the output looks like this: + +```yaml +apiVersion: v1 +kind: Secret +metadata: + name: secret-tls +type: kubernetes.io/tls +data: + tls.crt: | + Rk9PQkFSQkFaCg== +``` + +This output can be then passed to Kubernetes as follows: + +``` +tgen -f secret.yaml | kubectl apply -f - +``` + +Do keep in mind though your DevOps requirements in terms of keeping a copy of your YAML files, rendered. Additionally, the `readfile` function is akin to `helm`'s `.Files`, with the exception that **you can read any file the `tgen` binary has access**, including potentially sensitive files such as `/etc/passwd`. If this is a concern, please run `tgen` in a CI/CD environment or where access to these resources is limited. + +You can also use a `values.yaml` file like Helm. `tgen` will allow you to read values from the values file as `.variable` or `.Values.variable`. The latter is the same as Helm's `.Values.variable` and the former is a shortcut to `.Values.variable` for convenience. Consider the following YAML values file: + +```yaml +name: Patrick +``` + +And the following template: + +```handlebars +Hello, my name is {{ .name }}. +``` + +Running `tgen` with the values file will yield the following output: + +```bash +$ tgen -f template.yaml -v values.yaml +Hello, my name is Patrick. +``` + +If your values file is called `values.yaml`, you also have the handy shortcut of simply specifying `--with-values` and `tgen` will automatically include the values file from the current working directory. diff --git a/errors.go b/errors.go new file mode 100644 index 0000000..99e8b82 --- /dev/null +++ b/errors.go @@ -0,0 +1,46 @@ +package main + +import "fmt" + +type requiredError struct { + msg string +} + +func (r requiredError) Error() string { + return r.msg +} + +type templateFuncError struct { + line string + original error +} + +func (t templateFuncError) Error() string { + if t.line == "" { + return t.original.Error() + } + + return fmt.Sprintf("evaluating %s: %s", t.line, t.original) +} + +func (t templateFuncError) Unwrap() error { + return t.original +} + +type notFoundErr struct{ name string } + +func (e *notFoundErr) Error() string { + return "strict mode on: environment variable not found: $" + e.name +} + +type missingKeyErr struct{ name string } + +func (e *missingKeyErr) Error() string { + return "strict mode on: missing value in values file: " + e.name +} + +type conflictingArgsError struct{ F1, F2 string } + +func (e *conflictingArgsError) Error() string { + return fmt.Sprintf("defined both --%s and --%s, only one must be used", e.F1, e.F2) +} diff --git a/go.mod b/go.mod index 4a5624b..642fef4 100644 --- a/go.mod +++ b/go.mod @@ -19,6 +19,7 @@ require ( github.com/spf13/cast v1.3.1 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/crypto v0.3.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) go 1.19 diff --git a/go.sum b/go.sum index 27ff067..a69f382 100644 --- a/go.sum +++ b/go.sum @@ -67,7 +67,8 @@ golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8T gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go index 2299ef8..ae020fd 100644 --- a/main.go +++ b/main.go @@ -35,7 +35,7 @@ func run() error { } root.Flags().StringVarP(&configs.environmentFile, "environment", "e", "", "an optional environment file to use (key=value formatted) to perform replacements") - root.Flags().StringVarP(&configs.templateFilePath, "file", "f", "", "the template file to process") + root.Flags().StringVarP(&configs.templateFilePath, "file", "f", "", "the template file to process, or \"-\" to read from stdin") root.Flags().StringVarP(&configs.customDelimiters, "delimiter", "d", "", `template delimiter (default "{{}}")`) root.Flags().StringVarP(&configs.stdinTemplateFile, "execute", "x", "", "a raw template to execute directly, without providing --file") root.Flags().StringVarP(&configs.valuesFile, "values", "v", "", "a file containing values to use for the template, a la Helm") diff --git a/structs.go b/structs.go index f6daf70..b3665fd 100644 --- a/structs.go +++ b/structs.go @@ -1,9 +1,5 @@ package main -import ( - "fmt" -) - type conf struct { environmentFile string templateFilePath string @@ -12,21 +8,3 @@ type conf struct { strictMode bool customDelimiters string } - -type enotfounderr struct{ name string } - -func (e *enotfounderr) Error() string { - return "strict mode on: environment variable not found: $" + e.name -} - -type emissingkeyerr struct{ name string } - -func (e *emissingkeyerr) Error() string { - return "strict mode on: missing value in values file: " + e.name -} - -type conflictingArgsError struct{ F1, F2 string } - -func (e *conflictingArgsError) Error() string { - return fmt.Sprintf("defined both --%s and --%s, only one must be used", e.F1, e.F2) -} diff --git a/template_functions.go b/template_functions.go index 23ed755..eb60cce 100644 --- a/template_functions.go +++ b/template_functions.go @@ -16,11 +16,13 @@ import ( "unsafe" "github.com/Masterminds/sprig/v3" + "gopkg.in/yaml.v3" ) func getTemplateFunctions(virtualKV map[string]string, strict bool) template.FuncMap { return template.FuncMap{ - "raw": raw, + "raw": raw, + "required": requiredField, // Go built-ins "lowercase": sprig.FuncMap()["lower"], @@ -45,7 +47,49 @@ func getTemplateFunctions(virtualKV map[string]string, strict bool) template.Fun "lbl": linebyline, "after": after, "skip": after, + + "asMap": asMap, + "toYAML": toYAML, + } +} + +// requireField returns an error if the given value is nil or an empty string. +func requiredField(warn string, val interface{}) (interface{}, error) { + if val == nil { + return val, &requiredError{msg: warn} + } + + if s, ok := val.(string); ok && s == "" { + return val, &requiredError{msg: warn} } + + return val, nil +} + +// toYAML takes an interface, marshals it to yaml, and returns a string. It will +// always return a string, even on marshal error (empty string). +// +// This is designed to be called from a template. +func toYAML(v interface{}) string { + data, err := yaml.Marshal(v) + if err != nil { + // Swallow errors inside of a template. + return "" + } + return strings.TrimSuffix(string(data), "\n") +} + +func asMap(m any) map[string]any { + if m == nil { + return nil + } + + newmap, ok := m.(map[string]any) + if !ok { + return nil + } + + return newmap } func raw(s string) string { @@ -116,7 +160,7 @@ func envfunc(k string, kv map[string]string, strictMode bool) (string, error) { } if strictMode { - return "", &enotfounderr{name: k} + return "", ¬FoundErr{name: k} } return "", nil diff --git a/template_functions_test.go b/template_functions_test.go index 0124cdd..f269c60 100644 --- a/template_functions_test.go +++ b/template_functions_test.go @@ -188,3 +188,45 @@ func Test_rndgen(t *testing.T) { t.Errorf("rndgen() values must be different, got = %q, %q", got1, got2) } } + +func Test_requiredField(t *testing.T) { + tests := []struct { + name string + warn string + val interface{} + want interface{} + wantErr bool + }{ + { + name: "required field nullable", + warn: "required field must be set", + val: nil, + want: nil, + wantErr: true, + }, + { + name: "required field not nullable", + warn: "required field must be set", + val: "", + want: "", + wantErr: true, + }, + { + name: "required field set", + val: "hello", + want: "hello", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := requiredField(tt.warn, tt.val) + if (err != nil) != tt.wantErr { + t.Errorf("requiredField() error = %v, wantErr %v", err, tt.wantErr) + return + } + if !reflect.DeepEqual(got, tt.want) { + t.Errorf("requiredField() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/tgen.go b/tgen.go index 085ce9d..7250277 100644 --- a/tgen.go +++ b/tgen.go @@ -3,9 +3,11 @@ package main import ( "bufio" "bytes" + "errors" "fmt" "io" "os" + "regexp" "strings" "text/template" @@ -190,18 +192,47 @@ func (t *tgen) render(w io.Writer) error { return err } +// reExtractLocation is used to extract the line number from the error message +// as a string like "/foo/bar:1:18" +var reExtractLocation = regexp.MustCompile(`\s([^:]*:\d+:\d+):`) + func (t *tgen) replaceTemplateRenderError(err error) error { if err == nil { return nil } - if _, ok := err.(template.ExecError); ok { - if strings.Contains(err.Error(), "environment variable not found") { - return &enotfounderr{name: err.Error()[strings.LastIndex(err.Error(), ": $")+3:]} + // Go templates won't propagate the error message back to the caller, so the + // only way to know what happened is to parse the error message and return + // a more meaningful error. + if t, ok := err.(template.ExecError); ok { + + // Check if we can unwrap the error bubbled up from the template + if unwrap := errors.Unwrap(t.Err); unwrap != nil { + // The original error does not provide enough contextual information + // to know where the error happened, so we need to extract the line + // number from the error message + matchExpr := reExtractLocation.FindStringSubmatch(t.Err.Error()) + match := "" + if len(matchExpr) > 0 { + match = matchExpr[1] + } + + switch unwrap.(type) { + case *requiredError, *notFoundErr: + return &templateFuncError{line: match, original: unwrap} + default: + // do nothing, the next section will take care + // of checking for additional items + } } - if strings.Contains(err.Error(), "map has no entry for key") { - return &emissingkeyerr{name: err.Error()[strings.LastIndex(err.Error(), ":")+2:]} + // If we can't unwrap, it means we're dealing with string-based errors + // which are even harder to validate + switch { + case strings.Contains(err.Error(), "map has no entry for key"): + return &missingKeyErr{name: err.Error()[strings.LastIndex(err.Error(), ":")+2:]} + default: + return t.Err } } diff --git a/vendor/modules.txt b/vendor/modules.txt index bb25feb..9d51590 100644 --- a/vendor/modules.txt +++ b/vendor/modules.txt @@ -43,6 +43,8 @@ golang.org/x/crypto/bcrypt golang.org/x/crypto/blowfish golang.org/x/crypto/pbkdf2 golang.org/x/crypto/scrypt +# gopkg.in/yaml.v2 v2.4.0 +## explicit; go 1.15 # gopkg.in/yaml.v3 v3.0.1 ## explicit gopkg.in/yaml.v3