feat: implement config:import command

Closes #6846
This commit is contained in:
Jose Diaz-Gonzalez
2025-11-08 01:02:19 -05:00
parent 48fd2b3862
commit e134c314ea
13 changed files with 477 additions and 12 deletions

View File

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

View File

@@ -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",

View File

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

View File

@@ -88,6 +88,11 @@ func LoadGlobalEnv() (*Env, error) {
return loadFromFile("<global>", 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
}

View File

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

View File

@@ -24,6 +24,7 @@ Additional commands:`
config:clear [--no-restart] (<app>|--global), Clears environment variables
config:export [--format=FORMAT] [--merged] (<app>|--global), Export a global or app environment
config:get [--quoted] (<app>|--global) KEY, Display a global or app-specific config value
config:import [--no-restart] [--replace] (<app>|--global) [FILE|-], Import environment from file
config:keys [--merged] (<app>|--global), Show keys set in environment
config:show [--merged] (<app>|--global), Show keys set in environment
config:set [--encoded] [--no-restart] (<app>|--global) KEY1=VALUE1 [KEY2=VALUE2 ...], Set one or more config vars

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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