-
Notifications
You must be signed in to change notification settings - Fork 284
feat: add --export flag to azd env get-values for shell sourcing #7364
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -9,6 +9,7 @@ import ( | |
| "errors" | ||
| "fmt" | ||
| "io" | ||
| "maps" | ||
| "os" | ||
| "slices" | ||
| "strings" | ||
|
|
@@ -1263,17 +1264,32 @@ func newEnvGetValuesCmd() *cobra.Command { | |
| return &cobra.Command{ | ||
| Use: "get-values", | ||
| Short: "Get all environment values.", | ||
| Args: cobra.NoArgs, | ||
| Long: "Get all environment values.\n\n" + | ||
| "Use --export to output in shell-ready format " + | ||
| "(export KEY=\"VALUE\").\n" + | ||
| "This enables shell integration:\n\n" + | ||
| " eval \"$(azd env get-values --export)\"", | ||
| Args: cobra.NoArgs, | ||
| } | ||
| } | ||
|
|
||
| type envGetValuesFlags struct { | ||
| internal.EnvFlag | ||
| global *internal.GlobalCommandOptions | ||
| export bool | ||
| } | ||
|
|
||
| func (eg *envGetValuesFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandOptions) { | ||
| func (eg *envGetValuesFlags) Bind( | ||
| local *pflag.FlagSet, | ||
| global *internal.GlobalCommandOptions, | ||
| ) { | ||
| eg.EnvFlag.Bind(local, global) | ||
| local.BoolVar( | ||
| &eg.export, | ||
| "export", | ||
| false, | ||
| "Output in shell-ready format (export KEY=\"VALUE\").", | ||
| ) | ||
| eg.global = global | ||
| } | ||
|
|
||
|
|
@@ -1305,6 +1321,13 @@ func newEnvGetValuesAction( | |
| } | ||
|
|
||
| func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, error) { | ||
| if eg.flags.export && eg.formatter.Kind() != output.EnvVarsFormat { | ||
| return nil, fmt.Errorf( | ||
| "--export and --output are mutually exclusive: %w", | ||
| internal.ErrInvalidFlagCombination, | ||
| ) | ||
| } | ||
|
|
||
| name, err := eg.azdCtx.GetDefaultEnvironmentName() | ||
| if err != nil { | ||
| return nil, err | ||
|
|
@@ -1338,9 +1361,42 @@ func (eg *envGetValuesAction) Run(ctx context.Context) (*actions.ActionResult, e | |
| return nil, fmt.Errorf("ensuring environment exists: %w", err) | ||
| } | ||
|
|
||
| if eg.flags.export { | ||
| return nil, writeExportedEnv( | ||
| env.Dotenv(), eg.writer, | ||
| ) | ||
| } | ||
|
|
||
| return nil, eg.formatter.Format(env.Dotenv(), eg.writer, nil) | ||
| } | ||
|
|
||
| // writeExportedEnv writes environment variables in shell-ready | ||
| // format (export KEY="VALUE") to the given writer. Values are | ||
| // double-quoted with embedded backslashes, double quotes, dollar | ||
| // signs, backticks, newlines, and carriage returns escaped. | ||
| func writeExportedEnv( | ||
| values map[string]string, | ||
| writer io.Writer, | ||
| ) error { | ||
| keys := slices.Sorted(maps.Keys(values)) | ||
| for _, key := range keys { | ||
| val := values[key] | ||
| escaped := strings.NewReplacer( | ||
| `\`, `\\`, | ||
| `"`, `\"`, | ||
| `$`, `\$`, | ||
| "`", "\\`", | ||
| "\n", `\n`, | ||
| "\r", `\r`, | ||
| ).Replace(val) | ||
| line := fmt.Sprintf("export %s=\"%s\"\n", key, escaped) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would this work for pwsh? Also, can we / shall we use the flag |
||
| if _, err := io.WriteString(writer, line); err != nil { | ||
| return err | ||
| } | ||
| } | ||
| return nil | ||
| } | ||
|
|
||
| func newEnvGetValueFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions) *envGetValueFlags { | ||
| flags := &envGetValueFlags{} | ||
| flags.Bind(cmd.Flags(), global) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,187 @@ | ||
| // Copyright (c) Microsoft Corporation. All rights reserved. | ||
| // Licensed under the MIT License. | ||
|
|
||
| package cmd | ||
|
|
||
| import ( | ||
| "bytes" | ||
| "testing" | ||
|
|
||
| "github.com/azure/azure-dev/cli/azd/internal" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/environment" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext" | ||
| "github.com/azure/azure-dev/cli/azd/pkg/output" | ||
| "github.com/azure/azure-dev/cli/azd/test/mocks" | ||
| "github.com/azure/azure-dev/cli/azd/test/mocks/mockenv" | ||
| "github.com/stretchr/testify/mock" | ||
| "github.com/stretchr/testify/require" | ||
| ) | ||
|
|
||
| func TestEnvGetValuesExport(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| envVars map[string]string | ||
| export bool | ||
| expected string | ||
| }{ | ||
| { | ||
| name: "export basic values", | ||
| envVars: map[string]string{ | ||
| "FOO": "bar", | ||
| "BAZ": "qux", | ||
| }, | ||
| export: true, | ||
| expected: "export AZURE_ENV_NAME=\"test\"\n" + | ||
| "export BAZ=\"qux\"\n" + | ||
| "export FOO=\"bar\"\n", | ||
| }, | ||
| { | ||
| name: "export values with special characters", | ||
| envVars: map[string]string{ | ||
| "CONN": `host="localhost" pass=$ecret`, | ||
| }, | ||
| export: true, | ||
| expected: "export AZURE_ENV_NAME=\"test\"\n" + | ||
| "export CONN=" + | ||
| `"host=\"localhost\" pass=\$ecret"` + | ||
| "\n", | ||
| }, | ||
| { | ||
| name: "export empty value", | ||
| envVars: map[string]string{ | ||
| "EMPTY": "", | ||
| }, | ||
| export: true, | ||
| expected: "export AZURE_ENV_NAME=\"test\"\n" + | ||
| "export EMPTY=\"\"\n", | ||
| }, | ||
| { | ||
| name: "export values with newlines", | ||
| envVars: map[string]string{ | ||
| "MULTILINE": "line1\nline2\nline3", | ||
| }, | ||
| export: true, | ||
| expected: "export AZURE_ENV_NAME=\"test\"\n" + | ||
| "export MULTILINE=\"line1\\nline2\\nline3\"\n", | ||
| }, | ||
| { | ||
| name: "export values with backslashes", | ||
| envVars: map[string]string{ | ||
| "WIN_PATH": `C:\path\to\dir`, | ||
| }, | ||
| export: true, | ||
| expected: "export AZURE_ENV_NAME=\"test\"\n" + | ||
| "export WIN_PATH=\"C:\\\\path\\\\to\\\\dir\"\n", | ||
| }, | ||
| { | ||
| name: "export values with backticks and command substitution", | ||
| envVars: map[string]string{ | ||
| "DANGEROUS": "value with `backticks` and $(command)", | ||
| }, | ||
| export: true, | ||
| expected: "export AZURE_ENV_NAME=\"test\"\n" + | ||
| "export DANGEROUS=\"value with \\`backticks\\` and \\$(command)\"\n", | ||
| }, | ||
| { | ||
| name: "export values with carriage returns", | ||
| envVars: map[string]string{ | ||
| "CR_VALUE": "line1\rline2", | ||
| }, | ||
| export: true, | ||
| expected: "export AZURE_ENV_NAME=\"test\"\n" + | ||
| "export CR_VALUE=\"line1\\rline2\"\n", | ||
| }, | ||
| { | ||
spboyer marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| name: "no export outputs dotenv format", | ||
| envVars: map[string]string{ | ||
| "KEY": "value", | ||
| }, | ||
| export: false, | ||
| expected: "AZURE_ENV_NAME=\"test\"\n" + | ||
| "KEY=\"value\"\n", | ||
| }, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| mockContext := mocks.NewMockContext( | ||
| t.Context(), | ||
| ) | ||
|
|
||
| azdCtx := azdcontext.NewAzdContextWithDirectory( | ||
| t.TempDir(), | ||
| ) | ||
| err := azdCtx.SetProjectState( | ||
| azdcontext.ProjectState{ | ||
| DefaultEnvironment: "test", | ||
| }, | ||
| ) | ||
| require.NoError(t, err) | ||
|
|
||
| testEnv := environment.New("test") | ||
| for k, v := range tt.envVars { | ||
| testEnv.DotenvSet(k, v) | ||
| } | ||
|
|
||
| envMgr := &mockenv.MockEnvManager{} | ||
| envMgr.On( | ||
| "Get", mock.Anything, "test", | ||
| ).Return(testEnv, nil) | ||
|
|
||
| var buf bytes.Buffer | ||
| formatter, err := output.NewFormatter("dotenv") | ||
| require.NoError(t, err) | ||
|
|
||
| action := &envGetValuesAction{ | ||
| azdCtx: azdCtx, | ||
| console: mockContext.Console, | ||
| envManager: envMgr, | ||
| formatter: formatter, | ||
| writer: &buf, | ||
| flags: &envGetValuesFlags{ | ||
| global: &internal.GlobalCommandOptions{}, | ||
| export: tt.export, | ||
| }, | ||
| } | ||
|
|
||
| _, err = action.Run(t.Context()) | ||
| require.NoError(t, err) | ||
| require.Equal(t, tt.expected, buf.String()) | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestEnvGetValuesExportOutputMutualExclusion(t *testing.T) { | ||
| mockContext := mocks.NewMockContext(t.Context()) | ||
|
|
||
| azdCtx := azdcontext.NewAzdContextWithDirectory( | ||
| t.TempDir(), | ||
| ) | ||
| err := azdCtx.SetProjectState( | ||
| azdcontext.ProjectState{ | ||
| DefaultEnvironment: "test", | ||
| }, | ||
| ) | ||
| require.NoError(t, err) | ||
|
|
||
| formatter, err := output.NewFormatter("json") | ||
| require.NoError(t, err) | ||
|
|
||
| var buf bytes.Buffer | ||
| action := &envGetValuesAction{ | ||
| azdCtx: azdCtx, | ||
| console: mockContext.Console, | ||
| formatter: formatter, | ||
| writer: &buf, | ||
| flags: &envGetValuesFlags{ | ||
| global: &internal.GlobalCommandOptions{}, | ||
| export: true, | ||
| }, | ||
| } | ||
|
|
||
| _, err = action.Run(t.Context()) | ||
| require.Error(t, err) | ||
| require.Contains( | ||
| t, err.Error(), "mutually exclusive", | ||
| ) | ||
| } | ||
Uh oh!
There was an error while loading. Please reload this page.