diff --git a/plugins/config/Makefile b/plugins/config/Makefile index 0cf396bd8..1f1be8b3c 100644 --- a/plugins/config/Makefile +++ b/plugins/config/Makefile @@ -1,5 +1,5 @@ GOARCH ?= amd64 -SUBCOMMANDS = subcommands/bundle subcommands/clear subcommands/export subcommands/get subcommands/keys subcommands/show subcommands/set subcommands/unset +SUBCOMMANDS = subcommands/bundle subcommands/clear subcommands/export subcommands/get subcommands/import subcommands/keys subcommands/show subcommands/set subcommands/unset TRIGGERS = triggers/config-export triggers/config-get triggers/config-get-global triggers/config-unset triggers/post-app-clone-setup triggers/post-app-rename-setup BUILD = commands config_sub subcommands triggers PLUGIN_NAME = config diff --git a/plugins/config/config.go b/plugins/config/config.go index 90f04039d..18d4d9403 100644 --- a/plugins/config/config.go +++ b/plugins/config/config.go @@ -30,7 +30,7 @@ func GetWithDefault(appName string, key string, defaultValue string) (value stri } // 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) (err error) { +func SetMany(appName string, entries map[string]string, replace bool, restart bool) (err error) { global := appName == "" || appName == "--global" env, err := loadAppOrGlobalEnv(appName) if err != nil { @@ -42,6 +42,10 @@ func SetMany(appName string, entries map[string]string, restart bool) (err error return } } + + if replace { + env.Clear() + } for k, v := range entries { env.Set(k, v) keys = append(keys, k) @@ -128,6 +132,10 @@ func UnsetAll(appName string, restart bool) (err error) { } func triggerRestart(appName string) { + if !common.IsDeployed(appName) { + return + } + common.LogInfo1(fmt.Sprintf("Restarting app %s", appName)) _, err := common.CallPlugnTrigger(common.PlugnTriggerInput{ Trigger: "release-and-deploy", diff --git a/plugins/config/config_test.go b/plugins/config/config_test.go index e6b9abfce..5c43edbb9 100644 --- a/plugins/config/config_test.go +++ b/plugins/config/config_test.go @@ -128,6 +128,82 @@ func TestConfigUnsetMany(t *testing.T) { Expect(CommandUnset(testAppName+"does-not-exist", keys, false, true)).ToNot(Succeed()) } +func TestConfigImport(t *testing.T) { + RegisterTestingT(t) + Expect(setupTests()).To(Succeed()) + Expect(setupTestApp()).To(Succeed()) + defer teardownTestApp() + + tempFile, err := os.CreateTemp("", "test-config-import-*.env") + Expect(err).To(Succeed()) + defer os.Remove(tempFile.Name()) + content := ` +testKey=TESTING +testKey2=TESTING-'\n'-updated +` + _, err = tempFile.WriteString(content) + Expect(err).To(Succeed()) + tempFile.Close() + + Expect(CommandImport(testAppName, false, false, true, "envfile", tempFile.Name())).To(Succeed()) + expectValue(testAppName, "testKey", "TESTING") + expectValue(testAppName, "testKey2", "TESTING-'\n'-updated") + + env, err := LoadAppEnv(testAppName) + Expect(err).To(Succeed()) + env.Set("testKey", "TESTING-updated2") + env.Set("testKey2", "TESTING-'\n'-updated2") + env.Set("testKey3", "TESTING-'\n'-updated3") + env.Write() + + Expect(CommandImport(testAppName, false, false, true, "envfile", tempFile.Name())).To(Succeed()) + expectValue(testAppName, "testKey", "TESTING-updated2") + expectValue(testAppName, "testKey2", "TESTING-'\n'-updated2") + expectValue(testAppName, "testKey3", "TESTING-'\n'-updated3") + + Expect(CommandImport(testAppName, false, true, true, "envfile", tempFile.Name())).To(Succeed()) + expectValue(testAppName, "testKey", "TESTING-updated") + expectValue(testAppName, "testKey2", "TESTING-'\n'-updated") + expectNoValue(testAppName, "testKey3") +} + +func TestConfigImportJSON(t *testing.T) { + RegisterTestingT(t) + Expect(setupTests()).To(Succeed()) + Expect(setupTestApp()).To(Succeed()) + defer teardownTestApp() + + tempFile, err := os.CreateTemp("", "test-config-import-*.json") + Expect(err).To(Succeed()) + defer os.Remove(tempFile.Name()) + + content := `{"testKey": "TESTING", "testKey2": "TESTING-'\n'-updated"}` + _, err = tempFile.WriteString(content) + Expect(err).To(Succeed()) + tempFile.Close() + + Expect(CommandImport(testAppName, false, false, true, "json", tempFile.Name())).To(Succeed()) + expectValue(testAppName, "testKey", "TESTING") + expectValue(testAppName, "testKey2", "TESTING-'\n'-updated") + + env, err := LoadAppEnv(testAppName) + Expect(err).To(Succeed()) + env.Set("testKey", "TESTING-updated2") + env.Set("testKey2", "TESTING-'\n'-updated2") + env.Set("testKey3", "TESTING-'\n'-updated3") + env.Write() + + Expect(CommandImport(testAppName, false, false, true, "json", tempFile.Name())).To(Succeed()) + expectValue(testAppName, "testKey", "TESTING-updated2") + expectValue(testAppName, "testKey2", "TESTING-'\n'-updated2") + expectValue(testAppName, "testKey3", "TESTING-'\n'-updated3") + + Expect(CommandImport(testAppName, false, true, true, "json", tempFile.Name())).To(Succeed()) + expectValue(testAppName, "testKey", "TESTING-updated") + expectValue(testAppName, "testKey2", "TESTING-'\n'-updated") + expectNoValue(testAppName, "testKey3") +} + func TestEnvironmentLoading(t *testing.T) { RegisterTestingT(t) Expect(setupTests()).To(Succeed()) @@ -161,12 +237,13 @@ func TestInvalidKeys(t *testing.T) { invalidKeys := []string{"0invalidKey", "invalid:key", "invalid=Key", "!invalidKey"} for _, key := range invalidKeys { - Expect(SetMany(testAppName, map[string]string{key: "value"}, false)).NotTo(Succeed()) + Expect(SetMany(testAppName, map[string]string{key: "value"}, false, false)).NotTo(Succeed()) Expect(UnsetMany(testAppName, []string{key}, false)).NotTo(Succeed()) value, ok := Get(testAppName, key) Expect(ok).To(Equal(false)) - value = GetWithDefault(testAppName, key, "default") - Expect(value).To(Equal("default")) + Expect(value).To(Equal("")) + value2 := GetWithDefault(testAppName, key, "default") + Expect(value2).To(Equal("default")) } } diff --git a/plugins/config/environment.go b/plugins/config/environment.go index 877939e5f..6e341dd62 100644 --- a/plugins/config/environment.go +++ b/plugins/config/environment.go @@ -88,6 +88,11 @@ func LoadGlobalEnv() (*Env, error) { return loadFromFile("", getGlobalFile()) } +// Clear clears the environment +func (e *Env) Clear() { + e.env = make(map[string]string) +} + // Filename returns the full path on disk to the file holding the env vars func (e *Env) Filename() string { return e.filename @@ -326,8 +331,17 @@ func prettyPrintEnvEntries(prefix string, entries map[string]string) string { func loadFromFile(name string, filename string) (env *Env, err error) { envMap := make(map[string]string) + if filename == "-" { + envMap, err = godotenv.Parse(os.Stdin) + if err != nil { + return nil, err + } + } if _, err := os.Stat(filename); err == nil { envMap, err = godotenv.Read(filename) + if err != nil { + return nil, err + } } dirty := false @@ -352,6 +366,31 @@ func loadFromFile(name string, filename string) (env *Env, err error) { return } +func loadFromFileJSON(name string, filename string) (env *Env, err error) { + envMap := make(map[string]string) + if filename == "-" { + err = json.NewDecoder(os.Stdin).Decode(&envMap) + if err != nil { + return nil, err + } + } else { + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + err = json.NewDecoder(file).Decode(&envMap) + if err != nil { + return nil, err + } + } + + return &Env{ + name: name, + filename: "", + env: envMap, + }, nil +} func getAppFile(appName string) (string, error) { return filepath.Join(common.MustGetEnv("DOKKU_ROOT"), appName, "ENV"), nil } diff --git a/plugins/config/functions.go b/plugins/config/functions.go index 28997d25a..69932ec7c 100644 --- a/plugins/config/functions.go +++ b/plugins/config/functions.go @@ -82,6 +82,44 @@ func SubGet(appName string, keys []string, quoted bool) error { return nil } +// SubImport imports environment variables from a file +func SubImport(appName string, replace bool, noRestart bool, format string, filename string) error { + if filename == "-" { + common.LogInfo1Quiet("Reading config vars from stdin") + } + + if format == "" { + format = "envfile" + } + + var env *Env + var err error + switch format { + case "envfile": + env, err = loadFromFile(appName, filename) + if err != nil { + return err + } + case "json": + env, err = loadFromFileJSON(appName, filename) + if err != nil { + return err + } + default: + return fmt.Errorf("Unknown format: %s", format) + } + + if len(env.Map()) == 0 { + return errors.New("No config vars to set") + } + + for k, v := range env.Map() { + common.LogInfo1Quiet(fmt.Sprintf("Setting config var %s=%s", k, v)) + } + + return SetMany(appName, env.Map(), replace, !noRestart) +} + // SubKeys implements the logic for config:keys without app name validation func SubKeys(appName string, merged bool) error { env := getEnvironment(appName, merged) @@ -115,7 +153,7 @@ func SubSet(appName string, pairs []string, noRestart bool, encoded bool) error updated[key] = value } - return SetMany(appName, updated, !noRestart) + return SetMany(appName, updated, false, !noRestart) } // SubShow implements the logic for config:show without app name validation diff --git a/plugins/config/src/commands/commands.go b/plugins/config/src/commands/commands.go index 78d180de1..ea894fba0 100644 --- a/plugins/config/src/commands/commands.go +++ b/plugins/config/src/commands/commands.go @@ -24,6 +24,7 @@ Additional commands:` config:clear [--no-restart] (|--global), Clears environment variables config:export [--format=FORMAT] [--merged] (|--global), Export a global or app environment config:get [--quoted] (|--global) KEY, Display a global or app-specific config value + config:import [--no-restart] [--replace] (|--global) [FILE|-], Import environment from file config:keys [--merged] (|--global), Show keys set in environment config:show [--merged] (|--global), Show keys set in environment config:set [--encoded] [--no-restart] (|--global) KEY1=VALUE1 [KEY2=VALUE2 ...], Set one or more config vars diff --git a/plugins/config/src/subcommands/subcommands.go b/plugins/config/src/subcommands/subcommands.go index 5fe5bc834..2590c98ed 100644 --- a/plugins/config/src/subcommands/subcommands.go +++ b/plugins/config/src/subcommands/subcommands.go @@ -65,6 +65,22 @@ func main() { } keys := getKeys(args.Args(), *global) err = config.CommandGet(appName, keys, *global, *quoted) + case "import": + args := flag.NewFlagSet("config:import", flag.ExitOnError) + global := args.Bool("global", false, "--global: use the global environment") + noRestart := args.Bool("no-restart", false, "--no-restart: no restart") + replace := args.Bool("replace", false, "--replace: replace existing config vars") + format := args.String("format", "envfile", "--format: [ envfile | json ] which format to export as)") + + args.Parse(os.Args[2:]) + var filename string + if !*global { + appName = args.Arg(0) + filename = args.Arg(1) + } else { + filename = args.Arg(0) + } + err = config.CommandImport(appName, *global, *replace, *noRestart, *format, filename) case "keys": args := flag.NewFlagSet("config:keys", flag.ExitOnError) global := args.Bool("global", false, "--global: use the global environment") diff --git a/plugins/config/subcommands.go b/plugins/config/subcommands.go index fe7e252d6..9b6a45be3 100644 --- a/plugins/config/subcommands.go +++ b/plugins/config/subcommands.go @@ -42,6 +42,16 @@ func CommandGet(appName string, keys []string, global bool, quoted bool) error { return SubGet(appName, keys, quoted) } +// CommandImport imports environment variables from a file +func CommandImport(appName string, global bool, replace bool, noRestart bool, format string, filename string) error { + appName, err := getAppNameOrGlobal(appName, global) + if err != nil { + return err + } + + return SubImport(appName, replace, noRestart, format, filename) +} + // CommandKeys shows the keys set for the specified environment func CommandKeys(appName string, global bool, merged bool) error { appName, err := getAppNameOrGlobal(appName, global) diff --git a/plugins/ports/functions.go b/plugins/ports/functions.go index 29ca80b0d..76aeaed3d 100644 --- a/plugins/ports/functions.go +++ b/plugins/ports/functions.go @@ -449,7 +449,7 @@ func setProxyPort(appName string, port int) error { entries := map[string]string{ "DOKKU_PROXY_PORT": fmt.Sprint(port), } - return config.SetMany(appName, entries, false) + return config.SetMany(appName, entries, false, false) }, map[string]string{"DOKKU_QUIET_OUTPUT": "1"}) } @@ -459,7 +459,7 @@ func setProxySSLPort(appName string, port int) error { entries := map[string]string{ "DOKKU_PROXY_SSL_PORT": fmt.Sprint(port), } - return config.SetMany(appName, entries, false) + return config.SetMany(appName, entries, false, false) }, map[string]string{"DOKKU_QUIET_OUTPUT": "1"}) } diff --git a/plugins/proxy/proxy.go b/plugins/proxy/proxy.go index 57a7bfba6..b674f72b0 100644 --- a/plugins/proxy/proxy.go +++ b/plugins/proxy/proxy.go @@ -41,7 +41,7 @@ func Disable(appName string) error { "DOKKU_DISABLE_PROXY": "1", } - if err := config.SetMany(appName, entries, false); err != nil { + if err := config.SetMany(appName, entries, false, false); err != nil { return err } diff --git a/plugins/proxy/subcommands.go b/plugins/proxy/subcommands.go index 96a90535b..ccf7fa9f5 100644 --- a/plugins/proxy/subcommands.go +++ b/plugins/proxy/subcommands.go @@ -106,5 +106,5 @@ func CommandSet(appName string, proxyType string) error { entries := map[string]string{ key: proxyType, } - return config.SetMany(appName, entries, false) + return config.SetMany(appName, entries, false, false) } diff --git a/plugins/ps/triggers.go b/plugins/ps/triggers.go index 0e355933c..aa2282460 100644 --- a/plugins/ps/triggers.go +++ b/plugins/ps/triggers.go @@ -42,7 +42,7 @@ func TriggerCorePostDeploy(appName string) error { } return common.SuppressOutput(func() error { - return config.SetMany(appName, entries, false) + return config.SetMany(appName, entries, false, false) }) } @@ -245,7 +245,7 @@ func TriggerPostStop(appName string) error { } return common.SuppressOutput(func() error { - return config.SetMany(appName, entries, false) + return config.SetMany(appName, entries, false, false) }) } diff --git a/tests/unit/config.bats b/tests/unit/config.bats index d8d8d1808..e66d3e92a 100644 --- a/tests/unit/config.bats +++ b/tests/unit/config.bats @@ -77,6 +77,282 @@ teardown() { assert_output_not_exists } +@test "(config) config:import stdin" { + run /bin/bash -c "dokku config:set $TEST_APP first_key=first_value" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "echo 'second_key=second_value' | dokku config:import $TEST_APP -" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "first_value" + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "second_value" + + run /bin/bash -c "echo 'test_var=TESTING2' | dokku config:import $TEST_APP -" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "first_value" + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "second_value" + run /bin/bash -c "dokku config:get $TEST_APP test_var" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "TESTING2" + + run /bin/bash -c "echo 'test_var=TESTING3' | dokku config:import --replace $TEST_APP -" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_failure + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_failure + run /bin/bash -c "dokku config:get $TEST_APP test_var" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "TESTING3" +} + +@test "(config) config:import file" { + local TMP_FILE=$(mktemp "/tmp/config-import.XXXXX") + trap 'popd &>/dev/null || true; rm -rf "$TMP"' INT TERM + + run /bin/bash -c "dokku config:set $TEST_APP first_key=first_value" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "echo 'second_key=second_value' > $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:import $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "chown dokku:dokku $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:import $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "first_value" + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "second_value" + run /bin/bash -c "rm -f $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "echo 'test_var=TESTING2' > $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "chown dokku:dokku $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:import $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "first_value" + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "second_value" + run /bin/bash -c "dokku config:get $TEST_APP test_var" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "TESTING2" + run /bin/bash -c "rm -f $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "echo 'test_var=TESTING3' > $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "chown dokku:dokku $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:import --replace $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_failure + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_failure + run /bin/bash -c "dokku config:get $TEST_APP test_var" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "TESTING3" + run /bin/bash -c "rm -f $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success +} + + +@test "(config) config:import file json" { + local TMP_FILE=$(mktemp "/tmp/config-import.XXXXX") + trap 'popd &>/dev/null || true; rm -rf "$TMP"' INT TERM + + run /bin/bash -c "dokku config:set $TEST_APP first_key=first_value" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "echo '{\"second_key\": \"second_value\"}' > $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:import --format json $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_failure + + run /bin/bash -c "chown dokku:dokku $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:import --format json $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "first_value" + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "second_value" + run /bin/bash -c "rm -f $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "echo '{\"test_var\": \"TESTING2\"}' > $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "chown dokku:dokku $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:import --format json $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "first_value" + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "second_value" + run /bin/bash -c "dokku config:get $TEST_APP test_var" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "TESTING2" + run /bin/bash -c "rm -f $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "echo '{\"test_var\": \"TESTING3\"}' > $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "chown dokku:dokku $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku config:import --replace --format json $TEST_APP $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success + run /bin/bash -c "dokku config:get $TEST_APP first_key" + echo "output: $output" + echo "status: $status" + assert_failure + run /bin/bash -c "dokku config:get $TEST_APP second_key" + echo "output: $output" + echo "status: $status" + assert_failure + run /bin/bash -c "dokku config:get $TEST_APP test_var" + echo "output: $output" + echo "status: $status" + assert_success + assert_output "TESTING3" + run /bin/bash -c "rm -f $TMP_FILE" + echo "output: $output" + echo "status: $status" + assert_success +} + @test "(config) config:set/get" { run ssh "dokku@$DOKKU_DOMAIN" config:set $TEST_APP test_var1=true test_var2=\"hello world\" test_var3='double\"quotes' echo "output: $output"