[config] move to godotenv

This commit is contained in:
Alex Quick
2017-05-20 14:38:27 -04:00
parent 08aa2b9fa4
commit eeb6e78fa4
30 changed files with 1189 additions and 362 deletions

View File

@@ -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 (<app>|--global) [--encoded] [--no-restart] KEY1=VALUE1 [KEY2=VALUE2 ...], Set one or more config vars
config:unset (<app>|--global) KEY1 [KEY2 ...], Unset one or more config vars
config:export (<app>|--global) [--envfile], Export a global or app environment
config:keys (<app> | --global) [--merged], Show keys set in environment
config:bundle (<app> | --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":

View File

@@ -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("<unknown>", "", strings.NewReader(rep))
envMap, err := godotenv.ReadFromReader(strings.NewReader(rep))
env := &Env{
"<unknown>",
"",
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) {

View File

@@ -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 {

View File

@@ -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
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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 {

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}