Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
112 changes: 92 additions & 20 deletions cli/src/cmd/app/commands/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,15 +22,16 @@ const (
)

var (
envFormat string
envNoMask bool
envFile string
envAll bool
envExplain bool
envDiff bool
envWrite bool
envOut string
envKeys bool
envFormat string
envNoMask bool
envFile string
envAll bool
envExplain bool
envDiff bool
envWrite bool
envOut string
envKeys bool
envPrefixes []string
)

// NewEnvCommand creates the env command.
Expand Down Expand Up @@ -74,6 +75,9 @@ Examples:
# List variable names without values
azd app env api --keys

# Only print Azure variables for one service
azd app env api --prefix AZURE_

# Write the resolved environment to api/.env
azd app env api --write

Expand All @@ -97,6 +101,7 @@ Examples:
cmd.Flags().BoolVar(&envWrite, "write", false, "Write the resolved environment to a .env file instead of printing it")
cmd.Flags().StringVar(&envOut, "out", "", "Destination folder for --write files (writes <service>.env); defaults to each service directory")
cmd.Flags().BoolVar(&envKeys, "keys", false, "Print variable names only")
cmd.Flags().StringSliceVar(&envPrefixes, "prefix", nil, "Only include variables whose names start with the given prefix (repeatable)")

return cmd
}
Expand All @@ -111,6 +116,10 @@ func runEnv(_ *cobra.Command, args []string) error {
if err != nil {
return fmt.Errorf("failed to load azure.yaml: %w", err)
}
prefixes, err := normalizeEnvPrefixes(envPrefixes)
if err != nil {
return err
}

names := make([]string, 0, len(azureYaml.Services))
for name := range azureYaml.Services {
Expand All @@ -123,6 +132,9 @@ func runEnv(_ *cobra.Command, args []string) error {
if envKeys {
return fmt.Errorf("cannot combine --keys with --diff")
}
if len(prefixes) > 0 {
return fmt.Errorf("cannot combine --prefix with --diff")
}
return runEnvDiff(azureYaml, names, args)
}

Expand All @@ -149,7 +161,7 @@ func runEnv(_ *cobra.Command, args []string) error {
return fmt.Errorf("cannot combine --keys with --write")
}
if envAll {
return runEnvAllKeys(azureYaml, names)
return runEnvAllKeys(azureYaml, names, prefixes)
}
if len(args) == 0 {
return fmt.Errorf("specify a service name or --all with --keys")
Expand All @@ -160,11 +172,11 @@ func runEnv(_ *cobra.Command, args []string) error {
if !envAll && len(args) == 0 {
return fmt.Errorf("specify a service name or --all with --write")
}
return runEnvWrite(azureYaml, names, args)
return runEnvWrite(azureYaml, names, args, prefixes)
}

if envAll {
return runEnvAll(azureYaml, names)
return runEnvAll(azureYaml, names, prefixes)
}

// No service name: list the available services and exit.
Expand Down Expand Up @@ -194,21 +206,26 @@ func runEnv(_ *cobra.Command, args []string) error {
mask := !envNoMask

if envExplain {
return runEnvExplain(serviceName, svc, mask)
return runEnvExplain(serviceName, svc, mask, prefixes)
}

resolved, err := service.ResolveEnvironment(context.Background(), svc, getAzureEnvironmentValues(), envFile, nil)
if err != nil {
return fmt.Errorf("failed to resolve environment for %q: %w", serviceName, err)
}
resolved = filterEnvByPrefixes(resolved, prefixes)

if envKeys {
return renderEnvKeys(extractEnvKeys(resolved), format)
return renderEnvKeys(extractEnvKeys(resolved), format, prefixes)
}

if format == envFormatJSON {
return cliout.PrintJSON(maskEnv(resolved, mask))
}
if len(resolved) == 0 && len(prefixes) > 0 {
cliout.Info("No environment variables match prefix %s", formatEnvPrefixes(prefixes))
return nil
}

fmt.Print(formatEnv(resolved, format, mask))
return nil
Expand All @@ -217,7 +234,7 @@ func runEnv(_ *cobra.Command, args []string) error {
// runEnvAll resolves and prints the environment for every service. Every service
// is resolved before any output is written so a resolution failure is reported
// without emitting a partial dump.
func runEnvAll(azureYaml *service.AzureYaml, names []string) error {
func runEnvAll(azureYaml *service.AzureYaml, names []string, prefixes []string) error {
format, err := resolveEnvFormat(envFormat)
if err != nil {
return err
Expand All @@ -234,13 +251,14 @@ func runEnvAll(azureYaml *service.AzureYaml, names []string) error {
if err != nil {
return fmt.Errorf("failed to resolve environment for %q: %w", name, err)
}
resolved = filterEnvByPrefixes(resolved, prefixes)
resolvedByService[name] = resolved
}

return renderAllEnv(resolvedByService, names, format, !envNoMask)
}

func runEnvAllKeys(azureYaml *service.AzureYaml, names []string) error {
func runEnvAllKeys(azureYaml *service.AzureYaml, names []string, prefixes []string) error {
format, err := resolveEnvFormat(envFormat)
if err != nil {
return err
Expand All @@ -255,12 +273,57 @@ func runEnvAllKeys(azureYaml *service.AzureYaml, names []string) error {
if err != nil {
return fmt.Errorf("failed to resolve environment for %q: %w", name, err)
}
keysByService[name] = extractEnvKeys(resolved)
keysByService[name] = extractEnvKeys(filterEnvByPrefixes(resolved, prefixes))
}

return renderAllEnvKeys(keysByService, names, format)
}

func normalizeEnvPrefixes(prefixes []string) ([]string, error) {
normalized := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
prefix = strings.TrimSpace(prefix)
if prefix == "" {
return nil, fmt.Errorf("--prefix values cannot be empty")
}
normalized = append(normalized, prefix)
}
return normalized, nil
}

func filterEnvByPrefixes(env map[string]string, prefixes []string) map[string]string {
if len(prefixes) == 0 {
return env
}
filtered := make(map[string]string)
for key, value := range env {
if envKeyHasPrefix(key, prefixes) {
filtered[key] = value
}
}
return filtered
}

func envKeyHasPrefix(key string, prefixes []string) bool {
for _, prefix := range prefixes {
if strings.HasPrefix(key, prefix) {
return true
}
}
return false
}

func formatEnvPrefixes(prefixes []string) string {
if len(prefixes) == 1 {
return fmt.Sprintf("%q", prefixes[0])
}
quoted := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
quoted = append(quoted, fmt.Sprintf("%q", prefix))
}
return strings.Join(quoted, ", ")
}

func extractEnvKeys(env map[string]string) []string {
keys := make([]string, 0, len(env))
for key := range env {
Expand All @@ -270,10 +333,14 @@ func extractEnvKeys(env map[string]string) []string {
return keys
}

func renderEnvKeys(keys []string, format string) error {
func renderEnvKeys(keys []string, format string, prefixes []string) error {
if format == envFormatJSON {
return cliout.PrintJSON(keys)
}
if len(keys) == 0 && len(prefixes) > 0 {
cliout.Info("No environment variables match prefix %s", formatEnvPrefixes(prefixes))
return nil
}
for _, key := range keys {
fmt.Println(key)
}
Expand Down Expand Up @@ -333,7 +400,7 @@ func renderAllEnv(resolvedByService map[string]map[string]string, names []string
// resolution or path error is reported without leaving a partial set of files.
// The default destination is each service's own directory (<service>/.env); when
// --out is set the files go to <out>/<service>.env instead.
func runEnvWrite(azureYaml *service.AzureYaml, names []string, args []string) error {
func runEnvWrite(azureYaml *service.AzureYaml, names []string, args []string, prefixes []string) error {
format, err := resolveEnvFormat(envFormat)
if err != nil {
return err
Expand Down Expand Up @@ -379,6 +446,7 @@ func runEnvWrite(azureYaml *service.AzureYaml, names []string, args []string) er
if rerr != nil {
return fmt.Errorf("failed to resolve environment for %q: %w", name, rerr)
}
resolved = filterEnvByPrefixes(resolved, prefixes)

content, cerr := envFileContent(resolved, format, mask)
if cerr != nil {
Expand Down Expand Up @@ -448,11 +516,12 @@ type envExplainEntry struct {

// runEnvExplain prints each effective variable with the source that won and,
// when a higher-priority source replaced a lower one, the sources it overrode.
func runEnvExplain(serviceName string, svc service.Service, mask bool) error {
func runEnvExplain(serviceName string, svc service.Service, mask bool, prefixes []string) error {
resolved, prov, err := service.ResolveEnvironmentWithSources(context.Background(), svc, getAzureEnvironmentValues(), envFile, nil)
if err != nil {
return fmt.Errorf("failed to resolve environment for %q: %w", serviceName, err)
}
resolved = filterEnvByPrefixes(resolved, prefixes)

masked := maskEnv(resolved, mask)
keys := make([]string, 0, len(masked))
Expand All @@ -479,6 +548,9 @@ func runEnvExplain(serviceName string, svc service.Service, mask bool) error {
fmt.Printf(" source: %s\n", p.Source)
}
}
if len(keys) == 0 && len(prefixes) > 0 {
cliout.Info("No environment variables match prefix %s", formatEnvPrefixes(prefixes))
}
return nil
}

Expand Down
79 changes: 78 additions & 1 deletion cli/src/cmd/app/commands/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ func TestNewEnvCommand(t *testing.T) {
assert.NotEmpty(t, cmd.Short)
require.NotNil(t, cmd.RunE)

for _, name := range []string{"format", "no-mask", "env-file", "all", "explain", "diff", "write", "out", "keys"} {
for _, name := range []string{"format", "no-mask", "env-file", "all", "explain", "diff", "write", "out", "keys", "prefix"} {
assert.NotNil(t, cmd.Flags().Lookup(name), "expected --%s flag", name)
}
}
Expand Down Expand Up @@ -103,6 +103,22 @@ func TestFormatEnv(t *testing.T) {
})
}

func TestFilterEnvByPrefixes(t *testing.T) {
env := map[string]string{
"AZURE_CLIENT_ID": "client",
"DB_HOST": "localhost",
"PLAIN": "value",
}

filtered := filterEnvByPrefixes(env, []string{"AZURE_", "DB_"})

assert.Equal(t, map[string]string{
"AZURE_CLIENT_ID": "client",
"DB_HOST": "localhost",
}, filtered)
assert.Equal(t, env, filterEnvByPrefixes(env, nil))
}

func TestShellQuoteDouble(t *testing.T) {
tests := []struct {
in string
Expand Down Expand Up @@ -171,6 +187,32 @@ func TestRunEnvCommand(t *testing.T) {
assert.Contains(t, out, "SERVICE_ENV_MARKER=marker-value")
})

t.Run("prefix filters service output", func(t *testing.T) {
resetEnvFlags()
t.Setenv("MATCHED_ENV_MARKER", "marker-value")
t.Setenv("OTHER_ENV_MARKER", "other-value")
out, runErr := captureStdout(t, func() error {
cmd := NewEnvCommand()
cmd.SetArgs([]string{"api", "--prefix", "MATCHED_"})
return cmd.Execute()
})
require.NoError(t, runErr)
assert.Contains(t, out, "MATCHED_ENV_MARKER=marker-value")
assert.NotContains(t, out, "OTHER_ENV_MARKER")
})

t.Run("prefix reports no matches for text output", func(t *testing.T) {
resetEnvFlags()
out, runErr := captureStdout(t, func() error {
cmd := NewEnvCommand()
cmd.SetArgs([]string{"api", "--prefix", "NO_SUCH_ENV_PREFIX_"})
return cmd.Execute()
})
require.NoError(t, runErr)
assert.Contains(t, out, "No environment variables match prefix")
assert.Contains(t, out, "NO_SUCH_ENV_PREFIX_")
})

t.Run("invalid format errors", func(t *testing.T) {
resetEnvFlags()
cmd := NewEnvCommand()
Expand Down Expand Up @@ -231,6 +273,21 @@ func TestRunEnvCommand(t *testing.T) {
assert.NotContains(t, out, "z-value")
})

t.Run("keys respects prefix filter", func(t *testing.T) {
resetEnvFlags()
t.Setenv("MATCHED_ENV_MARKER", "marker-value")
t.Setenv("OTHER_ENV_MARKER", "other-value")
out, runErr := captureStdout(t, func() error {
cmd := NewEnvCommand()
cmd.SetArgs([]string{"api", "--keys", "--prefix", "MATCHED_"})
return cmd.Execute()
})
require.NoError(t, runErr)
assert.Contains(t, out, "MATCHED_ENV_MARKER")
assert.NotContains(t, out, "OTHER_ENV_MARKER")
assert.NotContains(t, out, "marker-value")
})

t.Run("all keys groups services", func(t *testing.T) {
resetEnvFlags()
t.Setenv("SERVICE_ENV_MARKER", "marker-value")
Expand Down Expand Up @@ -264,6 +321,25 @@ func TestRunEnvCommand(t *testing.T) {
assert.Contains(t, parsed, "web")
})

t.Run("all keys with json respects multiple prefixes", func(t *testing.T) {
resetEnvFlags()
t.Setenv("MATCHED_ONE", "one")
t.Setenv("MATCHED_TWO", "two")
t.Setenv("OTHER_ENV_MARKER", "other")
out, runErr := captureStdout(t, func() error {
cmd := NewEnvCommand()
cmd.SetArgs([]string{"--all", "--keys", "--format", "json", "--prefix", "MATCHED_ONE", "--prefix", "MATCHED_TWO"})
return cmd.Execute()
})
require.NoError(t, runErr)

var parsed map[string][]string
require.NoError(t, json.Unmarshal([]byte(out), &parsed))
assert.Contains(t, parsed["api"], "MATCHED_ONE")
assert.Contains(t, parsed["api"], "MATCHED_TWO")
assert.NotContains(t, parsed["api"], "OTHER_ENV_MARKER")
})

t.Run("keys with json emits key array", func(t *testing.T) {
resetEnvFlags()
t.Setenv("SERVICE_ENV_MARKER", "marker-value")
Expand Down Expand Up @@ -526,6 +602,7 @@ func resetEnvFlags() {
envWrite = false
envOut = ""
envKeys = false
envPrefixes = nil
_ = cliout.SetFormat("default")
}

Expand Down
Loading