From c0f362d9bb53e937dd37f737569e45cbb74ffd93 Mon Sep 17 00:00:00 2001 From: Rob Watson Date: Mon, 16 May 2022 02:06:05 +0200 Subject: [PATCH] Add first iteration and test suite --- .drone.yml | 14 ++++ README.md | 61 +++++++++++++++ go.mod | 14 ++++ go.sum | 13 ++++ main.go | 47 ++++++++++++ scanner/scanner.go | 76 ++++++++++++++++++ scanner/scanner_test.go | 166 ++++++++++++++++++++++++++++++++++++++++ 7 files changed, 391 insertions(+) create mode 100644 .drone.yml create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 scanner/scanner.go create mode 100644 scanner/scanner_test.go diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..ec9653a --- /dev/null +++ b/.drone.yml @@ -0,0 +1,14 @@ +--- +kind: pipeline +type: docker +name: default + +steps: +- name: go-1.18 + image: golang:1.18 + commands: + - go install honnef.co/go/tools/cmd/staticcheck@latest + - go build ./... + - go vet ./... + - staticcheck ./... + - go test -bench=. -benchmem -cover ./... diff --git a/README.md b/README.md new file mode 100644 index 0000000..ec97808 --- /dev/null +++ b/README.md @@ -0,0 +1,61 @@ +# envfilesubst + +envfilesubst is a variation of gettext's envsubst, with a different modus +operandi. + +Firstly, instead of reading the current environment, it instead parses a file +in "envfile" format. + +Secondly, it will read from standard input and replace all variable references +that can be found in the envfile. If variables are not explicitly mentioned in +the envfile, the references will be left intact (instead of replacing them with +an empty string). + +## Installation + +``` +go install git.netflux.io/rob/envfilesubst@latest +``` + +## Usage + +Given an envfile: + +``` +FOO=bar +X=1 +``` + +Then: + +``` +echo "FOO is $FOO and X is ${X}. I don't know $BAR." | envfilesubst -f myenvfile +``` + +The output is: + +``` +FOO is bar and X is 1. I don't know $BAR. +``` + +## License + +Copyright © 2022 Rob Watson. + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the “Software”), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies +of the Software, and to permit persons to whom the Software is furnished to do +so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..1dfa985 --- /dev/null +++ b/go.mod @@ -0,0 +1,14 @@ +module git.netflux.io/rob/envfilesubst + +go 1.18 + +require ( + github.com/hashicorp/go-envparse v0.0.0-20200406174449-d9cfd743a15e + github.com/stretchr/testify v1.7.1 +) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..a6a3a3d --- /dev/null +++ b/go.sum @@ -0,0 +1,13 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/hashicorp/go-envparse v0.0.0-20200406174449-d9cfd743a15e h1:v1d9+AJMP6i4p8BSKNU0InuvmIAdZjQLNN19V86AG4Q= +github.com/hashicorp/go-envparse v0.0.0-20200406174449-d9cfd743a15e/go.mod h1:/NlxCzN2D4C4L2uDE6ux/h6jM+n98VFQM14nnCIfHJU= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +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.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/main.go b/main.go new file mode 100644 index 0000000..fefd12c --- /dev/null +++ b/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "git.netflux.io/rob/envfilesubst/scanner" +) + +const version = "0.1" + +func main() { + var ( + path string + printVersion bool + ) + + flag.StringVar(&path, "f", "", "The envfile to read from") + flag.BoolVar(&printVersion, "version", false, "Print version and exit") + flag.Parse() + + if printVersion { + fmt.Fprintf(os.Stderr, "envfilesubst version %s\n", version) + os.Exit(0) + } + + if path == "" { + fmt.Fprint(os.Stderr, "envfilesubst reads an input from stdin and pipes it to stdout, replacing $ENV_VAR or ${ENV_VAR} occurrences that can be found in the provided envfile.\n\n") + fmt.Fprint(os.Stderr, "Variables that cannot be found in envfile are left unchanged.\n\n") + fmt.Fprint(os.Stderr, "Usage:\n") + flag.PrintDefaults() + os.Exit(1) + } + + envfile, err := os.Open(path) + if err != nil { + fmt.Fprintf(os.Stderr, "Error: %v", err) + os.Exit(1) + } + + scanner := scanner.New(os.Stdout, os.Stdin, envfile) + if err := scanner.Scan(); err != nil { + fmt.Fprintf(os.Stderr, "Error: %v", err) + os.Exit(1) + } +} diff --git a/scanner/scanner.go b/scanner/scanner.go new file mode 100644 index 0000000..fe87021 --- /dev/null +++ b/scanner/scanner.go @@ -0,0 +1,76 @@ +package scanner + +import ( + "bufio" + "fmt" + "io" + "regexp" + "strings" + + "github.com/hashicorp/go-envparse" +) + +var envvarRegex = regexp.MustCompile(`\$\{?([A-Z][A-Z0-9_]*)\}?`) + +type Scanner struct { + envfile, r io.Reader + w io.Writer +} + +// New returns a new Scanner with the provided arguments. +func New(w io.Writer, r io.Reader, envfile io.Reader) *Scanner { + return &Scanner{ + w: w, + r: r, + envfile: envfile, + } +} + +const nl = "\n" + +func (s *Scanner) Scan() error { + vars, err := envparse.Parse(s.envfile) + if err != nil { + return fmt.Errorf("error parsing envfile: %v", err) + } + + scanner := bufio.NewScanner(s.r) + for scanner.Scan() { + text := scanner.Text() + matchIndices := envvarRegex.FindAllStringSubmatchIndex(text, -1) + + var sb strings.Builder + var c int + for _, idx := range matchIndices { + m1, m2, n1, n2 := idx[0], idx[1], idx[2], idx[3] + writeString(&sb, text[c:m1]) + c = m2 + + name := text[n1:n2] + if val, ok := vars[name]; ok { + writeString(&sb, val) + } else { + writeString(&sb, text[m1:m2]) + } + } + + writeString(&sb, text[c:]) + writeString(&sb, nl) + + if _, err := s.w.Write([]byte(sb.String())); err != nil { + return fmt.Errorf("error writing to output: %v", err) + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("error reading input: %v", err) + } + + return nil +} + +// writeString writes a string to a StringBuilder, discarding the result and +// (non-existent) error. +func writeString(sb *strings.Builder, s string) { + _, _ = sb.WriteString(s) +} diff --git a/scanner/scanner_test.go b/scanner/scanner_test.go new file mode 100644 index 0000000..82373f1 --- /dev/null +++ b/scanner/scanner_test.go @@ -0,0 +1,166 @@ +package scanner_test + +import ( + "bytes" + "strings" + "testing" + + "git.netflux.io/rob/envfilesubst/scanner" + "github.com/stretchr/testify/assert" +) + +func TestScanner(t *testing.T) { + envfile := `FOO=bar +BAR=baz +BAZ=123 +FOO_BAR=true +QUUX1=2 +` + + testCases := []struct { + name string + envfile string + input string + wantOutput string + wantError string + }{ + { + name: "single variable", + envfile: envfile, + input: "$FOO", + wantOutput: "bar\n", + }, + { + name: "input with prefix text", + envfile: envfile, + input: "baz $FOO", + wantOutput: "baz bar\n", + }, + { + name: "input with suffix text", + envfile: envfile, + input: "$FOO baz", + wantOutput: "bar baz\n", + }, + { + name: "input with prefix and suffix text", + envfile: envfile, + input: "baz $FOO qux", + wantOutput: "baz bar qux\n", + }, + { + name: "input with prefix and suffix text and whitespace", + envfile: envfile, + input: "\tbaz $FOO qux ", + wantOutput: "\tbaz bar qux \n", + }, + { + name: "single variable with curly brackets", + envfile: envfile, + input: "${FOO}", + wantOutput: "bar\n", + }, + { + name: "multiple variables on a single line", + envfile: envfile, + input: "qux ${FOO} quxx $BAR $BAZ", + wantOutput: "qux bar quxx baz 123\n", + }, + { + name: "non-existent variable", + envfile: envfile, + input: "$NOPE", + wantOutput: "$NOPE\n", + }, + { + name: "non-existent variable with curly brackets", + envfile: envfile, + input: "${NOPE}", + wantOutput: "${NOPE}\n", + }, + { + name: "multiple variables including non-existent", + envfile: envfile, + input: "$FOO $BAR $NOPE $BAZ", + wantOutput: "bar baz $NOPE 123\n", + }, + { + name: "variable name with an underscore", + envfile: envfile, + input: "$FOO_BAR is true", + wantOutput: "true is true\n", + }, + { + name: "variable name with a number", + envfile: envfile, + input: "$QUUX1 + ${QUUX1} = 4", + wantOutput: "2 + 2 = 4\n", + }, + { + name: "multiline input ending with newline", + envfile: envfile, + input: `--- +metadata: + name: "$FOO" + labels: + bar: "$BAR" + baz: "$BAZ" +`, + wantOutput: `--- +metadata: + name: "bar" + labels: + bar: "baz" + baz: "123" +`, + }, + { + name: "multiline input not ending with newline", + envfile: envfile, + input: `--- +metadata: + name: "$FOO" + labels: + bar: "$BAR" + baz: "$BAZ"`, + wantOutput: `--- +metadata: + name: "bar" + labels: + bar: "baz" + baz: "123" +`, + }, + { + name: "empty string", + envfile: envfile, + input: "", + wantOutput: "", + }, + { + name: "multiline with only newlines", + envfile: envfile, + input: "\n\n\n\n\n", + wantOutput: "\n\n\n\n\n", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + envfile := strings.NewReader(strings.TrimSpace(tc.envfile)) + input := strings.NewReader(tc.input) + var output bytes.Buffer + + scanner := scanner.New(&output, input, envfile) + + err := scanner.Scan() + + if tc.wantError == "" { + assert.NoError(t, err) + assert.Equal(t, tc.wantOutput, output.String()) + } else { + assert.EqualError(t, err, tc.wantError) + } + }) + } +}