Repository: superfly/flyctl (Go)

Issue:
`fly secrets import` doesn't allow spaces in values
## Describe the bug

When using `fly secrets import`, secret values containing spaces cause the command to fail.  This seems highly related to https://github.com/superfly/flyctl/issues/589.

_Questions:_
- Does the `fly secrets import` simply not support spaces in values?
- Should we loop through the [`secretsArray`](https://github.com/superfly/flyctl/blob/master/internal/command/secrets/import.go#L55) and be more flexible about properly handling spaces & double quotes?
- _**Do you need a volunteer to fix this?**_  I'm not a go developer, but I'm happy to try!

* Operating system: mac, linux
* `fly version`: `v0.0.456 darwin/amd64`

** Paste your `fly.toml`

```toml
# paste your config file here
```

## Command output

I attempted several ways to use spaces in values when importing secrets, with various newlines & quotes, and they all failed in similar ways.  The example below of munged secret values includes the output from a GitHub Action.

### Yaml

```yaml
secrets: |-
  MY_ENVIRONMENT="${{ env.MY_ENVIRONMENT }}" # = staging
  TEST_VAR_1="${{ env.TEST_VAR_1 }}" # = test value 1
```

### GitHub Action shell script

```shell
# input passed from yaml `secrets`
if [ -n "$INPUT_SECRETS" ]; then
  echo $INPUT_SECRETS | flyctl secrets import --app "$app"
fi
```

### `flyctl` output
```shell
+ flyctl secrets import --app some-app
+ echo 'MY_ENVIRONMENT="staging"' 'TEST_VAR_1="test' value '1"'
```

The way that the current cli version works by attempting to automatically surround values in quotes is not working particularly well for me, and seems unable to support spaces in values.

I am, however, able to successfully import secrets if I don't use any spaces or double quotes at all.  But that is specifically breaking a very common use case.

Am I simply using this incorrectly?  I forked the repo to try and spin up the cli, but I'm not 💯 sure what I'm doing 🙂  If there are some helpful developer docs for getting started, I'd love some links.  The `building.md` didn't seem to have what I'm looking for, and I wasn't sure if I needed to run the `install.sh` before building the docker image, do I need to rebuild and restart after each change, etc ... 

Thanks, y'all!


Discussion: Thanks for reporting this issue! This looks like the same issue as #589.

We might be able to use [`shlex.Split()`](https://pkg.go.dev/github.com/google/shlex)  or [`go-envparse`](https://pkg.go.dev/github.com/hashicorp/go-envparse) in the secrets import code you linked. I think either of those would handle spaces and quoting and whatnot more robustly than the parse code we have today. If you're up for that, we'd appreciate the contribution! We'll get to it as soon as we're able otherwise.

> I forked the repo to try and spin up the cli, but I'm not 💯 sure what I'm doing 🙂 If there are some helpful developer docs for getting started, I'd love some links. The building.md didn't seem to have what I'm looking for, and I wasn't sure if I needed to run the install.sh before building the docker image, do I need to rebuild and restart after each change, etc ...

Make sure you have a modern version of go, then `make` or `winbuild.ps1` will build a local copy of the flyctl binary and place it in `bin/flyctl`. Then that binary can be used to test changes.

@tvdfly Thanks for the fast reply and helpful instructions!  I’ll see what I can do about fixing this 🙂



Fix (patch):
diff --git a/internal/command/deploy/deploy.go b/internal/command/deploy/deploy.go
index 4186f0f7cc..fdbc2838fa 100644
--- a/internal/command/deploy/deploy.go
+++ b/internal/command/deploy/deploy.go
@@ -209,7 +209,7 @@ func deployToMachines(ctx context.Context, appConfig *appconfig.Config, appCompa
 		AppCompact:            appCompact,
 		DeploymentImage:       img.Tag,
 		Strategy:              flag.GetString(ctx, "strategy"),
-		EnvFromFlags:          flag.GetStringSlice(ctx, "env"),
+		EnvFromFlags:          flag.GetStringArray(ctx, "env"),
 		PrimaryRegionFlag:     appConfig.PrimaryRegion,
 		SkipSmokeChecks:       flag.GetDetach(ctx) || !flag.GetBool(ctx, "smoke-checks"),
 		SkipHealthChecks:      flag.GetDetach(ctx),
@@ -314,7 +314,7 @@ func determineAppConfig(ctx context.Context) (cfg *appconfig.Config, err error)
 		}
 	}
 
-	if env := flag.GetStringSlice(ctx, "env"); len(env) > 0 {
+	if env := flag.GetStringArray(ctx, "env"); len(env) > 0 {
 		parsedEnv, err := cmdutil.ParseKVStringsToMap(env)
 		if err != nil {
 			return nil, fmt.Errorf("failed parsing environment: %w", err)
diff --git a/internal/command/deploy/deploy_build.go b/internal/command/deploy/deploy_build.go
index c291c6827a..70dc89e1a1 100644
--- a/internal/command/deploy/deploy_build.go
+++ b/internal/command/deploy/deploy_build.go
@@ -97,7 +97,7 @@ func determineImage(ctx context.Context, appConfig *appconfig.Config) (img *imgs
 		Buildpacks:      build.Buildpacks,
 	}
 
-	cliBuildSecrets, err := cmdutil.ParseKVStringsToMap(flag.GetStringSlice(ctx, "build-secret"))
+	cliBuildSecrets, err := cmdutil.ParseKVStringsToMap(flag.GetStringArray(ctx, "build-secret"))
 	if err != nil {
 		return
 	}
@@ -196,7 +196,7 @@ func mergeBuildArgs(ctx context.Context, args map[string]string) (map[string]str
 	}
 
 	// set additional Docker build args from the command line, overriding similar ones from the config
-	cliBuildArgs, err := cmdutil.ParseKVStringsToMap(flag.GetStringSlice(ctx, "build-arg"))
+	cliBuildArgs, err := cmdutil.ParseKVStringsToMap(flag.GetStringArray(ctx, "build-arg"))
 	if err != nil {
 		return nil, fmt.Errorf("invalid build args: %w", err)
 	}
diff --git a/internal/command/launch/launch.go b/internal/command/launch/launch.go
index 1d2f5972d4..ae3d254bfc 100644
--- a/internal/command/launch/launch.go
+++ b/internal/command/launch/launch.go
@@ -180,7 +180,7 @@ func run(ctx context.Context) (err error) {
 	}
 
 	var envVars map[string]string = nil
-	envFlags := flag.GetStringSlice(ctx, "env")
+	envFlags := flag.GetStringArray(ctx, "env")
 	if len(envFlags) > 0 {
 		envVars, err = cmdutil.ParseKVStringsToMap(envFlags)
 		if err != nil {
diff --git a/internal/command/machine/run.go b/internal/command/machine/run.go
index 9bd559284d..1f452fa8e5 100644
--- a/internal/command/machine/run.go
+++ b/internal/command/machine/run.go
@@ -55,7 +55,7 @@ var sharedFlags = flag.Set{
 		Name:        "memory",
 		Description: "Memory (in megabytes) to attribute to the machine",
 	},
-	flag.StringSlice{
+	flag.StringArray{
 		Name:        "env",
 		Shorthand:   "e",
 		Description: "Set of environment variables in the form of NAME=VALUE pairs. Can be specified multiple times.",
@@ -87,7 +87,7 @@ var sharedFlags = flag.Set{
 		Name:        "dockerfile",
 		Description: "Path to a Dockerfile. Defaults to the Dockerfile in the working directory.",
 	},
-	flag.StringSlice{
+	flag.StringArray{
 		Name:        "build-arg",
 		Description: "Set of build time variables in the form of NAME=VALUE pairs. Can be specified multiple times.",
 		Hidden:      true,
@@ -107,11 +107,11 @@ var sharedFlags = flag.Set{
 		Description: "Do not use the cache when building the image",
 		Hidden:      true,
 	},
-	flag.StringSlice{
+	flag.StringArray{
 		Name:        "kernel-arg",
 		Description: "List of kernel arguments to be provided to the init. Can be specified multiple times.",
 	},
-	flag.StringSlice{
+	flag.StringArray{
 		Name:        "metadata",
 		Shorthand:   "m",
 		Description: "Metadata in the form of NAME=VALUE pairs. Can be specified multiple times.",
@@ -237,7 +237,7 @@ func runMachineRun(ctx context.Context) error {
 			CPUKind:    "shared",
 			CPUs:       1,
 			MemoryMB:   256,
-			KernelArgs: flag.GetStringSlice(ctx, "kernel-arg"),
+			KernelArgs: flag.GetStringArray(ctx, "kernel-arg"),
 		},
 		AutoDestroy: flag.GetBool(ctx, "rm"),
 		DNS: &api.DNSConfig{
@@ -372,7 +372,7 @@ func createApp(ctx context.Context, message, name string, client *api.Client) (*
 func parseKVFlag(ctx context.Context, flagName string, initialMap map[string]string) (parsed map[string]string, err error) {
 	parsed = initialMap
 
-	if value := flag.GetStringSlice(ctx, flagName); len(value) > 0 {
+	if value := flag.GetStringArray(ctx, flagName); len(value) > 0 {
 		parsed, err = cmdutil.ParseKVStringsToMap(value)
 		if err != nil {
 			return nil, fmt.Errorf("invalid key/value pairs specified for flag %s", flagName)
@@ -417,7 +417,7 @@ func determineImage(ctx context.Context, appName string, imageOrPath string) (im
 			opts.DockerfilePath = dockerfilePath
 		}
 
-		extraArgs, err := cmdutil.ParseKVStringsToMap(flag.GetStringSlice(ctx, "build-arg"))
+		extraArgs, err := cmdutil.ParseKVStringsToMap(flag.GetStringArray(ctx, "build-arg"))
 		if err != nil {
 			return nil, errors.Wrap(err, "invalid build-arg")
 		}
@@ -670,8 +670,8 @@ func determineMachineConfig(ctx context.Context, input *determineMachineConfigIn
 		return nil, fmt.Errorf("memory cannot be zero")
 	}
 
-	if len(flag.GetStringSlice(ctx, "kernel-arg")) != 0 {
-		machineConf.Guest.KernelArgs = flag.GetStringSlice(ctx, "kernel-arg")
+	if len(flag.GetStringArray(ctx, "kernel-arg")) != 0 {
+		machineConf.Guest.KernelArgs = flag.GetStringArray(ctx, "kernel-arg")
 	}
 
 	parsedEnv, err := parseKVFlag(ctx, "env", machineConf.Env)
diff --git a/internal/flag/context.go b/internal/flag/context.go
index 4a1b1d71dd..66f08730fe 100644
--- a/internal/flag/context.go
+++ b/internal/flag/context.go
@@ -60,7 +60,19 @@ func GetInt(ctx context.Context, name string) int {
 	}
 }
 
-// GetString returns the value of the named string flag ctx carries.
+// GetStringArray returns the values of the named string flag ctx carries.
+// Preserves commas (unlike the following `GetStringSlice`): in `--flag x,y` the value is string[]{`x,y`}.
+// This is useful to pass key-value pairs like environment variables or build arguments.
+func GetStringArray(ctx context.Context, name string) []string {
+	if v, err := FromContext(ctx).GetStringArray(name); err != nil {
+		return []string{}
+	} else {
+		return v
+	}
+}
+
+// GetStringSlice returns the values of the named string flag ctx carries.
+// Can be comma separated or passed "by repeated flags": `--flag x,y` is equivalent to `--flag x --flag y`.
 func GetStringSlice(ctx context.Context, name string) []string {
 	if v, err := FromContext(ctx).GetStringSlice(name); err != nil {
 		return []string{}
diff --git a/internal/flag/flag.go b/internal/flag/flag.go
index 1069a2c76f..6c3dc41452 100644
--- a/internal/flag/flag.go
+++ b/internal/flag/flag.go
@@ -164,6 +164,30 @@ func (ss StringSlice) addTo(cmd *cobra.Command) {
 	f.Hidden = ss.Hidden
 }
 
+// StringArray wraps the set of string array flags.
+type StringArray struct {
+	Name        string
+	Shorthand   string
+	Description string
+	Default     []string
+	ConfName    string
+	EnvName     string
+	Hidden      bool
+}
+
+func (ss StringArray) addTo(cmd *cobra.Command) {
+	flags := cmd.Flags()
+
+	if ss.Shorthand != "" {
+		_ = flags.StringArrayP(ss.Name, ss.Shorthand, ss.Default, ss.Description)
+	} else {
+		_ = flags.StringArray(ss.Name, ss.Default, ss.Description)
+	}
+
+	f := flags.Lookup(ss.Name)
+	f.Hidden = ss.Hidden
+}
+
 // Duration wraps the set of duration flags.
 type Duration struct {
 	Name        string
@@ -367,15 +391,15 @@ func NoCache() Bool {
 	}
 }
 
-func BuildSecret() StringSlice {
-	return StringSlice{
+func BuildSecret() StringArray {
+	return StringArray{
 		Name:        "build-secret",
 		Description: "Set of build secrets of NAME=VALUE pairs. Can be specified multiple times. See https://docs.docker.com/develop/develop-images/build_enhancements/#new-docker-build-secret-information",
 	}
 }
 
-func BuildArg() StringSlice {
-	return StringSlice{
+func BuildArg() StringArray {
+	return StringArray{
 		Name:        "build-arg",
 		Description: "Set of build time variables in the form of NAME=VALUE pairs. Can be specified multiple times.",
 	}