From 63a05219922646ba24badc949237e5d5bb8907e1 Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Thu, 10 Sep 2026 13:37:50 -0700 Subject: [PATCH 1/2] Keep commas in env values and accept bare KEY=VALUE args in env set The flag library split every repeatable string flag on commas, so `miren env set -e KEY=a,b,c` arrived as three entries: KEY=a, b, and c. mflags now has a split:"false" struct tag that keeps each occurrence whole. This bumps to that version and applies the tag to the -e and -s flags of env set and deploy, and to --org on auth provider add github, whose documented name:team1,team2 form was being split the same way. env set also accepts bare KEY=VALUE arguments with no flag, treated the same as -e. Fixes MIR-1168. --- cli/commands/auth_provider_github.go | 2 +- cli/commands/commands.go | 6 ++- cli/commands/deploy.go | 4 +- cli/commands/env.go | 16 ++++--- cli/commands/env_set_flags_test.go | 63 ++++++++++++++++++++++++++++ docs/docs/command/env-set.md | 10 ++++- go.mod | 2 +- go.sum | 4 +- 8 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 cli/commands/env_set_flags_test.go diff --git a/cli/commands/auth_provider_github.go b/cli/commands/auth_provider_github.go index d26e050ba..23ac48f4d 100644 --- a/cli/commands/auth_provider_github.go +++ b/cli/commands/auth_provider_github.go @@ -15,7 +15,7 @@ func AuthProviderAddGitHub(ctx *Context, opts struct { Name string `position:"0" usage:"Name for this identity provider" required:"true"` ClientID string `long:"client-id" description:"GitHub OAuth app client ID" required:"true"` ClientSecret string `long:"client-secret" description:"GitHub OAuth app client secret" required:"true"` - Orgs []string `long:"org" description:"GitHub org restriction (repeatable). Use \"name\" for any-member, or \"name:team1,team2\" to require team membership and populate X-User-Groups."` + Orgs []string `long:"org" split:"false" description:"GitHub org restriction (repeatable). Use \"name\" for any-member, or \"name:team1,team2\" to require team membership and populate X-User-Groups."` Update bool `long:"update" description:"Overwrite an existing provider with the same name (rotates client secret)"` ConfigCentric }) error { diff --git a/cli/commands/commands.go b/cli/commands/commands.go index 1e4520795..26451b228 100644 --- a/cli/commands/commands.go +++ b/cli/commands/commands.go @@ -487,6 +487,10 @@ miren deploy --analyze Name: "Set an environment variable", Body: "miren env set -e DATABASE_URL=postgres://localhost/mydb", }), + WithExample(mflags.Example{ + Name: "Set several variables without flags", + Body: "miren env set LOG_LEVEL=debug ALLOWED_HOSTS=a.example.com,b.example.com", + }), WithExample(mflags.Example{ Name: "Set a sensitive variable (prompted with masking)", Body: "miren env set -s SECRET_KEY", @@ -1417,7 +1421,7 @@ Prefer ` + "`" + `miren secret disable` + "`" + ` when revoking a leaked credent const envSetDescription = `Setting an environment variable creates a new app version and rolls it out automatically — you do not need to run ` + "`" + `miren deploy` + "`" + ` or ` + "`" + `miren app restart` + "`" + ` afterward. The new version reuses your existing container image (no rebuild); Miren boots new sandboxes with the updated environment and drains the old ones. The command waits for the new version to become healthy before returning. -Use ` + "`" + `-e` + "`" + ` for plain values and ` + "`" + `-s` + "`" + ` for sensitive values (masked in output and logs). Note that ` + "`" + `-s` + "`" + ` affects display only — the value itself is stored in the clear. For a credential that should be encrypted at rest, store it with ` + "`" + `miren secret set` + "`" + ` instead. Pass ` + "`" + `--service` + "`" + ` to scope the change to a single service instead of all services. +Use ` + "`" + `-e` + "`" + ` for plain values and ` + "`" + `-s` + "`" + ` for sensitive values (masked in output and logs). Bare ` + "`" + `KEY=VALUE` + "`" + ` arguments without a flag are treated as plain values, the same as ` + "`" + `-e` + "`" + `. Values may contain commas. Note that ` + "`" + `-s` + "`" + ` affects display only — the value itself is stored in the clear. For a credential that should be encrypted at rest, store it with ` + "`" + `miren secret set` + "`" + ` instead. Pass ` + "`" + `--service` + "`" + ` to scope the change to a single service instead of all services. :::note[No restart needed] Environment variable changes take effect on their own. Running ` + "`" + `miren app restart` + "`" + ` afterward only triggers a redundant second rollout. diff --git a/cli/commands/deploy.go b/cli/commands/deploy.go index 54625af50..282cae6d9 100644 --- a/cli/commands/deploy.go +++ b/cli/commands/deploy.go @@ -88,8 +88,8 @@ func Deploy(ctx *Context, opts struct { Explain bool `short:"x" long:"explain" description:"Explain the build process"` ExplainFormat string `long:"explain-format" description:"Explain format" choice:"auto" choice:"plain" choice:"tty" choice:"rawjson" default:"auto"` //nolint Force bool `short:"f" long:"force" description:"Skip confirmation prompt"` - Env []string `short:"e" long:"env" description:"Set environment variable (KEY=VALUE, KEY=@file, or KEY to prompt)"` - Sensitive []string `short:"s" long:"sensitive" description:"Set sensitive environment variable (masked in output)"` + Env []string `short:"e" long:"env" split:"false" description:"Set environment variable (KEY=VALUE, KEY=@file, or KEY to prompt)"` + Sensitive []string `short:"s" long:"sensitive" split:"false" description:"Set sensitive environment variable (masked in output)"` Ephemeral string `long:"ephemeral" description:"Deploy as ephemeral preview with this label (e.g. feat-login)"` TTL string `long:"ttl" description:"TTL for ephemeral version (e.g. 48h)" default:"24h"` SummaryJSON string `long:"summary-json" description:"Write a JSON summary of the deploy result (deploy id, version, and route URLs) to this path"` diff --git a/cli/commands/env.go b/cli/commands/env.go index fb8c4051e..294a4eb7d 100644 --- a/cli/commands/env.go +++ b/cli/commands/env.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "regexp" + "slices" "sort" "strings" @@ -196,22 +197,27 @@ func parseEnvVarSpec(spec string, sensitive bool) (EnvVarSpec, error) { func EnvSet(ctx *Context, opts struct { AppCentric + Args []string `rest:"true" usage:"KEY=VALUE pairs to set, the same as passing each with -e"` Service string `short:"S" long:"service" description:"Set env var for specific service only (if not specified, sets for all services)"` - Env []string `short:"e" long:"env" description:"Set environment variables (use KEY to prompt, KEY=VALUE to set directly, KEY=@file to read from file)"` - Sensitive []string `short:"s" long:"sensitive" description:"Set sensitive environment variables (use KEY to prompt with masking, KEY=VALUE to set directly, KEY=@file to read from file)"` + Env []string `short:"e" long:"env" split:"false" description:"Set environment variables (use KEY to prompt, KEY=VALUE to set directly, KEY=@file to read from file)"` + Sensitive []string `short:"s" long:"sensitive" split:"false" description:"Set sensitive environment variables (use KEY to prompt with masking, KEY=VALUE to set directly, KEY=@file to read from file)"` Backend string `short:"b" long:"backend" description:"Source the value from a secret backend instead of setting it literally (default: cluster, with --ref)"` Ref string `long:"ref" description:"Backend-relative reference to the secret, e.g. payments/stripe-key"` }) error { + // Bare KEY=VALUE arguments count as plain -e entries. + env := slices.Concat(opts.Env, opts.Args) + sensitive := opts.Sensitive + if opts.Ref != "" || opts.Backend != "" { - return envSetReference(ctx, opts.App, opts.Service, opts.Env, opts.Sensitive, opts.Backend, opts.Ref) + return envSetReference(ctx, opts.App, opts.Service, env, sensitive, opts.Backend, opts.Ref) } - if len(opts.Env) == 0 && len(opts.Sensitive) == 0 { + if len(env) == 0 && len(sensitive) == 0 { return fmt.Errorf("no environment variables specified") } // Parse all env var specs - specs, err := ParseEnvVarSpecs(opts.Env, opts.Sensitive) + specs, err := ParseEnvVarSpecs(env, sensitive) if err != nil { return err } diff --git a/cli/commands/env_set_flags_test.go b/cli/commands/env_set_flags_test.go new file mode 100644 index 000000000..9c103d2c9 --- /dev/null +++ b/cli/commands/env_set_flags_test.go @@ -0,0 +1,63 @@ +package commands + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +// stringSliceField reads a []string option off a parsed command by field name. +// The option structs are anonymous, so the fields cannot be type-asserted. +func stringSliceField(t *testing.T, cmd *Cmd, name string) []string { + t.Helper() + f := cmd.opts.Elem().FieldByName(name) + require.True(t, f.IsValid(), "field %s", name) + return f.Interface().([]string) +} + +func TestEnvSetKeepsCommasAndAcceptsBareArgs(t *testing.T) { + cmd := Infer("env set", "test", EnvSet) + require.NoError(t, cmd.fs.Parse([]string{ + "-e", "A=1,2", "B=3,4", "-s", "SECRET=x,y", "--env=C=5", "D=6", "-eE=7,8", + })) + + require.Equal(t, []string{"A=1,2", "C=5", "E=7,8"}, stringSliceField(t, cmd, "Env")) + require.Equal(t, []string{"SECRET=x,y"}, stringSliceField(t, cmd, "Sensitive")) + require.Equal(t, []string{"B=3,4", "D=6"}, stringSliceField(t, cmd, "Args")) +} + +func TestDeployKeepsCommasInEnvFlags(t *testing.T) { + cmd := Infer("deploy", "test", Deploy) + require.NoError(t, cmd.fs.Parse([]string{"-e", "A=1,2", "-s", "SECRET=x,y", "-e", "B=3"})) + + require.Equal(t, []string{"A=1,2", "B=3"}, stringSliceField(t, cmd, "Env")) + require.Equal(t, []string{"SECRET=x,y"}, stringSliceField(t, cmd, "Sensitive")) +} + +func TestAuthProviderGitHubOrgTeamsSurviveParsing(t *testing.T) { + cmd := Infer("auth provider add github", "test", AuthProviderAddGitHub) + require.NoError(t, cmd.fs.Parse([]string{ + "gh", "--client-id", "id", "--client-secret", "secret", + "--org", "mirendev:platform,eng", "--org", "other", + })) + + orgs := stringSliceField(t, cmd, "Orgs") + require.Equal(t, []string{"mirendev:platform,eng", "other"}, orgs) + + raw, err := buildGitHubConfigJSON(orgs) + require.NoError(t, err) + + var cfg struct { + Orgs []struct { + Name string `json:"name"` + Teams []string `json:"teams"` + } `json:"orgs"` + } + require.NoError(t, json.Unmarshal([]byte(raw), &cfg)) + require.Len(t, cfg.Orgs, 2) + require.Equal(t, "mirendev", cfg.Orgs[0].Name) + require.Equal(t, []string{"platform", "eng"}, cfg.Orgs[0].Teams) + require.Equal(t, "other", cfg.Orgs[1].Name) + require.Empty(t, cfg.Orgs[1].Teams) +} diff --git a/docs/docs/command/env-set.md b/docs/docs/command/env-set.md index 2faae314d..039acf29d 100644 --- a/docs/docs/command/env-set.md +++ b/docs/docs/command/env-set.md @@ -10,7 +10,7 @@ Set environment variables for an application Setting an environment variable creates a new app version and rolls it out automatically — you do not need to run `miren deploy` or `miren app restart` afterward. The new version reuses your existing container image (no rebuild); Miren boots new sandboxes with the updated environment and drains the old ones. The command waits for the new version to become healthy before returning. -Use `-e` for plain values and `-s` for sensitive values (masked in output and logs). Note that `-s` affects display only — the value itself is stored in the clear. For a credential that should be encrypted at rest, store it with `miren secret set` instead. Pass `--service` to scope the change to a single service instead of all services. +Use `-e` for plain values and `-s` for sensitive values (masked in output and logs). Bare `KEY=VALUE` arguments without a flag are treated as plain values, the same as `-e`. Values may contain commas. Note that `-s` affects display only — the value itself is stored in the clear. For a credential that should be encrypted at rest, store it with `miren secret set` instead. Pass `--service` to scope the change to a single service instead of all services. :::note[No restart needed] Environment variable changes take effect on their own. Running `miren app restart` afterward only triggers a redundant second rollout. @@ -19,7 +19,7 @@ Environment variable changes take effect on their own. Running `miren app restar ## Usage ```bash -miren env set [flags] +miren env set [args...] [flags] ``` ## Flags @@ -54,6 +54,12 @@ miren env set [flags] miren env set -e DATABASE_URL=postgres://localhost/mydb ``` +**Set several variables without flags:** + +```bash +miren env set LOG_LEVEL=debug ALLOWED_HOSTS=a.example.com,b.example.com +``` + **Set a sensitive variable (prompted with masking):** ```bash diff --git a/go.mod b/go.mod index eeaf2f536..990512350 100644 --- a/go.mod +++ b/go.mod @@ -107,7 +107,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 k8s.io/klog/v2 v2.130.1 miren.dev/lbd v0.0.0-20260224020427-8914d8db2233 - miren.dev/mflags v0.0.0-20260709231109-a397dcbc98df + miren.dev/mflags v0.0.0-20260910203505-bda3448ec3ee modernc.org/sqlite v1.45.0 sigs.k8s.io/knftables v0.0.21 ) diff --git a/go.sum b/go.sum index 374755f8d..ec1f06d4b 100644 --- a/go.sum +++ b/go.sum @@ -1955,8 +1955,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= miren.dev/lbd v0.0.0-20260224020427-8914d8db2233 h1:9DxH7Dhnmu7hn1OA2JC5fHpLuVgAqBySws9GLNssLl4= miren.dev/lbd v0.0.0-20260224020427-8914d8db2233/go.mod h1:+x9fy2p45csBnGUJdqxCUmzlUTCipoVDbv6zIapTgDA= -miren.dev/mflags v0.0.0-20260709231109-a397dcbc98df h1:4fo71OveODliBgU9sv2M/uepiRNAwQPJ7YTPuNffQNc= -miren.dev/mflags v0.0.0-20260709231109-a397dcbc98df/go.mod h1:G1eQ/upWVdO6BGT6dlh5Yqjt+9ncH5RUAKX6UKi1F9Q= +miren.dev/mflags v0.0.0-20260910203505-bda3448ec3ee h1:USF6SEIvbBi6ib6om+I4U9XdPzjQE9fal9SNFzPLga0= +miren.dev/mflags v0.0.0-20260910203505-bda3448ec3ee/go.mod h1:G1eQ/upWVdO6BGT6dlh5Yqjt+9ncH5RUAKX6UKi1F9Q= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc= From 1b2fee8773cff303b84131afc29f7339eb4ab13a Mon Sep 17 00:00:00 2001 From: Evan Phoenix Date: Thu, 10 Sep 2026 15:59:48 -0700 Subject: [PATCH 2/2] Pin mflags to the merged split tag commit --- go.mod | 2 +- go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/go.mod b/go.mod index 990512350..b489951a0 100644 --- a/go.mod +++ b/go.mod @@ -107,7 +107,7 @@ require ( gopkg.in/yaml.v3 v3.0.1 k8s.io/klog/v2 v2.130.1 miren.dev/lbd v0.0.0-20260224020427-8914d8db2233 - miren.dev/mflags v0.0.0-20260910203505-bda3448ec3ee + miren.dev/mflags v0.0.0-20260910225849-7704913d5c9c modernc.org/sqlite v1.45.0 sigs.k8s.io/knftables v0.0.21 ) diff --git a/go.sum b/go.sum index ec1f06d4b..9ca6bbd5a 100644 --- a/go.sum +++ b/go.sum @@ -1955,8 +1955,8 @@ k8s.io/klog/v2 v2.130.1 h1:n9Xl7H1Xvksem4KFG4PYbdQCQxqc/tTUyrgXaOhHSzk= k8s.io/klog/v2 v2.130.1/go.mod h1:3Jpz1GvMt720eyJH1ckRHK1EDfpxISzJ7I9OYgaDtPE= miren.dev/lbd v0.0.0-20260224020427-8914d8db2233 h1:9DxH7Dhnmu7hn1OA2JC5fHpLuVgAqBySws9GLNssLl4= miren.dev/lbd v0.0.0-20260224020427-8914d8db2233/go.mod h1:+x9fy2p45csBnGUJdqxCUmzlUTCipoVDbv6zIapTgDA= -miren.dev/mflags v0.0.0-20260910203505-bda3448ec3ee h1:USF6SEIvbBi6ib6om+I4U9XdPzjQE9fal9SNFzPLga0= -miren.dev/mflags v0.0.0-20260910203505-bda3448ec3ee/go.mod h1:G1eQ/upWVdO6BGT6dlh5Yqjt+9ncH5RUAKX6UKi1F9Q= +miren.dev/mflags v0.0.0-20260910225849-7704913d5c9c h1:x4XxiRPIEh64GV1DzDaxGoiMqj+9qYCOnFBW2h+L3Vs= +miren.dev/mflags v0.0.0-20260910225849-7704913d5c9c/go.mod h1:G1eQ/upWVdO6BGT6dlh5Yqjt+9ncH5RUAKX6UKi1F9Q= modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=