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
4 changes: 4 additions & 0 deletions cli/docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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 |
Expand Down
36 changes: 30 additions & 6 deletions cli/src/cmd/app/commands/run.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ var (
runServiceFilter string
runScale []string
runEnvFile string
runEnvInline []string
runVerbose bool
runDryRun bool
runDetach bool
Expand Down Expand Up @@ -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)")
Expand Down Expand Up @@ -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)
Expand Down
124 changes: 124 additions & 0 deletions cli/src/cmd/app/commands/run_envinline_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading