From eeb6e78fa477f945818108921cc27e64bb521ab1 Mon Sep 17 00:00:00 2001 From: Alex Quick Date: Sat, 20 May 2017 14:38:27 -0400 Subject: [PATCH] [config] move to godotenv --- plugins/build-env/pre-build-buildpack | 4 +- plugins/config/Makefile | 2 +- plugins/config/config.go | 111 +++++- plugins/config/docker-args-deploy | 2 +- plugins/config/functions | 7 +- plugins/config/glide.lock | 9 +- plugins/config/glide.yaml | 2 + plugins/config/src/commands/commands.go | 16 +- plugins/config/src/configenv/configenv.go | 93 +++-- .../config/src/configenv/configenv_test.go | 38 +- plugins/config/src/configenv/parsing.go | 129 ------- .../config/src/subcommands/bundle/bundle.go | 24 ++ .../config/src/subcommands/export/export.go | 75 +--- plugins/config/src/subcommands/get/get.go | 24 +- plugins/config/src/subcommands/keys/keys.go | 26 ++ plugins/config/src/subcommands/set/set.go | 39 +- plugins/config/src/subcommands/unset/unset.go | 45 +-- .../github.com/alexquick/godotenv/.gitignore | 1 + .../github.com/alexquick/godotenv/.travis.yml | 8 + .../github.com/alexquick/godotenv/LICENCE | 23 ++ .../github.com/alexquick/godotenv/README.md | 127 +++++++ .../alexquick/godotenv/autoload/autoload.go | 15 + .../alexquick/godotenv/cmd/godotenv/cmd.go | 54 +++ .../alexquick/godotenv/fixtures/equals.env | 2 + .../alexquick/godotenv/fixtures/exported.env | 2 + .../alexquick/godotenv/fixtures/invalid1.env | 2 + .../alexquick/godotenv/fixtures/plain.env | 7 + .../alexquick/godotenv/fixtures/quoted.env | 8 + .../github.com/alexquick/godotenv/godotenv.go | 304 +++++++++++++++ .../alexquick/godotenv/godotenv_test.go | 352 ++++++++++++++++++ 30 files changed, 1189 insertions(+), 362 deletions(-) delete mode 100644 plugins/config/src/configenv/parsing.go create mode 100644 plugins/config/src/subcommands/bundle/bundle.go create mode 100644 plugins/config/src/subcommands/keys/keys.go create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/.gitignore create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/.travis.yml create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/LICENCE create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/README.md create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/autoload/autoload.go create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/cmd/godotenv/cmd.go create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/fixtures/equals.env create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/fixtures/exported.env create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/fixtures/invalid1.env create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/fixtures/plain.env create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/fixtures/quoted.env create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/godotenv.go create mode 100644 plugins/config/vendor/github.com/alexquick/godotenv/godotenv_test.go diff --git a/plugins/build-env/pre-build-buildpack b/plugins/build-env/pre-build-buildpack index 33863fbbf..4a6e980c1 100755 --- a/plugins/build-env/pre-build-buildpack +++ b/plugins/build-env/pre-build-buildpack @@ -15,12 +15,12 @@ build_env_pre_build_buildpack() { dokku_log_info1 "Adding BUILD_ENV to build environment..." # create build env files for use in buildpacks like this: # https://github.com/niteoweb/heroku-buildpack-buildout/blob/5879fa3418f7d8e079f1aa5816ba1adde73f4948/bin/compile#L34 - local id=$(config_export app "$APP" --merged --bundle | docker run "$DOKKU_GLOBAL_RUN_ARGS" -i -a stdin "$IMAGE" /bin/bash -c "mkdir -p /tmp/env; cat | tar -x -C /tmp/env") + local id=$(config_bundle --merged "$APP" | docker run "$DOKKU_GLOBAL_RUN_ARGS" -i -a stdin "$IMAGE" /bin/bash -c "mkdir -p /tmp/env; cat | tar -x -C /tmp/env") test "$(docker wait "$id")" -eq 0 docker commit "$id" "$IMAGE" > /dev/null # create build env for 'old style' buildpacks and dokku plugins - local id=$(config_export app "$APP" --envfile --merged --escape-newlines | docker run "$DOKKU_GLOBAL_RUN_ARGS" -i -a stdin "$IMAGE" /bin/bash -c "cat >> /app/.env") + local id=$(config_export app "$APP" --format envfile --merged | docker run "$DOKKU_GLOBAL_RUN_ARGS" -i -a stdin "$IMAGE" /bin/bash -c "cat >> /app/.env") test "$(docker wait "$id")" -eq 0 docker commit "$id" "$IMAGE" > /dev/null } diff --git a/plugins/config/Makefile b/plugins/config/Makefile index 2daae1719..4eb670252 100644 --- a/plugins/config/Makefile +++ b/plugins/config/Makefile @@ -2,7 +2,7 @@ include ../../common.mk GO_ARGS ?= -a -SUBCOMMANDS = subcommands/export subcommands/get subcommands/set subcommands/unset +SUBCOMMANDS = subcommands/export subcommands/get subcommands/set subcommands/unset subcommands/keys subcommands/bundle build-in-docker: clean docker run --rm \ diff --git a/plugins/config/config.go b/plugins/config/config.go index 29869cc09..a39cf1c56 100644 --- a/plugins/config/config.go +++ b/plugins/config/config.go @@ -1,20 +1,75 @@ package config -import configenv "github.com/dokku/dokku/plugins/config/src/configenv" -import columnize "github.com/ryanuber/columnize" -import "fmt" +import ( + "fmt" -//GetWithDefault gets value from app config + "github.com/dokku/dokku/plugins/common" + configenv "github.com/dokku/dokku/plugins/config/src/configenv" + columnize "github.com/ryanuber/columnize" +) + +//GetWithDefault gets a value from a config. If appName is empty the global config is used. func GetWithDefault(appName string, key string, defaultValue string) string { - env, err := configenv.NewFromTarget(appName) + env, err := loadConfig(appName) if err != nil { return defaultValue } return env.GetDefault(key, defaultValue) } -//PrettyPrintLogEntries in columns -func PrettyPrintLogEntries(prefix string, entries map[string]string) string { +//SetMany variables in the environment. If appName is empty the global config is used. If restart is true the app is restarted. +func SetMany(appName string, entries map[string]string, restart bool) { + global := appName == "" + env := GetConfig(appName, false) + + keys := make([]string, 0, len(entries)) + + for k, v := range entries { + env.Set(k, v) + keys = append(keys, k) + } + + if len(entries) != 0 { + common.LogInfo1("Setting config vars") + fmt.Println(PrettyPrintEnvEntries(" ", entries)) + env.Write() + args := append([]string{appName, "set"}, keys...) + common.PlugnTrigger("post-config-update", args...) + } + + if !global && restart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) { + common.LogInfo1(fmt.Sprintf("Restarting app %s", appName)) + cmd := common.NewTokenizedShellCmd("dokku", "ps:restart", appName) + cmd.Execute() + } +} + +//Unset a value in a config. If appName is empty the global config is used. If restart is true the app is restarted. +func Unset(appName string, keys []string, restart bool) { + global := appName == "" + env := GetConfig(appName, false) + var changed = false + for _, k := range keys { + common.LogInfo1(fmt.Sprintf("Unsetting %s", k)) + env.Unset(k) + changed = true + } + + if changed { + env.Write() + args := append([]string{appName, "unset"}, keys...) + common.PlugnTrigger("post-config-update", args...) + } + + if !global && restart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) { + common.LogInfo1(fmt.Sprintf("Restarting app %s", appName)) + cmd := common.NewTokenizedShellCmd("dokku", "ps:restart", appName) + cmd.Execute() + } +} + +//PrettyPrintEnvEntries in columns +func PrettyPrintEnvEntries(prefix string, entries map[string]string) string { colConfig := columnize.DefaultConfig() colConfig.Prefix = prefix colConfig.Delim = "\x00" @@ -24,3 +79,45 @@ func PrettyPrintLogEntries(prefix string, entries map[string]string) string { } return columnize.Format(lines, colConfig) } + +//GetCommonArgs extracts common positional args (appName and keys) +func GetCommonArgs(global bool, args []string) (string, []string) { + nextArg := 0 + appName := "" + if !global { + if len(args) > 0 { + appName = args[0] + } + if appName == "" { + common.LogFail("Please specify an app or --global") + } else { + nextArg++ + } + } + keys := args[nextArg:] + return appName, keys +} + +//GetConfig for the given app (global config if appName is empty). Merge with global config if merged is true. +func GetConfig(appName string, merged bool) *configenv.Env { + env, err := loadConfig(appName) + if err != nil { + common.LogFail(err.Error()) + } + if appName != "" && merged { + global, err := configenv.LoadGlobal() + if err != nil { + common.LogFail(err.Error()) + } + global.Merge(env) + return global + } + return env +} + +func loadConfig(appName string) (*configenv.Env, error) { + if appName == "" || appName == "--global" { + return configenv.LoadGlobal() + } + return configenv.LoadApp(appName) +} diff --git a/plugins/config/docker-args-deploy b/plugins/config/docker-args-deploy index b64acb23e..df8dee222 100755 --- a/plugins/config/docker-args-deploy +++ b/plugins/config/docker-args-deploy @@ -10,7 +10,7 @@ config_docker_args() { verify_app_name "$APP" if ! is_image_herokuish_based "$IMAGE"; then - local ENV_ARGS="$(config_export app "$APP" --prefix="-e" --separator=" " --merged)" + local ENV_ARGS="$(config_export app "$APP" --format docker-args --merged)" echo -n "$STDIN $ENV_ARGS" else echo -n "$STDIN" diff --git a/plugins/config/functions b/plugins/config/functions index f2787a170..9de4599c4 100755 --- a/plugins/config/functions +++ b/plugins/config/functions @@ -25,7 +25,7 @@ config_export() { config_keys() { declare desc="returns keys for app or global" - config_export "$@" "--keys" + config_keys "$@" return 0 } @@ -43,3 +43,8 @@ config_unset() { declare desc="set value of given config var" config_sub unset "$@" } + +config_bundle() { + declare desc="export tar bundle of config" + config_sub bundle "$@" +} diff --git a/plugins/config/glide.lock b/plugins/config/glide.lock index fe4a98f07..dc3e9faad 100644 --- a/plugins/config/glide.lock +++ b/plugins/config/glide.lock @@ -1,6 +1,7 @@ -hash: 1ddab5de41d1514c2722bd7e24758ad4b60bf6956eb5b9b925fa071a1427f149 -updated: 2017-01-03T17:16:50.97156327-08:00 +hash: 998d313aef819c8fa68d268d075079d281a7ccab0c3a16f6d8906b20c902f627 +updated: 2017-05-20T15:15:41.795778946-04:00 imports: +- name: github.com/alexquick/godotenv + version: 147552bee9c6196b9388f15ab03fe15721264092 - name: github.com/ryanuber/columnize - version: 0fbbb3f0e3fbdc5bae7c6cd5f6c1887ebfb76360 -testImports: [] + version: 0fbbb3f0e3fbdc5bae7c6cd5f6c1887ebfb76360 \ No newline at end of file diff --git a/plugins/config/glide.yaml b/plugins/config/glide.yaml index 3f7a96657..5e318bb53 100644 --- a/plugins/config/glide.yaml +++ b/plugins/config/glide.yaml @@ -1,3 +1,5 @@ package: . import: - package: github.com/ryanuber/columnize +- package: github.com/alexquick/godotenv + version: feature-write-dotenv diff --git a/plugins/config/src/commands/commands.go b/plugins/config/src/commands/commands.go index 02c909ace..040345e42 100644 --- a/plugins/config/src/commands/commands.go +++ b/plugins/config/src/commands/commands.go @@ -7,9 +7,7 @@ import ( "strconv" "strings" - common "github.com/dokku/dokku/plugins/common" "github.com/dokku/dokku/plugins/config" - configenv "github.com/dokku/dokku/plugins/config/src/configenv" columnize "github.com/ryanuber/columnize" ) @@ -26,6 +24,8 @@ Additional commands:` config:set (|--global) [--encoded] [--no-restart] KEY1=VALUE1 [KEY2=VALUE2 ...], Set one or more config vars config:unset (|--global) KEY1 [KEY2 ...], Unset one or more config vars config:export (|--global) [--envfile], Export a global or app environment + config:keys ( | --global) [--merged], Show keys set in environment + config:bundle ( | --global) [--merged], Bundle environment into tarfile ` ) @@ -37,16 +37,8 @@ func main() { switch cmd { case "config", "config:show": target := flag.Arg(1) - if target == "" { - usage() - common.LogFail("Please specify an app or --global") - } - env, err := configenv.NewFromTarget(target) - if err != nil { - common.LogFail(err.Error()) - } else { - fmt.Println(config.PrettyPrintLogEntries("", env.Map())) - } + env := config.GetConfig(target, false) + fmt.Print(config.PrettyPrintEnvEntries("", env.Map())) case "config:help": usage() case "help": diff --git a/plugins/config/src/configenv/configenv.go b/plugins/config/src/configenv/configenv.go index db6c79ecb..c1abb0341 100644 --- a/plugins/config/src/configenv/configenv.go +++ b/plugins/config/src/configenv/configenv.go @@ -4,46 +4,75 @@ import ( "errors" "fmt" "io" - "os" "path/filepath" "sort" "strings" "archive/tar" + "os" + + godotenv "github.com/alexquick/godotenv" common "github.com/dokku/dokku/plugins/common" ) +type ExportFormat int + +const ( + Exports ExportFormat = iota + Envfile + DockerArgs +) + //Env is a representation for global or app environment type Env struct { - name string - env map[string]string - filename string - EscapeNewlines bool + name string + filename string + env map[string]string } func (e *Env) String() string { return e.EnvfileString() } -//EnvfileString returns the contents of this Env in ENVFILE format +func (e *Env) Export(format ExportFormat) string { + switch format { + case Exports: + return e.ExportfileString() + case Envfile: + return e.EnvfileString() + case DockerArgs: + return e.DockerArgsString() + default: + common.LogFail(fmt.Sprintf("Unknown export format: %v", format)) + return "" + } +} + +//EnvfileString returns the contents of this Env in dotenv format func (e *Env) EnvfileString() string { - return e.StringWithPrefixAndSeparator("", "\n") + rep, _ := godotenv.WriteString(e.Map()) + return rep } //ExportfileString returns the contents of this Env as bash exports func (e *Env) ExportfileString() string { - return e.StringWithPrefixAndSeparator("export ", "\n") + return e.stringWithPrefixAndSeparator("export ", "\n", true) +} + +//DockerArgsString gets the contents of this Env in the form -env=KEY=VALUE --env... +func (e *Env) DockerArgsString() string { + return e.stringWithPrefixAndSeparator("--env=", " ", true) } //StringWithPrefixAndSeparator makes a string of the environment // with the given prefix and separator for each entry -func (e *Env) StringWithPrefixAndSeparator(prefix string, separator string) string { +func (e *Env) stringWithPrefixAndSeparator(prefix string, separator string, allowNewlines bool) string { keys := e.Keys() entries := make([]string, len(keys)) for i, k := range keys { v := SingleQuoteEscape(e.env[k]) - if e.EscapeNewlines { + if !allowNewlines { v = strings.Replace(v, "\n", "'$'\\n''", -1) } entries[i] = fmt.Sprintf("%s%s='%s'", prefix, k, v) @@ -78,31 +107,45 @@ func (e *Env) ExportBundle(dest io.Writer) error { return nil } -//NewFromTarget creates an env from the given target. Target is either "--global" or an app name -func NewFromTarget(target string) (*Env, error) { - if target == "--global" { - return LoadGlobal() - } - return LoadApp(target) -} - //LoadApp loads an environment for the given app func LoadApp(appName string) (*Env, error) { appfile, err := getAppFile(appName) if err != nil { return nil, err } - return parseEnv(appName, appfile) + + if _, err := os.Stat(appfile); os.IsNotExist(err) { + _, err := os.Create(appfile) + if err != nil { + common.LogFail(err.Error()) + } + } + return loadFromFile(appName, appfile) } //LoadGlobal loads the global environmen func LoadGlobal() (*Env, error) { - return parseEnv("global", getGlobalFile()) + return loadFromFile("global", getGlobalFile()) } //NewFromString creates an env from the given ENVFILE contents representation func NewFromString(rep string) (*Env, error) { - return parseEnvFromReader("", "", strings.NewReader(rep)) + envMap, err := godotenv.ReadFromReader(strings.NewReader(rep)) + env := &Env{ + "", + "", + envMap, + } + return env, err +} +func loadFromFile(name string, filename string) (*Env, error) { + envMap, err := godotenv.Read(filename) + env := &Env{ + name, + filename, + envMap, + } + return env, err } //Merge merges the given environment on top of the reciever @@ -172,13 +215,7 @@ func (e *Env) Write() error { if e.filename == "" { return errors.New("this Env was created unbound to a file") } - file, err := os.Create(e.filename) - defer file.Close() - if err != nil { - return err - } - _, err = file.WriteString(e.ExportfileString()) - return err + return godotenv.Write(e.Map(), e.filename) } func getAppFile(appName string) (string, error) { diff --git a/plugins/config/src/configenv/configenv_test.go b/plugins/config/src/configenv/configenv_test.go index 56033f81c..9b86ae452 100644 --- a/plugins/config/src/configenv/configenv_test.go +++ b/plugins/config/src/configenv/configenv_test.go @@ -11,39 +11,29 @@ func TestExportfileRoundtrip(t *testing.T) { env, err := NewFromString("HI='ho'\nFoo='Bar'\n\nBaz='BOFF'") Expect(err).NotTo(HaveOccurred()) Expect(env.Map()).To(Equal(pairs("Baz", "BOFF", "Foo", "Bar", "HI", "ho"))) - Expect(env.String()).To(Equal("Baz='BOFF'\nFoo='Bar'\nHI='ho'")) + Expect(env.String()).To(Equal("Baz=\"BOFF\"\nFoo=\"Bar\"\nHI=\"ho\"")) - env, err = NewFromString("\n export HI='h\\\no\\' \n") + env, err = NewFromString(`export HI="h\no"`) Expect(err).NotTo(HaveOccurred()) - Expect(env.Map()).To(Equal(pairs("HI", "h\\\no\\"))) - Expect(env.String()).To(Equal("HI='h\\\no\\'")) + Expect(env.Map()).To(Equal(pairs("HI", "h\no"))) + Expect(env.String()).To(Equal(`HI="h\no"`)) - env, err = NewFromString("\n export HI=ho\n") - Expect(err).NotTo(HaveOccurred()) - Expect(env.Map()).To(Equal(pairs("HI", "ho"))) - Expect(env.String()).To(Equal("HI='ho'")) - - env, err = NewFromString("HI='ho'\nFOO=''\\''\nBAR='\\'''\\'''") + env, err = NewFromString("HI='ho'\nFOO=\"'\\nBAR=''\"") Expect(err).NotTo(HaveOccurred()) Expect(env.Map()).To(Equal(pairs("HI", "ho", "FOO", "'\nBAR=''"))) - Expect(env.String()).To(Equal("FOO=''\\''\nBAR='\\'''\\'''\nHI='ho'")) + Expect(env.String()).To(Equal("FOO=\"'\\nBAR=''\"\nHI=\"ho\"")) - env, err = NewFromString("HI='ho\n'\n\nFOO='=bar\"\n'\\''\nbaz'") + env, err = NewFromString("FOO='bar' ") Expect(err).NotTo(HaveOccurred()) - Expect(env.Map()).To(Equal(pairs("HI", "ho\n", "FOO", "=bar\"\n'\nbaz"))) - Expect(env.String()).To(Equal("FOO='=bar\"\n'\\''\nbaz'\nHI='ho\n'")) + Expect(env.Map()).To(Equal(pairs("FOO", "bar"))) + Expect(env.String()).To(Equal(`FOO="bar"`)) + } func TestExportfileErrors(t *testing.T) { RegisterTestingT(t) - _, err := NewFromString("FOO='bar\\''") //single quotes are not escaped this way - Expect(err).To(HaveOccurred()) - - _, err = NewFromString("F\nOO='bar'") //keys cannot have embedded newlines - Expect(err).To(HaveOccurred()) - - _, err = NewFromString("FOO='bar' ") //no trailing content + _, err := NewFromString("F\nOO='bar'") //keys cannot have embedded newlines Expect(err).To(HaveOccurred()) } @@ -57,9 +47,9 @@ func TestMerge(t *testing.T) { func TestArrayExport(t *testing.T) { RegisterTestingT(t) - e, _ := NewFromString("BAR='BAZ'\nFOO='b'\\''ar '") - Expect(e.StringWithPrefixAndSeparator("", " ")).To(Equal("BAR='BAZ' FOO='b'\\''ar '")) - Expect(e.StringWithPrefixAndSeparator("-e", " ")).To(Equal("-eBAR='BAZ' -eFOO='b'\\''ar '")) + e, _ := NewFromString("BAR='BAZ'\nFOO='b'ar '") + Expect(e.EnvfileString()).To(Equal("BAR=\"BAZ\"\nFOO=\"b'ar \"")) + Expect(e.DockerArgsString()).To(Equal("--env=BAR='BAZ' --env=FOO='b'\\''ar '")) } func pairs(vars ...string) map[string]string { diff --git a/plugins/config/src/configenv/parsing.go b/plugins/config/src/configenv/parsing.go deleted file mode 100644 index 762a65358..000000000 --- a/plugins/config/src/configenv/parsing.go +++ /dev/null @@ -1,129 +0,0 @@ -package configenv - -import ( - "bufio" - "bytes" - "errors" - "io" - "os" - "strings" -) - -func parseEnv(name string, filename string) (*Env, error) { - var file *os.File - var err error - if _, err := os.Stat(filename); os.IsNotExist(err) { - file, err = os.Create(filename) - } else { - file, err = os.Open(filename) - } - if err != nil { - return nil, err - } - defer file.Close() - return parseEnvFromReader(name, filename, file) -} - -func parseEnvFromReader(name string, filename string, reader io.Reader) (*Env, error) { - //The file format looks like: `KEY='VALUE'`. Each pair is terminated with a newline. - //All characters are valid for VALUE without escaping but one: the single quote. - //A single quote literal is represented by ending the quoting - //emitting a \' and resuming the quoting. So for VALUE to be "don't care": - //`KEY='don'\''t care'` - //The advantage of this is that it's easily parsed and sourced by bash correctly. - - buffered := bufio.NewReader(reader) - const ( - StateKey = iota - StateValue = iota - ) - env := &Env{name, map[string]string{}, filename, false} - var buffer bytes.Buffer - var state = StateKey - var quoted = false - var exportStyle = false - var escaped = false - var key = "" - for char, _, err := buffered.ReadRune(); err == nil; char, _, err = buffered.ReadRune() { - switch state { - case StateKey: - switch char { - case ' ': - if buffer.String() == "export" { - exportStyle = true - buffer.Truncate(0) //so we can read exportfiles as well as envfiles - } else if buffer.Len() == 0 { - continue //leading spaced are allowed but not encouraged - } else { - return nil, errors.New("keys cannot have spaces") - } - case '=': - key = buffer.String() - buffer.Truncate(0) - state = StateValue - case '\n': - if buffer.Len() > 0 { - return nil, errors.New("keys cannot contain newlines") - } - default: - buffer.WriteRune(char) - } - case StateValue: - switch char { - case '\'': - if escaped { - buffer.WriteRune('\'') - escaped = false - } else { - quoted = !quoted - } - case '\n': - if quoted { - buffer.WriteRune(char) - } else { - state = StateKey - value := buffer.String() - if exportStyle { - value = strings.TrimSpace(value) - } - env.Set(key, value) - buffer.Truncate(0) - key = "" - escaped = false - quoted = false - exportStyle = false - } - case '\\': - if quoted { - buffer.WriteRune(char) - escaped = false - } else { - escaped = true - } - default: - if !quoted && !exportStyle { - return nil, errors.New("trailing content for value of " + key) - } - if escaped { - return nil, errors.New("invalid escape for " + key) - } - buffer.WriteRune(char) - } - } - } - if escaped { - return nil, errors.New("unterminated escape at end") - } - if quoted { - return nil, errors.New("unterminated quote at end") - } - - if state == StateValue { - env.Set(key, buffer.String()) - } else { - if buffer.Len() > 0 { - return nil, errors.New("invalid trailing content: '" + buffer.String() + "'") - } - } - return env, nil -} diff --git a/plugins/config/src/subcommands/bundle/bundle.go b/plugins/config/src/subcommands/bundle/bundle.go new file mode 100644 index 000000000..fc3b11407 --- /dev/null +++ b/plugins/config/src/subcommands/bundle/bundle.go @@ -0,0 +1,24 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/config" +) + +func main() { + args := flag.NewFlagSet("config:get", flag.ExitOnError) + global := args.Bool("global", false, "--global: use the global environment") + merged := args.Bool("merged", false, "--merged: merge app environment and global environment") + args.Parse(os.Args[2:]) + + appName, trailingArgs := config.GetCommonArgs(*global, args.Args()) + if len(trailingArgs) > 0 { + common.LogFail(fmt.Sprintf("Trailing argument(s): %v", trailingArgs)) + } + config := config.GetConfig(appName, *merged) + config.ExportBundle(os.Stdout) +} diff --git a/plugins/config/src/subcommands/export/export.go b/plugins/config/src/subcommands/export/export.go index 1a75d51c8..24840ebf3 100644 --- a/plugins/config/src/subcommands/export/export.go +++ b/plugins/config/src/subcommands/export/export.go @@ -6,6 +6,7 @@ import ( "os" common "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/config" "github.com/dokku/dokku/plugins/config/src/configenv" ) @@ -16,66 +17,26 @@ func main() { args := flag.NewFlagSet("config:export", flag.ExitOnError) global := args.Bool("global", false, "--global: use the global environment") merged := args.Bool("merged", false, "--merged: merge app environment and global environment") - keys := args.Bool("keys", false, "--keys: export keys only") - bundle := args.Bool("bundle", false, "--bundle: export as tar bundle") - envfile := args.Bool("envfile", false, "--envfile: export as envfile rather than bash exports (--prefix='')") - prefix := args.String("prefix", defaultPrefix, "--prefix: prefix") - separator := args.String("separator", defaultSeparator, "--separator: separator") - escapeNewlines := args.Bool("escape-newlines", false, "--escape-newlines: replace literal newlines with $'\n'") + format := args.String("format", "exports", "--format: [ exports | envfile | docker-args ] which format to export as)") args.Parse(os.Args[2:]) - appName := args.Arg(0) - if appName == "" && !*global { - common.LogFail("Please specify an app or --global") + appName, trailingArgs := config.GetCommonArgs(*global, args.Args()) + if len(trailingArgs) > 0 { + common.LogFail(fmt.Sprintf("Trailing argument(s): %v", trailingArgs)) } - if *global { - if appName != "" { - common.LogFail("Trailing argument: " + appName) - } - if *merged == true { - common.LogFail("Only app environments can be merged") - } - appName = "--global" + env := config.GetConfig(appName, *merged) + exportType := configenv.Exports + switch *format { + case "exports": + exportType = configenv.Exports + case "envfile": + exportType = configenv.Envfile + case "docker-args": + exportType = configenv.DockerArgs + default: + common.LogFail(fmt.Sprintf("Unknown export format: %v", *format)) } - - env, err := configenv.NewFromTarget(appName) - if err != nil { - common.LogFail(err.Error()) - } - - if *merged { - global, err := configenv.LoadGlobal() - if err != nil { - common.LogFail(err.Error()) - } - global.Merge(env) - env = global - } - - if *keys { - for _, k := range env.Keys() { - fmt.Println(k) - } - return - } - - if *escapeNewlines { - env.EscapeNewlines = true - } - - if *bundle { - if *prefix != defaultPrefix || *separator != defaultSeparator || *envfile { - common.LogFail("--bundle cannot be given with --envfile, --prefix, or --separator") - } - env.ExportBundle(os.Stdout) - } - if *envfile { - if *prefix != defaultPrefix { - common.LogFail("--prefix and --envfile cannot both be given") - return - } - *prefix = "" - } - fmt.Println(env.StringWithPrefixAndSeparator(*prefix, *separator)) + exported := env.Export(exportType) + fmt.Println(exported) } diff --git a/plugins/config/src/subcommands/get/get.go b/plugins/config/src/subcommands/get/get.go index 321ed856b..1c6194edc 100644 --- a/plugins/config/src/subcommands/get/get.go +++ b/plugins/config/src/subcommands/get/get.go @@ -6,6 +6,7 @@ import ( "os" common "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/config" "github.com/dokku/dokku/plugins/config/src/configenv" ) @@ -15,28 +16,13 @@ func main() { global := args.Bool("global", false, "--global: use the global environment") quoted := args.Bool("quoted", false, "--quoted: get the value quoted") args.Parse(os.Args[2:]) - appName := args.Arg(0) - nextArg := 0 - if appName == "" && !*global { - common.LogFail("Please specify an app or --global") - } - if *global { - appName = "--global" - } else { - nextArg = 1 + appName, keys := config.GetCommonArgs(*global, args.Args()) + if len(keys) > 1 { + common.LogFail(fmt.Sprintf("Unexpected argument(s): %v", keys[1:])) } + value := config.GetWithDefault(appName, keys[0], "") - env, err := configenv.NewFromTarget(appName) - if err != nil { - common.LogFail(err.Error()) - } - - if args.NArg() > nextArg+1 { - common.LogFail(fmt.Sprintf("Unexpected argument(s): %v", args.Args()[nextArg+1:])) - } - key := args.Arg(nextArg) - value := env.GetDefault(key, "") if *quoted { fmt.Printf("'%s'", configenv.SingleQuoteEscape(value)) } else { diff --git a/plugins/config/src/subcommands/keys/keys.go b/plugins/config/src/subcommands/keys/keys.go new file mode 100644 index 000000000..0694e4dc3 --- /dev/null +++ b/plugins/config/src/subcommands/keys/keys.go @@ -0,0 +1,26 @@ +package main + +import ( + "flag" + "fmt" + "os" + + "github.com/dokku/dokku/plugins/common" + "github.com/dokku/dokku/plugins/config" +) + +func main() { + args := flag.NewFlagSet("config:get", flag.ExitOnError) + global := args.Bool("global", false, "--global: use the global environment") + merged := args.Bool("merged", false, "--merged: merge app environment and global environment") + args.Parse(os.Args[2:]) + + appName, trailingArgs := config.GetCommonArgs(*global, args.Args()) + if len(trailingArgs) > 0 { + common.LogFail(fmt.Sprintf("Trailing argument(s): %v", trailingArgs)) + } + config := config.GetConfig(appName, *merged) + for _, k := range config.Keys() { + fmt.Println(k) + } +} diff --git a/plugins/config/src/subcommands/set/set.go b/plugins/config/src/subcommands/set/set.go index f3504883b..276b8dc79 100644 --- a/plugins/config/src/subcommands/set/set.go +++ b/plugins/config/src/subcommands/set/set.go @@ -9,7 +9,6 @@ import ( common "github.com/dokku/dokku/plugins/common" config "github.com/dokku/dokku/plugins/config" - "github.com/dokku/dokku/plugins/config/src/configenv" ) // set the given entries from the specified environment @@ -19,28 +18,10 @@ func main() { encoded := args.Bool("encoded", false, "--encoded: interpret VALUEs as base64") noRestart := args.Bool("no-restart", false, "--no-restart: no restart") args.Parse(os.Args[2:]) - shouldRestart := !*global && !*noRestart - var nextArg = 0 - appName := args.Arg(0) - if appName == "" && !*global { - common.LogFail("Please specify an app or --global") - } + appName, pairs := config.GetCommonArgs(*global, args.Args()) - if *global { - appName = "--global" - } else { - nextArg = 1 - } - - env, err := configenv.NewFromTarget(appName) - if err != nil { - common.LogFail(err.Error()) - } - - entries := args.Args()[nextArg:] updated := make(map[string]string) - for _, e := range entries { - //log + for _, e := range pairs { parts := strings.SplitN(e, "=", 2) if len(parts) == 1 { common.LogFail("Invalid env pair: " + e) @@ -53,21 +34,7 @@ func main() { } value = string(decoded) } - env.Set(key, value) updated[key] = value } - - if len(updated) != 0 { - common.LogInfo1("Setting config vars") - fmt.Println(config.PrettyPrintLogEntries(" ", updated)) - env.Write() - args := append([]string{appName, "set"}, entries...) - common.PlugnTrigger("post-config-update", args...) - } - - if shouldRestart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) { - common.LogInfo1(fmt.Sprintf("Restarting app %s", appName)) - cmd := common.NewTokenizedShellCmd("dokku", "ps:restart", appName) - cmd.Execute() - } + config.SetMany(appName, updated, !*noRestart) } diff --git a/plugins/config/src/subcommands/unset/unset.go b/plugins/config/src/subcommands/unset/unset.go index 7b37e1811..856854612 100644 --- a/plugins/config/src/subcommands/unset/unset.go +++ b/plugins/config/src/subcommands/unset/unset.go @@ -4,10 +4,7 @@ import ( "flag" "os" - "fmt" - - common "github.com/dokku/dokku/plugins/common" - "github.com/dokku/dokku/plugins/config/src/configenv" + "github.com/dokku/dokku/plugins/config" ) //unset the given entries from the given environment @@ -15,42 +12,8 @@ func main() { args := flag.NewFlagSet("config:unset", flag.ExitOnError) global := args.Bool("global", false, "--global: use the global environment") noRestart := args.Bool("no-restart", false, "--no-restart: no restart") + args.Parse(os.Args[2:]) - shouldRestart := !*global && !*noRestart - var nextArg = 0 - appName := args.Arg(0) - if appName == "" && !*global { - common.LogFail("Please specify an app or --global") - } - - if *global { - appName = "--global" - } else { - nextArg = 1 - } - - env, err := configenv.NewFromTarget(appName) - if err != nil { - common.LogFail(err.Error()) - } - - var changed = false - keys := args.Args()[nextArg:] - for _, k := range keys { - common.LogInfo1(fmt.Sprintf("Unsetting %s", k)) - env.Unset(k) - changed = true - } - - if changed { - env.Write() - args := append([]string{appName, "unset"}, keys...) - common.PlugnTrigger("post-config-update", args...) - } - - if shouldRestart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) { - common.LogInfo1(fmt.Sprintf("Restarting app %s", appName)) - cmd := common.NewTokenizedShellCmd("dokku", "ps:restart", appName) - cmd.Execute() - } + appName, keys := config.GetCommonArgs(*global, args.Args()) + config.Unset(appName, keys, !*noRestart) } diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/.gitignore b/plugins/config/vendor/github.com/alexquick/godotenv/.gitignore new file mode 100644 index 000000000..e43b0f988 --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/.gitignore @@ -0,0 +1 @@ +.DS_Store diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/.travis.yml b/plugins/config/vendor/github.com/alexquick/godotenv/.travis.yml new file mode 100644 index 000000000..df65350ba --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/.travis.yml @@ -0,0 +1,8 @@ +language: go + +go: + - 1.8 + +os: + - linux + - osx diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/LICENCE b/plugins/config/vendor/github.com/alexquick/godotenv/LICENCE new file mode 100644 index 000000000..e7ddd51be --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/LICENCE @@ -0,0 +1,23 @@ +Copyright (c) 2013 John Barton + +MIT License + +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/plugins/config/vendor/github.com/alexquick/godotenv/README.md b/plugins/config/vendor/github.com/alexquick/godotenv/README.md new file mode 100644 index 000000000..aa6442b50 --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/README.md @@ -0,0 +1,127 @@ +# GoDotEnv [![Build Status](https://travis-ci.org/joho/godotenv.svg?branch=master)](https://travis-ci.org/joho/godotenv) [![Build status](https://ci.appveyor.com/api/projects/status/9v40vnfvvgde64u4?svg=true)](https://ci.appveyor.com/project/joho/godotenv) + +A Go (golang) port of the Ruby dotenv project (which loads env vars from a .env file) + +From the original Library: + +> Storing configuration in the environment is one of the tenets of a twelve-factor app. Anything that is likely to change between deployment environments–such as resource handles for databases or credentials for external services–should be extracted from the code into environment variables. +> +> But it is not always practical to set environment variables on development machines or continuous integration servers where multiple projects are run. Dotenv load variables from a .env file into ENV when the environment is bootstrapped. + +It can be used as a library (for loading in env for your own daemons etc) or as a bin command. + +There is test coverage and CI for both linuxish and windows environments, but I make no guarantees about the bin version working on windows. + +## Installation + +As a library + +```shell +go get github.com/joho/godotenv +``` + +or if you want to use it as a bin command +```shell +go get github.com/joho/godotenv/cmd/godotenv +``` + +## Usage + +Add your application configuration to your `.env` file in the root of your project: + +```shell +S3_BUCKET=YOURS3BUCKET +SECRET_KEY=YOURSECRETKEYGOESHERE +``` + +Then in your Go app you can do something like + +```go +package main + +import ( + "github.com/joho/godotenv" + "log" + "os" +) + +func main() { + err := godotenv.Load() + if err != nil { + log.Fatal("Error loading .env file") + } + + s3Bucket := os.Getenv("S3_BUCKET") + secretKey := os.Getenv("SECRET_KEY") + + // now do something with s3 or whatever +} +``` + +If you're even lazier than that, you can just take advantage of the autoload package which will read in `.env` on import + +```go +import _ "github.com/joho/godotenv/autoload" +``` + +While `.env` in the project root is the default, you don't have to be constrained, both examples below are 100% legit + +```go +_ = godotenv.Load("somerandomfile") +_ = godotenv.Load("filenumberone.env", "filenumbertwo.env") +``` + +If you want to be really fancy with your env file you can do comments and exports (below is a valid env file) + +```shell +# I am a comment and that is OK +SOME_VAR=someval +FOO=BAR # comments at line end are OK too +export BAR=BAZ +``` + +Or finally you can do YAML(ish) style + +```yaml +FOO: bar +BAR: baz +``` + +as a final aside, if you don't want godotenv munging your env you can just get a map back instead + +```go +var myEnv map[string]string +myEnv, err := godotenv.Read() + +s3Bucket := myEnv["S3_BUCKET"] +``` + +### Command Mode + +Assuming you've installed the command as above and you've got `$GOPATH/bin` in your `$PATH` + +``` +godotenv -f /some/path/to/.env some_command with some args +``` + +If you don't specify `-f` it will fall back on the default of loading `.env` in `PWD` + +## Contributing + +Contributions are most welcome! The parser itself is pretty stupidly naive and I wouldn't be surprised if it breaks with edge cases. + +*code changes without tests will not be accepted* + +1. Fork it +2. Create your feature branch (`git checkout -b my-new-feature`) +3. Commit your changes (`git commit -am 'Added some feature'`) +4. Push to the branch (`git push origin my-new-feature`) +5. Create new Pull Request + +## CI + +Linux: [![wercker status](https://app.wercker.com/status/507594c2ec7e60f19403a568dfea0f78/m "wercker status")](https://app.wercker.com/project/bykey/507594c2ec7e60f19403a568dfea0f78) Windows: [![Build status](https://ci.appveyor.com/api/projects/status/9v40vnfvvgde64u4)](https://ci.appveyor.com/project/joho/godotenv) + +## Who? + +The original library [dotenv](https://github.com/bkeepers/dotenv) was written by [Brandon Keepers](http://opensoul.org/), and this port was done by [John Barton](http://whoisjohnbarton.com) based off the tests/fixtures in the original library. diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/autoload/autoload.go b/plugins/config/vendor/github.com/alexquick/godotenv/autoload/autoload.go new file mode 100644 index 000000000..fbcd2bdf8 --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/autoload/autoload.go @@ -0,0 +1,15 @@ +package autoload + +/* + You can just read the .env file on import just by doing + + import _ "github.com/joho/godotenv/autoload" + + And bob's your mother's brother +*/ + +import "github.com/joho/godotenv" + +func init() { + godotenv.Load() +} diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/cmd/godotenv/cmd.go b/plugins/config/vendor/github.com/alexquick/godotenv/cmd/godotenv/cmd.go new file mode 100644 index 000000000..04a9f6497 --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/cmd/godotenv/cmd.go @@ -0,0 +1,54 @@ +package main + +import ( + "flag" + "fmt" + "log" + + "strings" + + "github.com/joho/godotenv" +) + +func main() { + var showHelp bool + flag.BoolVar(&showHelp, "h", false, "show help") + var rawEnvFilenames string + flag.StringVar(&rawEnvFilenames, "f", "", "comma separated paths to .env files") + + flag.Parse() + + usage := ` +Run a process with a env setup from a .env file + +godotenv [-f ENV_FILE_PATHS] COMMAND_ARGS + +ENV_FILE_PATHS: comma separated paths to .env files +COMMAND_ARGS: command and args you want to run + +example + godotenv -f /path/to/something/.env,/another/path/.env fortune +` + // if no args or -h flag + // print usage and return + args := flag.Args() + if showHelp || len(args) == 0 { + fmt.Println(usage) + return + } + + // load env + var envFilenames []string + if rawEnvFilenames != "" { + envFilenames = strings.Split(rawEnvFilenames, ",") + } + + // take rest of args and "exec" them + cmd := args[0] + cmdArgs := args[1:] + + err := godotenv.Exec(envFilenames, cmd, cmdArgs) + if err != nil { + log.Fatal(err) + } +} diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/equals.env b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/equals.env new file mode 100644 index 000000000..594c5328a --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/equals.env @@ -0,0 +1,2 @@ +export OPTION_A='postgres://localhost:5432/database?sslmode=disable' + diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/exported.env b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/exported.env new file mode 100644 index 000000000..5821377c7 --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/exported.env @@ -0,0 +1,2 @@ +export OPTION_A=2 +export OPTION_B='\n' diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/invalid1.env b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/invalid1.env new file mode 100644 index 000000000..38f7e0e8b --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/invalid1.env @@ -0,0 +1,2 @@ +INVALID LINE +foo=bar diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/plain.env b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/plain.env new file mode 100644 index 000000000..43f7e44cc --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/plain.env @@ -0,0 +1,7 @@ +OPTION_A=1 +OPTION_B=2 +OPTION_C= 3 +OPTION_D =4 +OPTION_E = 5 +OPTION_F = +OPTION_G= \ No newline at end of file diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/quoted.env b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/quoted.env new file mode 100644 index 000000000..a03ce249b --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/fixtures/quoted.env @@ -0,0 +1,8 @@ +OPTION_A='1' +OPTION_B='2' +OPTION_C='' +OPTION_D='\n' +OPTION_E="1" +OPTION_F="2" +OPTION_G="" +OPTION_H="\n" diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/godotenv.go b/plugins/config/vendor/github.com/alexquick/godotenv/godotenv.go new file mode 100644 index 000000000..e56663427 --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/godotenv.go @@ -0,0 +1,304 @@ +// Package godotenv is a go port of the ruby dotenv library (https://github.com/bkeepers/dotenv) +// +// Examples/readme can be found on the github page at https://github.com/joho/godotenv +// +// The TL;DR is that you make a .env file that looks something like +// +// SOME_ENV_VAR=somevalue +// +// and then in your go code you can call +// +// godotenv.Load() +// +// and all the env vars declared in .env will be avaiable through os.Getenv("SOME_ENV_VAR") +package godotenv + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "os/exec" + "regexp" + "sort" + "strings" +) + +// Load will read your env file(s) and load them into ENV for this process. +// +// Call this function as close as possible to the start of your program (ideally in main) +// +// If you call Load without any args it will default to loading .env in the current path +// +// You can otherwise tell it which files to load (there can be more than one) like +// +// godotenv.Load("fileone", "filetwo") +// +// It's important to note that it WILL NOT OVERRIDE an env variable that already exists - consider the .env file to set dev vars or sensible defaults +func Load(filenames ...string) (err error) { + filenames = filenamesOrDefault(filenames) + + for _, filename := range filenames { + err = loadFile(filename, false) + if err != nil { + return // return early on a spazout + } + } + return +} + +// Overload will read your env file(s) and load them into ENV for this process. +// +// Call this function as close as possible to the start of your program (ideally in main) +// +// If you call Overload without any args it will default to loading .env in the current path +// +// You can otherwise tell it which files to load (there can be more than one) like +// +// godotenv.Overload("fileone", "filetwo") +// +// It's important to note this WILL OVERRIDE an env variable that already exists - consider the .env file to forcefilly set all vars. +func Overload(filenames ...string) (err error) { + filenames = filenamesOrDefault(filenames) + + for _, filename := range filenames { + err = loadFile(filename, true) + if err != nil { + return // return early on a spazout + } + } + return +} + +// Read all env (with same file loading semantics as Load) but return values as +// a map rather than automatically writing values into env +func Read(filenames ...string) (envMap map[string]string, err error) { + filenames = filenamesOrDefault(filenames) + envMap = make(map[string]string) + + for _, filename := range filenames { + individualEnvMap, individualErr := readFile(filename) + + if individualErr != nil { + err = individualErr + return // return early on a spazout + } + + for key, value := range individualEnvMap { + envMap[key] = value + } + } + + return +} + +// Exec loads env vars from the specified filenames (empty map falls back to default) +// then executes the cmd specified. +// +// Simply hooks up os.Stdin/err/out to the command and calls Run() +// +// If you want more fine grained control over your command it's recommended +// that you use `Load()` or `Read()` and the `os/exec` package yourself. +func Exec(filenames []string, cmd string, cmdArgs []string) error { + Load(filenames...) + + command := exec.Command(cmd, cmdArgs...) + command.Stdin = os.Stdin + command.Stdout = os.Stdout + command.Stderr = os.Stderr + return command.Run() +} + +// Write serializes the given environment and writes it to a file +func Write(envMap map[string]string, filename string) error { + content, error := WriteString(envMap) + if error != nil { + return error + } + file, error := os.Create(filename) + if error != nil { + return error + } + _, err := file.WriteString(content) + return err +} + +// WriteString outputs the given environment as a dotenv-formatted environment file. +// +// Each line is in the format: KEY="VALUE" where VALUE is backslash-escaped. +func WriteString(envMap map[string]string) (string, error) { + lines := make([]string, 0, len(envMap)) + for k, v := range envMap { + lines = append(lines, fmt.Sprintf(`%s="%s"`, k, doubleQuoteEscape(v))) + } + sort.Strings(lines) + return strings.Join(lines, "\n"), nil +} + +func filenamesOrDefault(filenames []string) []string { + if len(filenames) == 0 { + return []string{".env"} + } + return filenames +} + +func loadFile(filename string, overload bool) error { + envMap, err := readFile(filename) + if err != nil { + return err + } + + currentEnv := map[string]bool{} + rawEnv := os.Environ() + for _, rawEnvLine := range rawEnv { + key := strings.Split(rawEnvLine, "=")[0] + currentEnv[key] = true + } + + for key, value := range envMap { + if !currentEnv[key] || overload { + os.Setenv(key, value) + } + } + + return nil +} + +func readFile(filename string) (envMap map[string]string, err error) { + file, err := os.Open(filename) + if err != nil { + return + } + defer file.Close() + return ReadFromReader(file) +} + +func readString(content string) (envMap map[string]string, err error) { + return ReadFromReader(strings.NewReader(content)) +} + +func ReadFromReader(r io.Reader) (envMap map[string]string, err error) { + envMap = make(map[string]string) + + var lines []string + scanner := bufio.NewScanner(r) + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + if err = scanner.Err(); err != nil { + return + } + + for _, fullLine := range lines { + if !isIgnoredLine(fullLine) { + var key, value string + key, value, err = parseLine(fullLine) + + if err != nil { + return + } + envMap[key] = value + } + } + return +} + +func parseLine(line string) (key string, value string, err error) { + if len(line) == 0 { + err = errors.New("zero length string") + return + } + + // ditch the comments (but keep quoted hashes) + if strings.Contains(line, "#") { + segmentsBetweenHashes := strings.Split(line, "#") + quotesAreOpen := false + var segmentsToKeep []string + for _, segment := range segmentsBetweenHashes { + if strings.Count(segment, "\"") == 1 || strings.Count(segment, "'") == 1 { + if quotesAreOpen { + quotesAreOpen = false + segmentsToKeep = append(segmentsToKeep, segment) + } else { + quotesAreOpen = true + } + } + + if len(segmentsToKeep) == 0 || quotesAreOpen { + segmentsToKeep = append(segmentsToKeep, segment) + } + } + + line = strings.Join(segmentsToKeep, "#") + } + + firstEquals := strings.Index(line, "=") + firstColon := strings.Index(line, ":") + splitString := strings.SplitN(line, "=", 2) + if firstColon != -1 && (firstColon < firstEquals || firstEquals == -1) { + //this is a yaml-style line + splitString = strings.SplitN(line, ":", 2) + } + + if len(splitString) != 2 { + err = errors.New("Can't separate key from value for line: " + line) + return + } + + // Parse the key + key = splitString[0] + if strings.HasPrefix(key, "export") { + key = strings.TrimPrefix(key, "export") + } + key = strings.Trim(key, " ") + + // Parse the value + value = parseValue(splitString[1]) + return +} + +func parseValue(value string) string { + + // trim + value = strings.Trim(value, " ") + + // check if we've got quoted values + if value != "" { + first := string(value[0:1]) + last := string(value[len(value)-1:]) + if first == last && strings.ContainsAny(first, `"'`) { + // pull the quotes off the edges + value = value[1 : len(value)-1] + // handle escapes + escapeRegex := regexp.MustCompile(`\\.`) + value = escapeRegex.ReplaceAllStringFunc(value, func(match string) string { + c := strings.TrimPrefix(match, `\`) + switch c { + case "n": + return "\n" + case "r": + return "\r" + default: + return c + } + }) + } + } + + return value +} + +func isIgnoredLine(line string) bool { + trimmedLine := strings.Trim(line, " \n\t") + return len(trimmedLine) == 0 || strings.HasPrefix(trimmedLine, "#") +} + +func doubleQuoteEscape(line string) string { + line = strings.Replace(line, `\`, `\\`, -1) + line = strings.Replace(line, "\n", `\n`, -1) + line = strings.Replace(line, "\r", `\r`, -1) + line = strings.Replace(line, `"`, `\"`, -1) + return line +} diff --git a/plugins/config/vendor/github.com/alexquick/godotenv/godotenv_test.go b/plugins/config/vendor/github.com/alexquick/godotenv/godotenv_test.go new file mode 100644 index 000000000..ee4e5eb3f --- /dev/null +++ b/plugins/config/vendor/github.com/alexquick/godotenv/godotenv_test.go @@ -0,0 +1,352 @@ +package godotenv + +import ( + "fmt" + "os" + "reflect" + "testing" +) + +var noopPresets = make(map[string]string) + +func parseAndCompare(t *testing.T, rawEnvLine string, expectedKey string, expectedValue string) { + key, value, _ := parseLine(rawEnvLine) + if key != expectedKey || value != expectedValue { + t.Errorf("Expected '%v' to parse as '%v' => '%v', got '%v' => '%v' instead", rawEnvLine, expectedKey, expectedValue, key, value) + } +} + +func loadEnvAndCompareValues(t *testing.T, loader func(files ...string) error, envFileName string, expectedValues map[string]string, presets map[string]string) { + // first up, clear the env + os.Clearenv() + + for k, v := range presets { + os.Setenv(k, v) + } + + err := loader(envFileName) + if err != nil { + t.Fatalf("Error loading %v", envFileName) + } + + for k := range expectedValues { + envValue := os.Getenv(k) + v := expectedValues[k] + if envValue != v { + t.Errorf("Mismatch for key '%v': expected '%v' got '%v'", k, v, envValue) + } + } +} + +func TestLoadWithNoArgsLoadsDotEnv(t *testing.T) { + err := Load() + pathError := err.(*os.PathError) + if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" { + t.Errorf("Didn't try and open .env by default") + } +} + +func TestOverloadWithNoArgsOverloadsDotEnv(t *testing.T) { + err := Overload() + pathError := err.(*os.PathError) + if pathError == nil || pathError.Op != "open" || pathError.Path != ".env" { + t.Errorf("Didn't try and open .env by default") + } +} + +func TestLoadFileNotFound(t *testing.T) { + err := Load("somefilethatwillneverexistever.env") + if err == nil { + t.Error("File wasn't found but Load didn't return an error") + } +} + +func TestOverloadFileNotFound(t *testing.T) { + err := Overload("somefilethatwillneverexistever.env") + if err == nil { + t.Error("File wasn't found but Overload didn't return an error") + } +} + +func TestReadPlainEnv(t *testing.T) { + envFileName := "fixtures/plain.env" + expectedValues := map[string]string{ + "OPTION_A": "1", + "OPTION_B": "2", + "OPTION_C": "3", + "OPTION_D": "4", + "OPTION_E": "5", + "OPTION_F": "", + "OPTION_G": "", + } + + envMap, err := Read(envFileName) + if err != nil { + t.Error("Error reading file") + } + + if len(envMap) != len(expectedValues) { + t.Error("Didn't get the right size map back") + } + + for key, value := range expectedValues { + if envMap[key] != value { + t.Error("Read got one of the keys wrong") + } + } +} + +func TestLoadDoesNotOverride(t *testing.T) { + envFileName := "fixtures/plain.env" + + // ensure NO overload + presets := map[string]string{ + "OPTION_A": "do_not_override", + "OPTION_B": "", + } + + expectedValues := map[string]string{ + "OPTION_A": "do_not_override", + "OPTION_B": "", + } + loadEnvAndCompareValues(t, Load, envFileName, expectedValues, presets) +} + +func TestOveroadDoesOverride(t *testing.T) { + envFileName := "fixtures/plain.env" + + // ensure NO overload + presets := map[string]string{ + "OPTION_A": "do_not_override", + } + + expectedValues := map[string]string{ + "OPTION_A": "1", + } + loadEnvAndCompareValues(t, Overload, envFileName, expectedValues, presets) +} + +func TestLoadPlainEnv(t *testing.T) { + envFileName := "fixtures/plain.env" + expectedValues := map[string]string{ + "OPTION_A": "1", + "OPTION_B": "2", + "OPTION_C": "3", + "OPTION_D": "4", + "OPTION_E": "5", + } + + loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) +} + +func TestLoadExportedEnv(t *testing.T) { + envFileName := "fixtures/exported.env" + expectedValues := map[string]string{ + "OPTION_A": "2", + "OPTION_B": "\n", + } + + loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) +} + +func TestLoadEqualsEnv(t *testing.T) { + envFileName := "fixtures/equals.env" + expectedValues := map[string]string{ + "OPTION_A": "postgres://localhost:5432/database?sslmode=disable", + } + + loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) +} + +func TestLoadQuotedEnv(t *testing.T) { + envFileName := "fixtures/quoted.env" + expectedValues := map[string]string{ + "OPTION_A": "1", + "OPTION_B": "2", + "OPTION_C": "", + "OPTION_D": "\n", + "OPTION_E": "1", + "OPTION_F": "2", + "OPTION_G": "", + "OPTION_H": "\n", + } + + loadEnvAndCompareValues(t, Load, envFileName, expectedValues, noopPresets) +} + +func TestActualEnvVarsAreLeftAlone(t *testing.T) { + os.Clearenv() + os.Setenv("OPTION_A", "actualenv") + _ = Load("fixtures/plain.env") + + if os.Getenv("OPTION_A") != "actualenv" { + t.Error("An ENV var set earlier was overwritten") + } +} + +func TestParsing(t *testing.T) { + // unquoted values + parseAndCompare(t, "FOO=bar", "FOO", "bar") + + // parses values with spaces around equal sign + parseAndCompare(t, "FOO =bar", "FOO", "bar") + parseAndCompare(t, "FOO= bar", "FOO", "bar") + + // parses double quoted values + parseAndCompare(t, `FOO="bar"`, "FOO", "bar") + + // parses single quoted values + parseAndCompare(t, "FOO='bar'", "FOO", "bar") + + // parses escaped double quotes + parseAndCompare(t, `FOO="escaped\"bar"`, "FOO", `escaped"bar`) + + // parses single quotes inside double quotes + parseAndCompare(t, `FOO="'d'"`, "FOO", `'d'`) + + // parses yaml style options + parseAndCompare(t, "OPTION_A: 1", "OPTION_A", "1") + + //parses yaml values with equal signs + parseAndCompare(t, "OPTION_A: Foo=bar", "OPTION_A", "Foo=bar") + + // parses non-yaml options with colons + parseAndCompare(t, "OPTION_A=1:B", "OPTION_A", "1:B") + + // parses export keyword + parseAndCompare(t, "export OPTION_A=2", "OPTION_A", "2") + parseAndCompare(t, `export OPTION_B='\n'`, "OPTION_B", "\n") + + // it 'expands newlines in quoted strings' do + // expect(env('FOO="bar\nbaz"')).to eql('FOO' => "bar\nbaz") + parseAndCompare(t, `FOO="bar\nbaz"`, "FOO", "bar\nbaz") + + // it 'parses varibales with "." in the name' do + // expect(env('FOO.BAR=foobar')).to eql('FOO.BAR' => 'foobar') + parseAndCompare(t, "FOO.BAR=foobar", "FOO.BAR", "foobar") + + // it 'parses varibales with several "=" in the value' do + // expect(env('FOO=foobar=')).to eql('FOO' => 'foobar=') + parseAndCompare(t, "FOO=foobar=", "FOO", "foobar=") + + // it 'strips unquoted values' do + // expect(env('foo=bar ')).to eql('foo' => 'bar') # not 'bar ' + parseAndCompare(t, "FOO=bar ", "FOO", "bar") + + // it 'ignores inline comments' do + // expect(env("foo=bar # this is foo")).to eql('foo' => 'bar') + parseAndCompare(t, "FOO=bar # this is foo", "FOO", "bar") + + // it 'allows # in quoted value' do + // expect(env('foo="bar#baz" # comment')).to eql('foo' => 'bar#baz') + parseAndCompare(t, `FOO="bar#baz" # comment`, "FOO", "bar#baz") + parseAndCompare(t, "FOO='bar#baz' # comment", "FOO", "bar#baz") + parseAndCompare(t, `FOO="bar#baz#bang" # comment`, "FOO", "bar#baz#bang") + + // it 'parses # in quoted values' do + // expect(env('foo="ba#r"')).to eql('foo' => 'ba#r') + // expect(env("foo='ba#r'")).to eql('foo' => 'ba#r') + parseAndCompare(t, `FOO="ba#r"`, "FOO", "ba#r") + parseAndCompare(t, "FOO='ba#r'", "FOO", "ba#r") + + //newlines and backslashes should be escaped + parseAndCompare(t, `FOO="bar\n\ b\az"`, "FOO", "bar\n baz") + parseAndCompare(t, `FOO="bar\\\n\ b\az"`, "FOO", "bar\\\n baz") + parseAndCompare(t, `FOO="bar\\r\ b\az"`, "FOO", "bar\\r baz") + + // it 'throws an error if line format is incorrect' do + // expect{env('lol$wut')}.to raise_error(Dotenv::FormatError) + badlyFormattedLine := "lol$wut" + _, _, err := parseLine(badlyFormattedLine) + if err == nil { + t.Errorf("Expected \"%v\" to return error, but it didn't", badlyFormattedLine) + } +} + +func TestLinesToIgnore(t *testing.T) { + // it 'ignores empty lines' do + // expect(env("\n \t \nfoo=bar\n \nfizz=buzz")).to eql('foo' => 'bar', 'fizz' => 'buzz') + if !isIgnoredLine("\n") { + t.Error("Line with nothing but line break wasn't ignored") + } + + if !isIgnoredLine("\t\t ") { + t.Error("Line full of whitespace wasn't ignored") + } + + // it 'ignores comment lines' do + // expect(env("\n\n\n # HERE GOES FOO \nfoo=bar")).to eql('foo' => 'bar') + if !isIgnoredLine("# comment") { + t.Error("Comment wasn't ignored") + } + + if !isIgnoredLine("\t#comment") { + t.Error("Indented comment wasn't ignored") + } + + // make sure we're not getting false positives + if isIgnoredLine(`export OPTION_B='\n'`) { + t.Error("ignoring a perfectly valid line to parse") + } +} + +func TestErrorReadDirectory(t *testing.T) { + envFileName := "fixtures/" + envMap, err := Read(envFileName) + + if err == nil { + t.Errorf("Expected error, got %v", envMap) + } +} + +func TestErrorParsing(t *testing.T) { + envFileName := "fixtures/invalid1.env" + envMap, err := Read(envFileName) + if err == nil { + t.Errorf("Expected error, got %v", envMap) + } +} + +func TestWrite(t *testing.T) { + writeAndCompare := func(env string, expected string) { + envMap, _ := readString(env) + actual, _ := WriteString(envMap) + if expected != actual { + t.Errorf("Expected '%v' (%v) to write as '%v', got '%v' instead.", env, envMap, expected, actual) + } + } + //just test some single lines to show the general idea + //TestRoundtrip makes most of the good assertions + + //values are always double-quoted + writeAndCompare(`key=value`, `key="value"`) + //double-quotes are escaped + writeAndCompare(`key=va"lu"e`, `key="va\"lu\"e"`) + //but single quotes are left alone + writeAndCompare(`key=va'lu'e`, `key="va'lu'e"`) + // newlines and backslashes are escaped + writeAndCompare(`foo="ba\n\r\\r!"`, `foo="ba\n\r\\r!"`) +} + +func TestRoundtrip(t *testing.T) { + fixtures := []string{"equals.env", "exported.env", "invalid1.env", "plain.env", "quoted.env"} + for _, fixture := range fixtures { + fixtureFilename := fmt.Sprintf("fixtures/%s", fixture) + env, err := readFile(fixtureFilename) + if err != nil { + continue + } + rep, err := WriteString(env) + if err != nil { + continue + } + roundtripped, err := readString(rep) + if err != nil { + continue + } + if !reflect.DeepEqual(env, roundtripped) { + t.Errorf("Expected '%s' to roundtrip as '%v', got '%v' instead", fixtureFilename, env, roundtripped) + } + + } +}