diff --git a/cli/docs/cli-reference.md b/cli/docs/cli-reference.md index 545ae68df..b7ce35f82 100644 --- a/cli/docs/cli-reference.md +++ b/cli/docs/cli-reference.md @@ -338,6 +338,9 @@ azd app run --verbose # Load environment variables from custom file azd app run --env-file .env.local +# Override a single variable inline (repeatable, wins over --env-file) +azd app run --env LOG_LEVEL=debug --env FEATURE_X=on + # Combine multiple flags azd app run -s web -v --runtime aspire @@ -352,6 +355,7 @@ azd app run --force | `--service` | `-s` | string | | Run specific service(s) only (comma-separated) | | `--runtime` | | string | `azd` | Runtime mode: 'azd' (azd dashboard) or 'aspire' (native Aspire with dotnet run) | | `--env-file` | | string | | Load environment variables from .env file | +| `--env` | | stringArray | | Set an environment variable inline as KEY=VALUE (repeatable, overrides --env-file) | | `--verbose` | `-v` | bool | `false` | Enable verbose logging | | `--dry-run` | | bool | `false` | Show what would be run without starting services | | `--no-timing` | | bool | `false` | Hide the per-service startup timing summary shown after services are ready | diff --git a/cli/src/cmd/app/commands/run.go b/cli/src/cmd/app/commands/run.go index e8fbeef8c..e13bbd9a8 100644 --- a/cli/src/cmd/app/commands/run.go +++ b/cli/src/cmd/app/commands/run.go @@ -31,6 +31,7 @@ var ( runServiceFilter string runScale []string runEnvFile string + runEnvInline []string runVerbose bool runDryRun bool runDetach bool @@ -62,6 +63,7 @@ func NewRunCommand() *cobra.Command { cmd.Flags().StringVarP(&runServiceFilter, "service", "s", "", "Run specific service(s) only (comma-separated)") cmd.Flags().StringSliceVar(&runScale, "scale", nil, "Run multiple instances of a service, e.g. --scale worker=3 (repeatable, comma-separated)") cmd.Flags().StringVar(&runEnvFile, "env-file", "", "Load environment variables from .env file") + cmd.Flags().StringArrayVar(&runEnvInline, "env", nil, "Set an environment variable inline as KEY=VALUE (repeatable, overrides --env-file)") cmd.Flags().BoolVarP(&runVerbose, "verbose", "v", false, "Enable verbose logging") cmd.Flags().BoolVar(&runDryRun, "dry-run", false, "Show what would be run without starting services") cmd.Flags().StringVar(&runRuntime, "runtime", runtimeModeAzd, "Runtime mode: 'azd' (azd dashboard) or 'aspire' (native Aspire with dotnet run)") @@ -395,19 +397,41 @@ func detectServiceRuntimes(services map[string]service.Service, azureYamlDir, ru return runtimes, nil } -// loadEnvironmentVariables loads environment variables from --env-file if specified. +// loadEnvironmentVariables loads environment variables from --env-file (if +// specified) and merges any inline --env KEY=VALUE overrides on top. Inline +// values take precedence over values loaded from the file. func loadEnvironmentVariables() (map[string]string, error) { - if runEnvFile == "" { - return make(map[string]string), nil + envVars := make(map[string]string) + + if runEnvFile != "" { + loaded, err := service.LoadDotEnv(runEnvFile) + if err != nil { + return nil, fmt.Errorf("failed to load env file: %w", err) + } + envVars = loaded } - envVars, err := service.LoadDotEnv(runEnvFile) - if err != nil { - return nil, fmt.Errorf("failed to load env file: %w", err) + if err := mergeInlineEnv(envVars, runEnvInline); err != nil { + return nil, err } + return envVars, nil } +// mergeInlineEnv parses KEY=VALUE entries and merges them into envVars, +// overriding any existing keys. Each entry must contain '=' and a non-empty +// key. The value may be empty or contain additional '=' characters. +func mergeInlineEnv(envVars map[string]string, entries []string) error { + for _, entry := range entries { + key, value, found := strings.Cut(entry, "=") + if !found || key == "" { + return fmt.Errorf("invalid --env value %q: expected KEY=VALUE", entry) + } + envVars[key] = value + } + return nil +} + // buildServiceSummaries composes URL summaries for display, preferring rich data from serviceinfo. func buildServiceSummaries(projectDir string, azureYaml *service.AzureYaml, processes map[string]*service.ServiceProcess) []service.ServiceURLSummary { services, err := serviceinfo.GetServiceInfo(projectDir) diff --git a/cli/src/cmd/app/commands/run_envinline_test.go b/cli/src/cmd/app/commands/run_envinline_test.go new file mode 100644 index 000000000..fbd1d5634 --- /dev/null +++ b/cli/src/cmd/app/commands/run_envinline_test.go @@ -0,0 +1,124 @@ +package commands + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMergeInlineEnv(t *testing.T) { + tests := []struct { + name string + initial map[string]string + entries []string + want map[string]string + wantErr string + }{ + { + name: "single entry", + initial: map[string]string{}, + entries: []string{"KEY=value"}, + want: map[string]string{"KEY": "value"}, + }, + { + name: "multiple entries", + initial: map[string]string{}, + entries: []string{"A=1", "B=2"}, + want: map[string]string{"A": "1", "B": "2"}, + }, + { + name: "inline overrides existing", + initial: map[string]string{"A": "fromfile"}, + entries: []string{"A=inline"}, + want: map[string]string{"A": "inline"}, + }, + { + name: "empty value allowed", + initial: map[string]string{}, + entries: []string{"EMPTY="}, + want: map[string]string{"EMPTY": ""}, + }, + { + name: "value with equals signs preserved", + initial: map[string]string{}, + entries: []string{"CONN=a=b=c"}, + want: map[string]string{"CONN": "a=b=c"}, + }, + { + name: "missing equals is an error", + initial: map[string]string{}, + entries: []string{"NOEQUALS"}, + wantErr: `invalid --env value "NOEQUALS"`, + }, + { + name: "empty key is an error", + initial: map[string]string{}, + entries: []string{"=value"}, + wantErr: `invalid --env value "=value"`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := mergeInlineEnv(tt.initial, tt.entries) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, tt.initial) + }) + } +} + +func TestLoadEnvironmentVariables_InlinePrecedence(t *testing.T) { + origFile, origInline := runEnvFile, runEnvInline + t.Cleanup(func() { + runEnvFile, runEnvInline = origFile, origInline + }) + + dir := t.TempDir() + envPath := filepath.Join(dir, ".env") + require.NoError(t, os.WriteFile(envPath, []byte("A=fromfile\nB=fromfile\n"), 0o600)) + + runEnvFile = envPath + runEnvInline = []string{"A=inline", "C=new"} + + got, err := loadEnvironmentVariables() + require.NoError(t, err) + assert.Equal(t, "inline", got["A"], "inline value should override the file value") + assert.Equal(t, "fromfile", got["B"], "file-only value should remain") + assert.Equal(t, "new", got["C"], "inline-only value should be added") +} + +func TestLoadEnvironmentVariables_InlineOnly(t *testing.T) { + origFile, origInline := runEnvFile, runEnvInline + t.Cleanup(func() { + runEnvFile, runEnvInline = origFile, origInline + }) + + runEnvFile = "" + runEnvInline = []string{"ONLY=1"} + + got, err := loadEnvironmentVariables() + require.NoError(t, err) + assert.Equal(t, map[string]string{"ONLY": "1"}, got) +} + +func TestLoadEnvironmentVariables_InlineError(t *testing.T) { + origFile, origInline := runEnvFile, runEnvInline + t.Cleanup(func() { + runEnvFile, runEnvInline = origFile, origInline + }) + + runEnvFile = "" + runEnvInline = []string{"bad"} + + _, err := loadEnvironmentVariables() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid --env value") +}