[config] first cut of config rewritten in golang

still to do:
* Rework disk format to be okay with quotes
* Fix Dockerfile deployments
* Dedup code in the subcommands
* Add --encode
* Add deploytests for the new stuff
* Restore some log messages (should I make some helpers in common?)
* Restore some functions that may be used by 3d-party plugins
This commit is contained in:
Alex Quick
2017-01-16 23:13:32 -05:00
parent 5e3184b61d
commit 294d4b1f29
23 changed files with 1187 additions and 312 deletions

2
plugins/config/.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/commands
/subcommands/*

32
plugins/config/Makefile Normal file
View File

@@ -0,0 +1,32 @@
.PHONY: build-in-docker build clean
GO_REPO_ROOT ?= /go/src/github.com/dokku/dokku
BUILD_IMAGE ?= golang:1.7.1
build-in-docker: clean
docker run --rm \
-v $$PWD/../..:$(GO_REPO_ROOT) \
-w $(GO_REPO_ROOT)/plugins/config \
$(BUILD_IMAGE) \
bash -c "make build" || exit $$?
build: commands subcommands
subcommands: subcommands/export subcommands/set subcommands/get subcommands/unset
commands: **/**/commands.go
go build -a -o commands src/commands/commands.go
subcommands/export: **/**/**/export.go
go build -a -o subcommands/export src/subcommands/export/export.go
subcommands/set: **/**/**/set.go
go build -a -o subcommands/set src/subcommands/set/set.go
subcommands/get: **/**/**/get.go
go build -a -o subcommands/get src/subcommands/get/get.go
subcommands/unset: **/**/**/unset.go
go build -a -o subcommands/unset src/subcommands/unset/unset.go
clean:
rm -rf commands subcommands

View File

@@ -1,33 +0,0 @@
#!/usr/bin/env bash
[[ " help config:help " == *" $1 "* ]] || exit "$DOKKU_NOT_IMPLEMENTED_EXIT"
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
case "$1" in
help | config:help)
help_content_func () {
declare desc="return config plugin help content"
cat<<help_content
config (<app>|--global), Display all global or app-specific config vars
config:get (<app>|--global) KEY, Display a global or app-specific config value
config:set (<app>|--global) KEY1=VALUE1 [KEY2=VALUE2 ...], Set one or more config vars
config:unset (<app>|--global) KEY1 [KEY2 ...], Unset one or more config vars
help_content
}
if [[ $1 = "config:help" ]] ; then
echo -e 'Usage: dokku config[:COMMAND] (<app>|--global)'
echo ''
echo 'Manage global or app-specific config vars.'
echo ''
echo 'Additional commands:'
help_content_func | sort | column -c2 -t -s,
else
help_content_func
fi
;;
*)
exit "$DOKKU_NOT_IMPLEMENTED_EXIT"
;;
esac

View File

@@ -6,251 +6,24 @@ config_export() {
declare desc="returns export command for config variable of specified type (app/global)"
local CONFIG_TYPE="$1"
local APP="$2"
local ENV_FILE="$DOKKU_ROOT/$APP/ENV"
[[ $CONFIG_TYPE == "global" ]] && local ENV_FILE="$DOKKU_ROOT/ENV"
[[ ! -f $ENV_FILE ]] && return 0
[[ ! -s $ENV_FILE ]] && return 0
local VARS=$(grep -Eo "export ([a-zA-Z_][a-zA-Z0-9_]*=.*)" "$ENV_FILE" | cut -d" " -f2-)
echo "$VARS" | awk '{print "export " $0}'
if [[ $CONFIG_TYPE == "global" ]]; then
APP="--global"
fi
dokku config:export "$APP"
return 0
}
get_sanitized_config_args() {
local desc="return sanitized arguments for config plugin"
local SANITIZED_ARGS=("$@")
SANITIZED_ARGS=("${SANITIZED_ARGS[@]//--global/}")
SANITIZED_ARGS=("${SANITIZED_ARGS[@]//--no-restart/}")
SANITIZED_ARGS=("${SANITIZED_ARGS[@]//--export/}")
SANITIZED_ARGS=("${SANITIZED_ARGS[@]//--shell/}")
echo "${SANITIZED_ARGS[@]}"
}
config_parse_args() {
declare desc="parse config plugin args"
unset APP ENV_FILE DOKKU_CONFIG_TYPE DOKKU_CONFIG_RESTART DOKKU_CONFIG_EXPORT
for var; do
if [[ "$var" == "--global" ]]; then
ENV_FILE="$DOKKU_ROOT/ENV"
DOKKU_CONFIG_TYPE="global"
DOKKU_CONFIG_RESTART=false
fi
if [[ "$var" == "--no-restart" ]]; then
DOKKU_CONFIG_RESTART=false
fi
if [[ "$var" == "--export" ]]; then
DOKKU_CONFIG_EXPORT=true
fi
if [[ "$var" == "--shell" ]]; then
DOKKU_CONFIG_SHELL=true
fi
done
local SANITIZED_ARGS=($(get_sanitized_config_args "$@"))
set -- "${SANITIZED_ARGS[@]}"
DOKKU_CONFIG_TYPE=${DOKKU_CONFIG_TYPE:="app"}
DOKKU_CONFIG_RESTART=${DOKKU_CONFIG_RESTART:=true}
if [[ "$DOKKU_CONFIG_TYPE" == "app" ]]; then
if [[ -z $2 ]]; then
dokku_log_fail "Please specify an app to run the command on"
else
verify_app_name "$2"
APP=${APP:="$2"}
ENV_FILE=${ENV_FILE:="$DOKKU_ROOT/$APP/ENV"}
fi
fi
export APP ENV_FILE DOKKU_CONFIG_TYPE DOKKU_CONFIG_RESTART DOKKU_CONFIG_EXPORT
}
config_create () {
declare desc="create config env file"
local ENV_FILE=$1
[[ -f $ENV_FILE ]] || touch "$ENV_FILE"
}
config_styled_hash () {
declare desc="internal config hash"
local vars="$1"
local prefix="$2"
local longest=""
while read -r word; do
local KEY=$(echo "$word" | cut -d"=" -f1)
if [[ ${#KEY} -gt ${#longest} ]]; then
local longest=$KEY
fi
done <<< "$vars"
while read -r word; do
local KEY=$(echo "$word" | cut -d"=" -f1)
local VALUE=$(echo "$word" | cut -d"=" -f2- | sed -e "s/^'//" -e "s/'$//" -e "s/\$$//g")
local num_zeros=$((${#longest} - ${#KEY}))
local zeros=" "
while [[ $num_zeros -gt 0 ]]; do
local zeros="$zeros "
local num_zeros=$((num_zeros - 1))
done
echo "$prefix$KEY:$zeros$VALUE"
done <<< "$vars"
}
config_write() {
declare desc="writes config vars out to appropriate file path"
local ENV_TEMP="$1"
local ENV_FILE_TEMP="${ENV_FILE}.tmp"
echo "$ENV_TEMP" | sed '/^$/d' | sort > "$ENV_FILE_TEMP"
if ! cmp -s "$ENV_FILE" "$ENV_FILE_TEMP"; then
cp -f "$ENV_FILE_TEMP" "$ENV_FILE"
chmod 600 "$ENV_FILE"
fi
rm -f "$ENV_FILE_TEMP"
}
config_all() {
declare desc="print or export config vars"
[[ "$1" == "config" ]] || set -- "config" "$@"
config_parse_args "$@"
local SANITIZED_ARGS=($(get_sanitized_config_args "$@"))
set -- "${SANITIZED_ARGS[@]}"
config_create "$ENV_FILE"
[[ $DOKKU_CONFIG_EXPORT ]] && config_export "$DOKKU_CONFIG_TYPE" "$APP" && return 0
[[ $APP ]] && local DOKKU_CONFIG_TYPE=$APP
[[ ! -s $ENV_FILE ]] && dokku_log_fail "no config vars for $DOKKU_CONFIG_TYPE"
local VARS=$(grep -Eo "export ([a-zA-Z_][a-zA-Z0-9_]*=.*)" "$ENV_FILE" | cut -d" " -f2-)
[[ $DOKKU_CONFIG_SHELL ]] && echo "$VARS" && return 0
dokku_log_info2_quiet "$DOKKU_CONFIG_TYPE config vars"
config_styled_hash "$VARS"
}
config_get() {
declare desc="get value of given config var"
[[ "$1" == "config:get" ]] || set -- "config:get" "$@"
config_parse_args "$@"
local SANITIZED_ARGS=($(get_sanitized_config_args "$@"))
set -- "${SANITIZED_ARGS[@]}"
if [[ -z $2 ]]; then
echo "Usage: dokku config:get APP KEY"
echo "Must specify KEY."
exit 1
fi
config_create "$ENV_FILE"
if [[ ! -s $ENV_FILE ]]; then
return 1
fi
local KEY="${*: -1}"
grep -Eo "export ([a-zA-Z_][a-zA-Z0-9_]*=.*)" "$ENV_FILE" | grep "^export $KEY=" | cut -d"=" -f2- | sed -e "s/^'//" -e "s/'$//"
dokku config:get "$@"
}
config_set() {
declare desc="set value of given config var"
source "$PLUGIN_AVAILABLE_PATH/ps/functions"
[[ "$1" == "config:set" ]] || set -- "config:set" "$@"
config_parse_args "$@"
if [[ -z "${*:3}" ]]; then
echo "Usage: dokku config:set APP KEY1=VALUE1 [KEY2=VALUE2 ...]"
echo "Whitespaces need to be escaped, i.e. KEY=\"VAL\ WITH\ SPACES\""
echo "Must specify KEY and VALUE to set."
exit 1
fi
config_create "$ENV_FILE"
local ENV_ADD=""
local ENV_TEMP=$(cat "${ENV_FILE}")
local RESTART_APP=false
shift 2
for var; do
if [[ $var == "--global" ]] || [[ $var == "--no-restart" ]] || ([[ $APP ]] && [[ $var == "$APP" ]]); then
shift 1
elif [[ $var != *"="* ]]; then
echo "Usage: dokku config:set APP KEY1=VALUE1 [KEY2=VALUE2 ...]"
echo "Whitespaces need to be escaped, i.e. KEY=\"VAL\ WITH\ SPACES\""
echo "Must specify KEY and VALUE to set."
exit 1
fi
done
for var; do
local KEY=$(echo ${var} | cut -d"=" -f1)
local VALUE=$(echo ${var} | cut -d"=" -f2-)
if [[ $KEY =~ [a-zA-Z_][a-zA-Z0-9_]* ]]; then
local RESTART_APP=true
local ENV_TEMP=$(echo "${ENV_TEMP}" | sed "/^export $KEY=/ d")
local ENV_TEMP="${ENV_TEMP}
export $KEY='$VALUE'"
local ENV_ADD=$(echo -e "${ENV_ADD}" | sed "/^$KEY=/ d")
local ENV_ADD="${ENV_ADD}$
${var}"
fi
done
local ENV_ADD=$(echo "$ENV_ADD" | tail -n +2) #remove first empty line
if [[ $RESTART_APP ]]; then
dokku_log_info1 "Setting config vars"
config_styled_hash "$ENV_ADD" " "
config_write "$ENV_TEMP"
plugn trigger post-config-update "$APP" "set" "$@"
fi
[[ $APP ]] && local DOKKU_APP_RESTORE=$(config_get "$APP" DOKKU_APP_RESTORE || true)
if [[ "$DOKKU_CONFIG_RESTART" == "true" ]] && [[ "$DOKKU_APP_RESTORE" != 0 ]]; then
dokku_log_info1 "Restarting app $APP"
ps_restart "$APP"
fi
dokku config:set "$@"
}
config_unset() {
declare desc="unset given config var"
source "$PLUGIN_AVAILABLE_PATH/ps/functions"
[[ "$1" == "config:unset" ]] || set -- "config:unset" "$@"
config_parse_args "$@"
local SANITIZED_ARGS=($(get_sanitized_config_args "$@"))
set -- "${SANITIZED_ARGS[@]}"
if [[ -z $2 ]]; then
echo "Usage: dokku config:unset APP KEY1 [KEY2 ...]"
echo "Must specify KEY to unset."
exit 1
fi
config_create "$ENV_FILE"
local ENV_TEMP=$(cat "${ENV_FILE}")
local VARS="${*:2}"
for var in $VARS; do
dokku_log_info1 "Unsetting $var"
local ENV_TEMP=$(echo "${ENV_TEMP}" | sed "/^export $var=/ d")
config_write "$ENV_TEMP"
done
plugn trigger post-config-update "$APP" "unset" "$@"
[[ $APP ]] && local DOKKU_APP_RESTORE=$(config_get "$APP" DOKKU_APP_RESTORE || true)
if [[ "$DOKKU_CONFIG_RESTART" == "true" ]] && [[ "$DOKKU_APP_RESTORE" != 0 ]]; then
dokku_log_info1 "Restarting app $APP"
ps_restart "$APP"
fi
declare desc="set value of given config var"
dokku config:unset "$@"
}

View File

@@ -0,0 +1,66 @@
package main
import (
"flag"
"fmt"
"os"
"strconv"
"strings"
common "github.com/dokku/dokku/plugins/common"
configenv "github.com/dokku/dokku/plugins/config/src/configenv"
columnize "github.com/ryanuber/columnize"
)
const (
helpHeader = `Usage: dokku config [<app>|--global]
Display all global or app-specific config vars
Additional commands:`
helpContent = `
config:get (<app>|--global) KEY, Display a global or app-specific config value
config:set (<app>|--global) [--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
`
)
func main() {
flag.Usage = usage
flag.Parse()
cmd := flag.Arg(0)
switch cmd {
case "config":
target := flag.Arg(1)
env, err := configenv.NewFromTarget(target)
if err != nil {
common.LogFail(err.Error())
} else {
fmt.Println(env.String())
}
case "config:help":
usage()
case "help":
fmt.Print(helpContent)
default:
dokkuNotImplementExitCode, err := strconv.Atoi(os.Getenv("DOKKU_NOT_IMPLEMENTED_EXIT"))
if err != nil {
fmt.Println("failed to retrieve DOKKU_NOT_IMPLEMENTED_EXIT environment variable")
dokkuNotImplementExitCode = 10
}
os.Exit(dokkuNotImplementExitCode)
}
}
func usage() {
config := columnize.DefaultConfig()
config.Delim = ","
config.Prefix = "\t"
config.Empty = ""
content := strings.Split(helpContent, "\n")[1:]
fmt.Println(helpHeader)
fmt.Println(columnize.Format(content, config))
}

View File

@@ -0,0 +1,139 @@
package configenv
import (
"errors"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
common "github.com/dokku/dokku/plugins/common"
)
//Env is a representation for global or app environment
type Env struct {
name string
env map[string]string
filename string
}
func (e *Env) String() string {
return e.EnvfileString()
}
//EnvfileString returns the contents of this Env in ENVFILE format
func (e *Env) EnvfileString() string {
return e.stringWithEntryPrefix("")
}
//ExportfileString returns the contents of this Env as bash exports
func (e *Env) ExportfileString() string {
return e.stringWithEntryPrefix("export ")
}
func (e *Env) stringWithEntryPrefix(prefix string) string {
keys := e.Keys()
sort.Strings(keys)
entries := make([]string, len(keys))
for i, k := range keys {
entries[i] = fmt.Sprintf("%s%s='%s'", prefix, k, escape(e.env[k]))
}
return strings.Join(entries, "\n")
}
func escape(value string) string { //TODO: improve this
return strings.Replace(value, "'", "\\'", -1)
}
//NewFromTarget creates an env from the given target. Tareget is either "--global" or an app name
func NewFromTarget(target string) (*Env, error) {
if target == "--global" {
return parseEnv("global", getGlobalFile())
}
appfile, err := getAppFile(target)
if err != nil {
return nil, err
}
return parseEnv(target, appfile)
}
//NewFromString creates an env from the given ENVFILE contents representation
func NewFromString(rep string) (*Env, error) {
return parseEnvFromReader("<unknown>", "", strings.NewReader(rep))
}
//Set an environment variable
func (e *Env) Set(key string, value string) {
e.env[key] = value
}
//Unset an environment variable
func (e *Env) Unset(key string) {
delete(e.env, key)
}
//Keys gets the keys in this environment
func (e *Env) Keys() []string {
keys := make([]string, 0, len(e.env))
for k := range e.env {
keys = append(keys, k)
}
return keys
}
//Get an environment variable
func (e *Env) Get(key string) (string, bool) {
v, ok := e.env[key]
return v, ok
}
//GetDefault an environment variable or a default if it doesnt exist
func (e *Env) GetDefault(key string, defaultValue string) string {
v, ok := e.env[key]
if !ok {
return defaultValue
}
return v
}
//GetBoolDefault gets the bool value of the given key with the given default
//right now that is evaluated as `value != "0"`
func (e *Env) GetBoolDefault(key string, defaultValue bool) bool {
v, ok := e.Get(key)
if !ok {
return defaultValue
}
return v != "0"
}
//Map return the Env as a map
func (e *Env) Map() map[string]string {
return e.env
}
//Write an Env back to the file it was read from as an exportfile
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
}
func getAppFile(appName string) (string, error) {
err := common.VerifyAppName(appName)
if err != nil {
return "", err
}
return filepath.Join(common.MustGetEnv("DOKKU_ROOT"), appName, "ENV"), nil
}
func getGlobalFile() string {
return filepath.Join(common.MustGetEnv("DOKKU_ROOT"), "ENV")
}

View File

@@ -0,0 +1,42 @@
package configenv
import (
"testing"
. "github.com/onsi/gomega"
)
func TestBasic(t *testing.T) {
RegisterTestingT(t)
env, err := NewFromString("HI=ho")
Expect(err).NotTo(HaveOccurred())
Expect(env.Map()).To(Equal(pairs("HI", "ho")))
Expect(env.String()).To(Equal("HI='ho'"))
env, err = NewFromString("HI=h\\o\\'")
Expect(err).NotTo(HaveOccurred())
Expect(env.Map()).To(Equal(pairs("HI", "h\\o'")))
Expect(env.String()).To(Equal("HI='h\\o\\''"))
env, err = NewFromString("HI=ho\nFOO=bar\n\n")
Expect(err).NotTo(HaveOccurred())
Expect(env.Map()).To(Equal(pairs("HI", "ho", "FOO", "bar")))
Expect(env.String()).To(Equal("FOO='bar'\nHI='ho'"))
env, err = NewFromString("HI='ho\n'\n\nFOO='bar\\'\nbaz'")
Expect(err).NotTo(HaveOccurred())
Expect(env.Map()).To(Equal(pairs("HI", "ho\n", "FOO", "bar'\nbaz")))
Expect(env.String()).To(Equal("FOO='bar\\'\nbaz'\nHI='ho\n'"))
}
func pairs(vars ...string) map[string]string {
res := map[string]string{}
var i = 0
for i < len(vars)-1 {
key := vars[i]
value := vars[i+1]
res[key] = value
i += 2
}
return res
}

View File

@@ -0,0 +1,119 @@
package configenv
import (
"bufio"
"bytes"
"errors"
"io"
"os"
)
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'. Eait pair is terminated with a newline.
//All characters are valid for VALUE without escaping
//but one: the single quote and backslash. To represent a single quote emit \'. To represent
//a literal backslash emit \\. In any other case, the backslash is interpreted literally.
//KEY='value
//one'
//KEY='val\\ue\\'' etc..
//TODO: We might want to rework this to use shell-style quoting for single quotes
buffered := bufio.NewReader(reader)
const (
StateKey = iota
StateValue = iota
)
env := &Env{name, map[string]string{}, filename}
var buffer bytes.Buffer
var state = StateKey
var quoted = 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" {
buffer.Truncate(0) //so we can read exportfiles too
} else {
return nil, errors.New("Env keys cannot have spaces")
}
case '=':
key = buffer.String()
buffer.Truncate(0)
state = StateValue
case '\n':
if buffer.Len() > 0 {
return nil, errors.New("Invalid newline after: " + buffer.String())
}
default:
buffer.WriteRune(char)
}
case StateValue:
switch char {
case '\'':
if escaped {
buffer.WriteRune('\'')
escaped = false
} else {
quoted = !quoted
}
case '\n':
if escaped || quoted {
buffer.WriteRune(char)
} else {
state = StateKey
env.Set(key, buffer.String())
buffer.Truncate(0)
key = ""
escaped = false
quoted = false
}
case '\\':
if escaped {
buffer.WriteRune(char)
escaped = false
} else {
escaped = true
}
default:
if escaped {
buffer.WriteRune('\\')
escaped = false
}
buffer.WriteRune(char)
}
}
}
if escaped {
return nil, errors.New("Unterminated escape")
}
if quoted {
return nil, errors.New("Unterminated quote")
}
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
}

6
plugins/config/src/glide.lock generated Normal file
View File

@@ -0,0 +1,6 @@
hash: 1ddab5de41d1514c2722bd7e24758ad4b60bf6956eb5b9b925fa071a1427f149
updated: 2017-01-03T17:16:50.97156327-08:00
imports:
- name: github.com/ryanuber/columnize
version: 0fbbb3f0e3fbdc5bae7c6cd5f6c1887ebfb76360
testImports: []

View File

@@ -0,0 +1,3 @@
package: .
import:
- package: github.com/ryanuber/columnize

View File

@@ -0,0 +1,40 @@
package main
import (
"flag"
"fmt"
"os"
common "github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/config/src/configenv"
)
// print the environment to stdout
func main() {
args := flag.NewFlagSet("config:export", flag.ExitOnError)
global := args.Bool("global", false, "--global: use the global environment")
envfile := args.Bool("envfile", false, "--envfile: export as envfile rather than bash exports")
args.Parse(os.Args[2:])
appName := args.Arg(0)
if appName == "" && !*global {
common.LogFail("Please specify an app or --global")
}
if *global {
if appName != "" {
common.LogFail("Trailing argument: " + appName)
}
appName = "--global"
}
env, err := configenv.NewFromTarget(appName)
if err != nil {
common.LogFail(err.Error())
}
if *envfile {
fmt.Println(env.EnvfileString())
} else {
fmt.Println(env.ExportfileString())
}
}

View File

@@ -0,0 +1,39 @@
package main
import (
"flag"
"fmt"
"os"
common "github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/config/src/configenv"
)
// get the given entries from the specified environment
func main() {
args := flag.NewFlagSet("config:get", flag.ExitOnError)
global := args.Bool("global", false, "--global: use the global environment")
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
}
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)
fmt.Printf("%s", env.GetDefault(key, ""))
}

View File

@@ -0,0 +1,61 @@
package main
import (
"flag"
"strings"
"os"
common "github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/config/src/configenv"
)
// set the given entries from the specified environment
func main() {
args := flag.NewFlagSet("config:set", 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
entries := args.Args()[nextArg:]
for _, e := range entries {
//log
parts := strings.SplitN(e, "=", 2)
if len(parts) == 1 {
common.LogFail("Invalid env pair: " + e)
}
env.Set(parts[0], parts[1])
changed = true
}
if changed {
//log
env.Write()
args := append([]string{appName, "set"}, entries...)
common.PlugnTrigger("post-config-update", args...)
}
if shouldRestart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) {
cmd := common.NewTokenizedShellCmd("dokku", "ps:restart", appName)
cmd.Execute()
}
}

View File

@@ -0,0 +1,54 @@
package main
import (
"flag"
"os"
common "github.com/dokku/dokku/plugins/common"
"github.com/dokku/dokku/plugins/config/src/configenv"
)
//unset the given entries from the given environment
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 {
//log
env.Unset(k)
changed = true
}
if changed {
//log
env.Write()
args := append([]string{appName, "unset"}, keys...)
common.PlugnTrigger("post-config-update", args...)
}
if shouldRestart && env.GetBoolDefault("DOKKU_APP_RESTORE", true) {
cmd := common.NewTokenizedShellCmd("dokku", "ps:restart", appName)
cmd.Execute()
}
}

View File

@@ -0,0 +1,3 @@
language: go
go:
- tip

View File

@@ -0,0 +1,20 @@
Copyright (c) 2016 Ryan Uber
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.

View File

@@ -0,0 +1,69 @@
Columnize
=========
Easy column-formatted output for golang
[![Build Status](https://travis-ci.org/ryanuber/columnize.svg)](https://travis-ci.org/ryanuber/columnize)
[![GoDoc](https://godoc.org/github.com/ryanuber/columnize?status.svg)](https://godoc.org/github.com/ryanuber/columnize)
Columnize is a really small Go package that makes building CLI's a little bit
easier. In some CLI designs, you want to output a number similar items in a
human-readable way with nicely aligned columns. However, figuring out how wide
to make each column is a boring problem to solve and eats your valuable time.
Here is an example:
```go
package main
import (
"fmt"
"github.com/ryanuber/columnize"
)
func main() {
output := []string{
"Name | Gender | Age",
"Bob | Male | 38",
"Sally | Female | 26",
}
result := columnize.SimpleFormat(output)
fmt.Println(result)
}
```
As you can see, you just pass in a list of strings. And the result:
```
Name Gender Age
Bob Male 38
Sally Female 26
```
Columnize is tolerant of missing or empty fields, or even empty lines, so
passing in extra lines for spacing should show up as you would expect.
Configuration
=============
Columnize is configured using a `Config`, which can be obtained by calling the
`DefaultConfig()` method. You can then tweak the settings in the resulting
`Config`:
```
config := columnize.DefaultConfig()
config.Delim = "|"
config.Glue = " "
config.Prefix = ""
config.Empty = ""
```
* `Delim` is the string by which columns of **input** are delimited
* `Glue` is the string by which columns of **output** are delimited
* `Prefix` is a string by which each line of **output** is prefixed
* `Empty` is a string used to replace blank values found in output
You can then pass the `Config` in using the `Format` method (signature below) to
have text formatted to your liking.
See the [godoc](https://godoc.org/github.com/ryanuber/columnize) page for usage.

View File

@@ -0,0 +1,178 @@
package columnize
import (
"bytes"
"fmt"
"strings"
)
// Config can be used to tune certain parameters which affect the way
// in which Columnize will format output text.
type Config struct {
// The string by which the lines of input will be split.
Delim string
// The string by which columns of output will be separated.
Glue string
// The string by which columns of output will be prefixed.
Prefix string
// A replacement string to replace empty fields
Empty string
}
// DefaultConfig returns a *Config with default values.
func DefaultConfig() *Config {
return &Config{
Delim: "|",
Glue: " ",
Prefix: "",
Empty: "",
}
}
// MergeConfig merges two config objects together and returns the resulting
// configuration. Values from the right take precedence over the left side.
func MergeConfig(a, b *Config) *Config {
var result Config = *a
// Return quickly if either side was nil
if a == nil || b == nil {
return &result
}
if b.Delim != "" {
result.Delim = b.Delim
}
if b.Glue != "" {
result.Glue = b.Glue
}
if b.Prefix != "" {
result.Prefix = b.Prefix
}
if b.Empty != "" {
result.Empty = b.Empty
}
return &result
}
// stringFormat, given a set of column widths and the number of columns in
// the current line, returns a sprintf-style format string which can be used
// to print output aligned properly with other lines using the same widths set.
func stringFormat(c *Config, widths []int, columns int) string {
// Create the buffer with an estimate of the length
buf := bytes.NewBuffer(make([]byte, 0, (6+len(c.Glue))*columns))
// Start with the prefix, if any was given. The buffer will not return an
// error so it does not need to be handled
buf.WriteString(c.Prefix)
// Create the format string from the discovered widths
for i := 0; i < columns && i < len(widths); i++ {
if i == columns-1 {
buf.WriteString("%s\n")
} else {
fmt.Fprintf(buf, "%%-%ds%s", widths[i], c.Glue)
}
}
return buf.String()
}
// elementsFromLine returns a list of elements, each representing a single
// item which will belong to a column of output.
func elementsFromLine(config *Config, line string) []interface{} {
seperated := strings.Split(line, config.Delim)
elements := make([]interface{}, len(seperated))
for i, field := range seperated {
value := strings.TrimSpace(field)
// Apply the empty value, if configured.
if value == "" && config.Empty != "" {
value = config.Empty
}
elements[i] = value
}
return elements
}
// runeLen calculates the number of visible "characters" in a string
func runeLen(s string) int {
l := 0
for _ = range s {
l++
}
return l
}
// widthsFromLines examines a list of strings and determines how wide each
// column should be considering all of the elements that need to be printed
// within it.
func widthsFromLines(config *Config, lines []string) []int {
widths := make([]int, 0, 8)
for _, line := range lines {
elems := elementsFromLine(config, line)
for i := 0; i < len(elems); i++ {
l := runeLen(elems[i].(string))
if len(widths) <= i {
widths = append(widths, l)
} else if widths[i] < l {
widths[i] = l
}
}
}
return widths
}
// Format is the public-facing interface that takes a list of strings and
// returns nicely aligned column-formatted text.
func Format(lines []string, config *Config) string {
conf := MergeConfig(DefaultConfig(), config)
widths := widthsFromLines(conf, lines)
// Estimate the buffer size
glueSize := len(conf.Glue)
var size int
for _, w := range widths {
size += w + glueSize
}
size *= len(lines)
// Create the buffer
buf := bytes.NewBuffer(make([]byte, 0, size))
// Create a cache for the string formats
fmtCache := make(map[int]string, 16)
// Create the formatted output using the format string
for _, line := range lines {
elems := elementsFromLine(conf, line)
// Get the string format using cache
numElems := len(elems)
stringfmt, ok := fmtCache[numElems]
if !ok {
stringfmt = stringFormat(conf, widths, numElems)
fmtCache[numElems] = stringfmt
}
fmt.Fprintf(buf, stringfmt, elems...)
}
// Get the string result
result := buf.String()
// Remove trailing newline without removing leading/trailing space
if n := len(result); n > 0 && result[n-1] == '\n' {
result = result[:n-1]
}
return result
}
// SimpleFormat is a convenience function to format text with the defaults.
func SimpleFormat(lines []string) string {
return Format(lines, nil)
}

View File

@@ -0,0 +1,306 @@
package columnize
import (
"fmt"
"testing"
crand "crypto/rand"
)
func TestListOfStringsInput(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"x | y | z",
}
config := DefaultConfig()
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestEmptyLinesOutput(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"",
"x | y | z",
}
config := DefaultConfig()
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "\n"
expected += "x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestLeadingSpacePreserved(t *testing.T) {
input := []string{
"| Column B | Column C",
"x | y | z",
}
config := DefaultConfig()
output := Format(input, config)
expected := " Column B Column C\n"
expected += "x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestColumnWidthCalculator(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"Longer than A | Longer than B | Longer than C",
"short | short | short",
}
config := DefaultConfig()
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "Longer than A Longer than B Longer than C\n"
expected += "short short short"
if output != expected {
printableProof := fmt.Sprintf("\nGot: %+q", output)
printableProof += fmt.Sprintf("\nExpected: %+q", expected)
t.Fatalf("\n%s", printableProof)
}
}
func TestColumnWidthCalculatorNonASCII(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"⌘⌘⌘⌘⌘⌘⌘⌘ | Longer than B | Longer than C",
"short | short | short",
}
config := DefaultConfig()
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "⌘⌘⌘⌘⌘⌘⌘⌘ Longer than B Longer than C\n"
expected += "short short short"
if output != expected {
printableProof := fmt.Sprintf("\nGot: %+q", output)
printableProof += fmt.Sprintf("\nExpected: %+q", expected)
t.Fatalf("\n%s", printableProof)
}
}
func BenchmarkColumnWidthCalculator(b *testing.B) {
// Generate the input
input := []string{
"UUID A | UUID B | UUID C | Column D | Column E",
}
format := "%s|%s|%s|%s"
short := "short"
uuid := func() string {
buf := make([]byte, 16)
if _, err := crand.Read(buf); err != nil {
panic(fmt.Errorf("failed to read random bytes: %v", err))
}
return fmt.Sprintf("%08x-%04x-%04x-%04x-%12x",
buf[0:4],
buf[4:6],
buf[6:8],
buf[8:10],
buf[10:16])
}
for i := 0; i < 1000; i++ {
l := fmt.Sprintf(format, uuid()[:8], uuid()[:12], uuid(), short, short)
input = append(input, l)
}
config := DefaultConfig()
b.ResetTimer()
for i := 0; i < b.N; i++ {
Format(input, config)
}
}
func TestVariedInputSpacing(t *testing.T) {
input := []string{
"Column A |Column B| Column C",
"x|y| z",
}
config := DefaultConfig()
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestUnmatchedColumnCounts(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"Value A | Value B",
"Value A | Value B | Value C | Value D",
}
config := DefaultConfig()
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "Value A Value B\n"
expected += "Value A Value B Value C Value D"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestAlternateDelimiter(t *testing.T) {
input := []string{
"Column | A % Column | B % Column | C",
"Value A % Value B % Value C",
}
config := DefaultConfig()
config.Delim = "%"
output := Format(input, config)
expected := "Column | A Column | B Column | C\n"
expected += "Value A Value B Value C"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestAlternateSpacingString(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"x | y | z",
}
config := DefaultConfig()
config.Glue = " "
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestSimpleFormat(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"x | y | z",
}
output := SimpleFormat(input)
expected := "Column A Column B Column C\n"
expected += "x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestAlternatePrefixString(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"x | y | z",
}
config := DefaultConfig()
config.Prefix = " "
output := Format(input, config)
expected := " Column A Column B Column C\n"
expected += " x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestEmptyFieldReplacement(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"x | | z",
}
config := DefaultConfig()
config.Empty = "<none>"
output := Format(input, config)
expected := "Column A Column B Column C\n"
expected += "x <none> z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestEmptyConfigValues(t *testing.T) {
input := []string{
"Column A | Column B | Column C",
"x | y | z",
}
config := Config{}
output := Format(input, &config)
expected := "Column A Column B Column C\n"
expected += "x y z"
if output != expected {
t.Fatalf("\nexpected:\n%s\n\ngot:\n%s", expected, output)
}
}
func TestMergeConfig(t *testing.T) {
conf1 := &Config{Delim: "a", Glue: "a", Prefix: "a", Empty: "a"}
conf2 := &Config{Delim: "b", Glue: "b", Prefix: "b", Empty: "b"}
conf3 := &Config{Delim: "c", Prefix: "c"}
m := MergeConfig(conf1, conf2)
if m.Delim != "b" || m.Glue != "b" || m.Prefix != "b" || m.Empty != "b" {
t.Fatalf("bad: %#v", m)
}
m = MergeConfig(conf1, conf3)
if m.Delim != "c" || m.Glue != "a" || m.Prefix != "c" || m.Empty != "a" {
t.Fatalf("bad: %#v", m)
}
m = MergeConfig(conf1, nil)
if m.Delim != "a" || m.Glue != "a" || m.Prefix != "a" || m.Empty != "a" {
t.Fatalf("bad: %#v", m)
}
m = MergeConfig(conf1, &Config{})
if m.Delim != "a" || m.Glue != "a" || m.Prefix != "a" || m.Empty != "a" {
t.Fatalf("bad: %#v", m)
}
}

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_AVAILABLE_PATH/config/functions"
config_main_cmd() {
declare desc="print config vars for app via command line"
local cmd="config"
config_all "$@"
}
config_main_cmd "$@"

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_AVAILABLE_PATH/config/functions"
config_get_cmd() {
declare desc="get specified config vars from app via command line"
local cmd="config:get"
config_get "$@"
}
config_get_cmd "$@"

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_AVAILABLE_PATH/config/functions"
config_set_cmd() {
declare desc="set specified config vars for app via command line"
local cmd="config:set"
config_set "$@"
}
config_set_cmd "$@"

View File

@@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -eo pipefail; [[ $DOKKU_TRACE ]] && set -x
source "$PLUGIN_AVAILABLE_PATH/config/functions"
config_unset_cmd() {
declare desc="unset specified config vars for app via command line"
local cmd="config:unset"
config_unset "$@"
}
config_unset_cmd "$@"