From df4de71dcc0f29b1d5cad4bffb7236b187d2b3b9 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 15 Jan 2026 12:52:44 -0800 Subject: [PATCH 1/3] Pass azd env vars to tools --- cli/azd/pkg/ai/python_bridge.go | 4 +-- cli/azd/pkg/project/container_helper.go | 17 ++++++++++-- .../pkg/project/framework_service_dotnet.go | 23 +++++++++++++--- .../pkg/project/framework_service_maven.go | 24 ++++++++++++++--- cli/azd/pkg/project/framework_service_npm.go | 26 ++++++++++++++++--- .../pkg/project/framework_service_python.go | 8 +++++- cli/azd/pkg/project/framework_service_swa.go | 9 ++++++- cli/azd/pkg/project/service_config.go | 24 +++++++++++++++++ cli/azd/pkg/tools/dotnet/dotnet.go | 17 +++++++++--- cli/azd/pkg/tools/maven/maven.go | 21 ++++++++++++--- cli/azd/pkg/tools/npm/npm.go | 24 ++++++++++++++--- cli/azd/pkg/tools/python/python.go | 18 +++++++++++-- cli/azd/pkg/tools/python/python_test.go | 4 +-- cli/azd/pkg/tools/swa/swa.go | 18 ++++++++++--- cli/azd/pkg/tools/swa/swa_test.go | 4 +-- 15 files changed, 207 insertions(+), 34 deletions(-) diff --git a/cli/azd/pkg/ai/python_bridge.go b/cli/azd/pkg/ai/python_bridge.go index 1b7dd0fd6e4..b4efd5720c9 100644 --- a/cli/azd/pkg/ai/python_bridge.go +++ b/cli/azd/pkg/ai/python_bridge.go @@ -79,7 +79,7 @@ func (b *pythonBridge) RequiredExternalTools(ctx context.Context) []tools.Extern // Run executes the specified python script with the given arguments func (b *pythonBridge) Run(ctx context.Context, scriptName ScriptPath, args ...string) (*exec.RunResult, error) { allArgs := append([]string{string(scriptName)}, args...) - return b.pythonCli.Run(ctx, b.workingDir, ".venv", allArgs...) + return b.pythonCli.Run(ctx, b.workingDir, ".venv", nil, allArgs...) } // initPython initializes the Python environment @@ -104,7 +104,7 @@ func (b *pythonBridge) initPython(ctx context.Context) error { return err } - if err := b.pythonCli.InstallRequirements(ctx, targetDir, ".venv", "requirements.txt"); err != nil { + if err := b.pythonCli.InstallRequirements(ctx, targetDir, ".venv", "requirements.txt", nil); err != nil { return err } diff --git a/cli/azd/pkg/project/container_helper.go b/cli/azd/pkg/project/container_helper.go index ea3c5d20746..cc2050f5632 100644 --- a/cli/azd/pkg/project/container_helper.go +++ b/cli/azd/pkg/project/container_helper.go @@ -458,11 +458,24 @@ func (ch *ContainerHelper) Build( // Include full environment variables for the docker build including: // 1. Environment variables from the host - // 2. Environment variables from the service configuration - // 3. Environment variables from the docker configuration + // 2. Environment variables from the azd environment + // 3. Environment variables from the service configuration (azure.yaml env) + // 4. Environment variables from the docker configuration dockerEnv := []string{} dockerEnv = append(dockerEnv, os.Environ()...) dockerEnv = append(dockerEnv, env.Environ()...) + + // Expand and add service-level environment variables from azure.yaml + if len(serviceConfig.Environment) > 0 { + expandedServiceEnv, err := serviceConfig.Environment.Expand(env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + for key, value := range expandedServiceEnv { + dockerEnv = append(dockerEnv, fmt.Sprintf("%s=%s", key, value)) + } + } + dockerEnv = append(dockerEnv, dockerOptions.BuildEnv...) // Build the container diff --git a/cli/azd/pkg/project/framework_service_dotnet.go b/cli/azd/pkg/project/framework_service_dotnet.go index 5d3024b22bd..ca35eaae537 100644 --- a/cli/azd/pkg/project/framework_service_dotnet.go +++ b/cli/azd/pkg/project/framework_service_dotnet.go @@ -94,7 +94,12 @@ func (dp *dotnetProject) Restore( return nil, err } - if err := dp.dotnetCli.Restore(ctx, projFile); err != nil { + env, err := serviceConfig.ExpandEnv(dp.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + + if err := dp.dotnetCli.Restore(ctx, projFile, env); err != nil { return nil, err } @@ -123,7 +128,13 @@ func (dp *dotnetProject) Build( if err != nil { return nil, err } - if err := dp.dotnetCli.Build(ctx, projFile, defaultDotNetBuildConfiguration, ""); err != nil { + + env, err := serviceConfig.ExpandEnv(dp.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + + if err := dp.dotnetCli.Build(ctx, projFile, defaultDotNetBuildConfiguration, "", env); err != nil { return nil, err } @@ -189,7 +200,13 @@ func (dp *dotnetProject) Package( if err != nil { return nil, err } - if err := dp.dotnetCli.Publish(ctx, projFile, defaultDotNetBuildConfiguration, packageDest); err != nil { + + env, err := serviceConfig.ExpandEnv(dp.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + + if err := dp.dotnetCli.Publish(ctx, projFile, defaultDotNetBuildConfiguration, packageDest, env); err != nil { return nil, err } diff --git a/cli/azd/pkg/project/framework_service_maven.go b/cli/azd/pkg/project/framework_service_maven.go index 127022c86a1..8ed8ea96e88 100644 --- a/cli/azd/pkg/project/framework_service_maven.go +++ b/cli/azd/pkg/project/framework_service_maven.go @@ -69,7 +69,13 @@ func (m *mavenProject) Restore( progress *async.Progress[ServiceProgress], ) (*ServiceRestoreResult, error) { progress.SetProgress(NewServiceProgress("Resolving maven dependencies")) - if err := m.mavenCli.ResolveDependencies(ctx, serviceConfig.Path()); err != nil { + + env, err := serviceConfig.ExpandEnv(m.env.Getenv) + if err != nil { + return nil, fmt.Errorf("getting service environment variables: %w", err) + } + + if err := m.mavenCli.ResolveDependencies(ctx, serviceConfig.Path(), env); err != nil { return nil, fmt.Errorf("resolving maven dependencies: %w", err) } @@ -98,7 +104,13 @@ func (m *mavenProject) Build( progress *async.Progress[ServiceProgress], ) (*ServiceBuildResult, error) { progress.SetProgress(NewServiceProgress("Compiling maven project")) - if err := m.mavenCli.Compile(ctx, serviceConfig.Path()); err != nil { + + env, err := serviceConfig.ExpandEnv(m.env.Getenv) + if err != nil { + return nil, fmt.Errorf("getting service environment variables: %w", err) + } + + if err := m.mavenCli.Compile(ctx, serviceConfig.Path(), env); err != nil { return nil, err } // Create build artifact for maven compile output @@ -125,7 +137,13 @@ func (m *mavenProject) Package( progress *async.Progress[ServiceProgress], ) (*ServicePackageResult, error) { progress.SetProgress(NewServiceProgress("Packaging maven project")) - if err := m.mavenCli.Package(ctx, serviceConfig.Path()); err != nil { + + env, err := serviceConfig.ExpandEnv(m.env.Getenv) + if err != nil { + return nil, fmt.Errorf("getting service environment variables: %w", err) + } + + if err := m.mavenCli.Package(ctx, serviceConfig.Path(), env); err != nil { return nil, err } diff --git a/cli/azd/pkg/project/framework_service_npm.go b/cli/azd/pkg/project/framework_service_npm.go index c5221f4e8e3..d3105e979ad 100644 --- a/cli/azd/pkg/project/framework_service_npm.go +++ b/cli/azd/pkg/project/framework_service_npm.go @@ -56,7 +56,14 @@ func (np *npmProject) Restore( progress *async.Progress[ServiceProgress], ) (*ServiceRestoreResult, error) { progress.SetProgress(NewServiceProgress("Installing NPM dependencies")) - if err := np.cli.Install(ctx, serviceConfig.Path()); err != nil { + + // Expand service environment variables + envVars, err := serviceConfig.ExpandEnv(np.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + + if err := np.cli.Install(ctx, serviceConfig.Path(), envVars); err != nil { return nil, err } @@ -87,7 +94,14 @@ func (np *npmProject) Build( // Exec custom `build` script if available // If `build`` script is not defined in the package.json the NPM script will NOT fail progress.SetProgress(NewServiceProgress("Running NPM build script")) - if err := np.cli.RunScript(ctx, serviceConfig.Path(), "build"); err != nil { + + // Expand service environment variables + envVars, err := serviceConfig.ExpandEnv(np.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + + if err := np.cli.RunScript(ctx, serviceConfig.Path(), "build", envVars); err != nil { return nil, err } @@ -122,10 +136,16 @@ func (np *npmProject) Package( ) (*ServicePackageResult, error) { progress.SetProgress(NewServiceProgress("Running NPM package script")) + // Expand service environment variables + envVars, err := serviceConfig.ExpandEnv(np.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + // Long term this script we call should better align with our inner-loop scenarios // Keeping this defaulted to `build` will create confusion for users when we start to support // both local dev / debug builds and production bundled builds - if err := np.cli.RunScript(ctx, serviceConfig.Path(), "build"); err != nil { + if err := np.cli.RunScript(ctx, serviceConfig.Path(), "build", envVars); err != nil { return nil, err } diff --git a/cli/azd/pkg/project/framework_service_python.go b/cli/azd/pkg/project/framework_service_python.go index e9606dbd511..3c0954428b0 100644 --- a/cli/azd/pkg/project/framework_service_python.go +++ b/cli/azd/pkg/project/framework_service_python.go @@ -79,8 +79,14 @@ func (pp *pythonProject) Restore( } } + // Expand service environment variables + envVars, err := serviceConfig.ExpandEnv(pp.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + progress.SetProgress(NewServiceProgress("Installing Python PIP dependencies")) - err = pp.cli.InstallRequirements(ctx, serviceConfig.Path(), vEnvName, "requirements.txt") + err = pp.cli.InstallRequirements(ctx, serviceConfig.Path(), vEnvName, "requirements.txt", envVars) if err != nil { return nil, fmt.Errorf("requirements for project '%s' could not be installed: %w", serviceConfig.Path(), err) } diff --git a/cli/azd/pkg/project/framework_service_swa.go b/cli/azd/pkg/project/framework_service_swa.go index 27b94e7f4a7..f0abbcf6b32 100644 --- a/cli/azd/pkg/project/framework_service_swa.go +++ b/cli/azd/pkg/project/framework_service_swa.go @@ -5,6 +5,7 @@ package project import ( "context" + "fmt" "github.com/azure/azure-dev/cli/azd/pkg/async" "github.com/azure/azure-dev/cli/azd/pkg/environment" @@ -96,16 +97,22 @@ func (p *swaProject) Build( serviceContext *ServiceContext, _ *async.Progress[ServiceProgress], ) (*ServiceBuildResult, error) { + env, err := serviceConfig.ExpandEnv(p.env.Getenv) + if err != nil { + return nil, fmt.Errorf("expanding service environment variables: %w", err) + } + previewerWriter := p.console.ShowPreviewer(ctx, &input.ShowPreviewerOptions{ Prefix: " ", MaxLineCount: 8, Title: "Build SWA Project", }) - err := p.swa.Build( + err = p.swa.Build( ctx, serviceConfig.Path(), previewerWriter, + env, ) p.console.StopPreviewer(ctx, false) diff --git a/cli/azd/pkg/project/service_config.go b/cli/azd/pkg/project/service_config.go index 6e0402a463b..8f74c77a33f 100644 --- a/cli/azd/pkg/project/service_config.go +++ b/cli/azd/pkg/project/service_config.go @@ -4,6 +4,8 @@ package project import ( + "fmt" + "os" "path/filepath" "github.com/azure/azure-dev/cli/azd/pkg/apphost" @@ -89,3 +91,25 @@ func (sc *ServiceConfig) Path() string { } return filepath.Join(sc.Project.Path, sc.RelativePath) } + +// ExpandEnv expands the service-level environment variables defined in azure.yaml +// using the provided lookup function and merges them with the current OS environment. +// Service-defined variables take precedence over OS environment variables. +// Returns nil if no service environment variables are configured. +func (sc *ServiceConfig) ExpandEnv(lookup func(string) string) ([]string, error) { + if len(sc.Environment) == 0 { + return nil, nil + } + + expanded, err := sc.Environment.Expand(lookup) + if err != nil { + return nil, err + } + + env := os.Environ() + for key, value := range expanded { + env = append(env, fmt.Sprintf("%s=%s", key, value)) + } + + return env, nil +} diff --git a/cli/azd/pkg/tools/dotnet/dotnet.go b/cli/azd/pkg/tools/dotnet/dotnet.go index acc7c5f428f..7eeabc22100 100644 --- a/cli/azd/pkg/tools/dotnet/dotnet.go +++ b/cli/azd/pkg/tools/dotnet/dotnet.go @@ -90,8 +90,11 @@ func (cli *Cli) CheckInstalled(ctx context.Context) error { return nil } -func (cli *Cli) Restore(ctx context.Context, project string) error { +func (cli *Cli) Restore(ctx context.Context, project string, env []string) error { runArgs := newDotNetRunArgs("restore", project) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("dotnet restore on project '%s' failed: %w", project, err) @@ -99,7 +102,7 @@ func (cli *Cli) Restore(ctx context.Context, project string) error { return nil } -func (cli *Cli) Build(ctx context.Context, project string, configuration string, output string) error { +func (cli *Cli) Build(ctx context.Context, project string, configuration string, output string, env []string) error { runArgs := newDotNetRunArgs("build", project) if configuration != "" { runArgs = runArgs.AppendParams("-c", configuration) @@ -109,6 +112,10 @@ func (cli *Cli) Build(ctx context.Context, project string, configuration string, runArgs = runArgs.AppendParams("--output", output) } + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } + _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("dotnet build on project '%s' failed: %w", project, err) @@ -116,7 +123,7 @@ func (cli *Cli) Build(ctx context.Context, project string, configuration string, return nil } -func (cli *Cli) Publish(ctx context.Context, project string, configuration string, output string) error { +func (cli *Cli) Publish(ctx context.Context, project string, configuration string, output string, env []string) error { runArgs := newDotNetRunArgs("publish", project) if configuration != "" { runArgs = runArgs.AppendParams("-c", configuration) @@ -126,6 +133,10 @@ func (cli *Cli) Publish(ctx context.Context, project string, configuration strin runArgs = runArgs.AppendParams("--output", output) } + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } + _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("dotnet publish on project '%s' failed: %w", project, err) diff --git a/cli/azd/pkg/tools/maven/maven.go b/cli/azd/pkg/tools/maven/maven.go index 7283db5426f..ce5e9bf2ea1 100644 --- a/cli/azd/pkg/tools/maven/maven.go +++ b/cli/azd/pkg/tools/maven/maven.go @@ -172,13 +172,18 @@ func (cli *Cli) extractVersion(ctx context.Context) (string, error) { return parts[1], nil } -func (cli *Cli) Compile(ctx context.Context, projectPath string) error { +// Compile runs mvn compile on the project. +// Optional env parameter allows passing additional environment variables to the maven process. +func (cli *Cli) Compile(ctx context.Context, projectPath string, env []string) error { mvnCmd, err := cli.mvnCmd() if err != nil { return err } runArgs := exec.NewRunArgs(mvnCmd, "compile").WithCwd(projectPath) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } _, err = cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("mvn compile on project '%s' failed: %w", projectPath, err) @@ -187,7 +192,9 @@ func (cli *Cli) Compile(ctx context.Context, projectPath string) error { return nil } -func (cli *Cli) Package(ctx context.Context, projectPath string) error { +// Package runs mvn package on the project. +// Optional env parameter allows passing additional environment variables to the maven process. +func (cli *Cli) Package(ctx context.Context, projectPath string, env []string) error { mvnCmd, err := cli.mvnCmd() if err != nil { return err @@ -195,6 +202,9 @@ func (cli *Cli) Package(ctx context.Context, projectPath string) error { // Maven's package phase includes tests by default. Skip it explicitly. runArgs := exec.NewRunArgs(mvnCmd, "package", "-DskipTests").WithCwd(projectPath) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } _, err = cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("mvn package on project '%s' failed: %w", projectPath, err) @@ -203,12 +213,17 @@ func (cli *Cli) Package(ctx context.Context, projectPath string) error { return nil } -func (cli *Cli) ResolveDependencies(ctx context.Context, projectPath string) error { +// ResolveDependencies runs mvn dependency:resolve on the project. +// Optional env parameter allows passing additional environment variables to the maven process. +func (cli *Cli) ResolveDependencies(ctx context.Context, projectPath string, env []string) error { mvnCmd, err := cli.mvnCmd() if err != nil { return err } runArgs := exec.NewRunArgs(mvnCmd, "dependency:resolve").WithCwd(projectPath) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } _, err = cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("mvn dependency:resolve on project '%s' failed: %w", projectPath, err) diff --git a/cli/azd/pkg/tools/npm/npm.go b/cli/azd/pkg/tools/npm/npm.go index c7166b7db04..1a5e72dc32e 100644 --- a/cli/azd/pkg/tools/npm/npm.go +++ b/cli/azd/pkg/tools/npm/npm.go @@ -65,11 +65,17 @@ func (cli *Cli) Name() string { return "npm CLI" } -func (cli *Cli) Install(ctx context.Context, project string) error { +// Install runs npm install in the specified project directory. +// Optional env parameter allows passing additional environment variables to the npm process. +func (cli *Cli) Install(ctx context.Context, project string, env []string) error { runArgs := exec. NewRunArgs("npm", "install"). WithCwd(project) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } + _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { @@ -78,11 +84,17 @@ func (cli *Cli) Install(ctx context.Context, project string) error { return nil } -func (cli *Cli) RunScript(ctx context.Context, projectPath string, scriptName string) error { +// RunScript runs an npm script in the specified project directory. +// Optional env parameter allows passing additional environment variables to the npm process. +func (cli *Cli) RunScript(ctx context.Context, projectPath string, scriptName string, env []string) error { runArgs := exec. NewRunArgs("npm", "run", scriptName, "--if-present"). WithCwd(projectPath) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } + _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { @@ -92,7 +104,9 @@ func (cli *Cli) RunScript(ctx context.Context, projectPath string, scriptName st return nil } -func (cli *Cli) Prune(ctx context.Context, projectPath string, production bool) error { +// Prune removes extraneous packages from the node_modules directory. +// Optional env parameter allows passing additional environment variables to the npm process. +func (cli *Cli) Prune(ctx context.Context, projectPath string, production bool, env []string) error { runArgs := exec. NewRunArgs("npm", "prune"). WithCwd(projectPath) @@ -101,6 +115,10 @@ func (cli *Cli) Prune(ctx context.Context, projectPath string, production bool) runArgs = runArgs.AppendParams("--production") } + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } + _, err := cli.commandRunner.Run(ctx, runArgs) if err != nil { return fmt.Errorf("failed pruning NPM packages, %w", err) diff --git a/cli/azd/pkg/tools/python/python.go b/cli/azd/pkg/tools/python/python.go index 43a9b5c16a2..e279e5568fe 100644 --- a/cli/azd/pkg/tools/python/python.go +++ b/cli/azd/pkg/tools/python/python.go @@ -67,9 +67,17 @@ func (cli *Cli) Name() string { return "Python CLI" } -func (cli *Cli) InstallRequirements(ctx context.Context, workingDir, environment, requirementFile string) error { +// InstallRequirements installs packages from a requirements file in a virtual environment. +// Optional env parameter allows passing additional environment variables to the python process. +func (cli *Cli) InstallRequirements( + ctx context.Context, + workingDir, + environment, + requirementFile string, + env []string, +) error { args := []string{"-m", "pip", "install", "-r", requirementFile} - _, err := cli.Run(ctx, workingDir, environment, args...) + _, err := cli.Run(ctx, workingDir, environment, env, args...) if err != nil { return fmt.Errorf("failed to install requirements for project '%s': %w", workingDir, err) } @@ -98,10 +106,13 @@ func (cli *Cli) CreateVirtualEnv(ctx context.Context, workingDir, name string) e return nil } +// Run executes a Python command within a virtual environment. +// Optional env parameter allows passing additional environment variables to the python process. func (cli *Cli) Run( ctx context.Context, workingDir string, environment string, + env []string, args ...string, ) (*exec.RunResult, error) { pyString, err := cli.checkPath() @@ -122,6 +133,9 @@ func (cli *Cli) Run( // We need to ensure the virtual environment is activated before running the script commands := []string{envActivationCmd, runCmd} runArgs := exec.NewRunArgs("").WithCwd(workingDir) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } runResult, err := cli.commandRunner.RunList(ctx, commands, runArgs) if err != nil { diff --git a/cli/azd/pkg/tools/python/python_test.go b/cli/azd/pkg/tools/python/python_test.go index d74f30bb750..134d309ec3a 100644 --- a/cli/azd/pkg/tools/python/python_test.go +++ b/cli/azd/pkg/tools/python/python_test.go @@ -30,7 +30,7 @@ func Test_Python_Run(t *testing.T) { return strings.Contains(command, pyString) }).Respond(exec.NewRunResult(0, "", "")) - runResult, err := cli.Run(*mockContext.Context, tempDir, ".venv", "pf_client.py", "arg1", "arg2", "arg3") + runResult, err := cli.Run(*mockContext.Context, tempDir, ".venv", nil, "pf_client.py", "arg1", "arg2", "arg3") require.NoError(t, err) require.NotNil(t, runResult) require.NotNil(t, runArgs) @@ -57,7 +57,7 @@ func Test_Python_InstallRequirements(t *testing.T) { return strings.Contains(command, "requirements.txt") }).Respond(exec.NewRunResult(0, "", "")) - err = cli.InstallRequirements(*mockContext.Context, tempDir, ".venv", "requirements.txt") + err = cli.InstallRequirements(*mockContext.Context, tempDir, ".venv", "requirements.txt", nil) require.NoError(t, err) require.NotNil(t, runArgs) require.Equal(t, tempDir, runArgs.Cwd) diff --git a/cli/azd/pkg/tools/swa/swa.go b/cli/azd/pkg/tools/swa/swa.go index 9ef6951e9f5..9dc1a4fcc86 100644 --- a/cli/azd/pkg/tools/swa/swa.go +++ b/cli/azd/pkg/tools/swa/swa.go @@ -37,9 +37,9 @@ type Cli struct { commandRunner exec.CommandRunner } -func (cli *Cli) Build(ctx context.Context, cwd string, buildProgress io.Writer) error { +func (cli *Cli) Build(ctx context.Context, cwd string, buildProgress io.Writer, env []string) error { fullAppFolderPath := filepath.Join(cwd) - result, err := cli.run(ctx, fullAppFolderPath, buildProgress, "build", "-V") + result, err := cli.run(ctx, fullAppFolderPath, buildProgress, env, "build", "-V") if err != nil { return fmt.Errorf("swa build: %w", err) @@ -116,15 +116,25 @@ func (cli *Cli) InstallUrl() string { } func (cli *Cli) executeCommand(ctx context.Context, cwd string, args ...string) (exec.RunResult, error) { - return cli.run(ctx, cwd, nil, args...) + return cli.run(ctx, cwd, nil, nil, args...) } -func (cli *Cli) run(ctx context.Context, cwd string, buildProgress io.Writer, args ...string) (exec.RunResult, error) { +func (cli *Cli) run( + ctx context.Context, + cwd string, + buildProgress io.Writer, + env []string, + args ...string, +) (exec.RunResult, error) { runArgs := exec. NewRunArgs("npx", "-y", swaCliPackage). AppendParams(args...). WithCwd(cwd) + if len(env) > 0 { + runArgs = runArgs.WithEnv(env) + } + if buildProgress != nil { runArgs = runArgs.WithStdOut(buildProgress).WithStdErr(buildProgress) } diff --git a/cli/azd/pkg/tools/swa/swa_test.go b/cli/azd/pkg/tools/swa/swa_test.go index 80c96727ed4..fd894f5abed 100644 --- a/cli/azd/pkg/tools/swa/swa_test.go +++ b/cli/azd/pkg/tools/swa/swa_test.go @@ -45,7 +45,7 @@ func Test_SwaBuild(t *testing.T) { }, nil }) - err := swacli.Build(context.Background(), testPath, nil) + err := swacli.Build(context.Background(), testPath, nil, nil) require.NoError(t, err) require.True(t, ran) }) @@ -72,7 +72,7 @@ func Test_SwaBuild(t *testing.T) { }, errors.New("exit code: 1") }) - err := swacli.Build(context.Background(), testPath, nil) + err := swacli.Build(context.Background(), testPath, nil, nil) require.True(t, ran) require.EqualError( t, From f2ae8ecd1f94d0e643102ec48a3f9b0d1fe640eb Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Thu, 15 Jan 2026 13:54:13 -0800 Subject: [PATCH 2/3] Adds functional tests for resolving env vars at command time --- cli/azd/test/functional/service_env_test.go | 117 ++++++++++++++++++ .../testdata/samples/swaenvtest/azure.yaml | 16 +++ .../testdata/samples/swaenvtest/build.js | 29 +++++ .../samples/swaenvtest/package-lock.json | 13 ++ .../testdata/samples/swaenvtest/package.json | 12 ++ 5 files changed, 187 insertions(+) create mode 100644 cli/azd/test/functional/service_env_test.go create mode 100644 cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml create mode 100644 cli/azd/test/functional/testdata/samples/swaenvtest/build.js create mode 100644 cli/azd/test/functional/testdata/samples/swaenvtest/package-lock.json create mode 100644 cli/azd/test/functional/testdata/samples/swaenvtest/package.json diff --git a/cli/azd/test/functional/service_env_test.go b/cli/azd/test/functional/service_env_test.go new file mode 100644 index 00000000000..7301d6976f5 --- /dev/null +++ b/cli/azd/test/functional/service_env_test.go @@ -0,0 +1,117 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package cli_test + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/azure/azure-dev/cli/azd/test/azdcli" + "github.com/stretchr/testify/require" +) + +// Test_CLI_ServiceEnv_Build validates that service-level environment variables +// defined in azure.yaml are properly expanded and passed to build tools. +func Test_CLI_ServiceEnv_Build(t *testing.T) { + t.Parallel() + ctx, cancel := newTestContext(t) + defer cancel() + + dir := tempDirWithDiagnostics(t) + t.Logf("DIR: %s", dir) + + envName := randomEnvName() + t.Logf("AZURE_ENV_NAME: %s", envName) + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + cli.Env = append(os.Environ(), "AZURE_LOCATION=eastus2") + + err := copySample(dir, "swaenvtest") + require.NoError(t, err, "failed expanding sample") + + // Initialize the project + _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") + require.NoError(t, err) + + // Set environment variables that will be referenced in azure.yaml + _, err = cli.RunCommand(ctx, "env", "set", "TEST_API_URL", "https://api.example.com") + require.NoError(t, err) + + _, err = cli.RunCommand(ctx, "env", "set", "TEST_APP_NAME", "MyTestApp") + require.NoError(t, err) + + // Run the build command which should pass env vars to npm build + result, err := cli.RunCommand(ctx, "build", "web") + require.NoError(t, err, "build failed: %s", result.Stdout) + + // Verify the env-output.json file was created with correct values + envOutputPath := filepath.Join(dir, "dist", "env-output.json") + envOutputBytes, err := os.ReadFile(envOutputPath) + require.NoError(t, err, "env-output.json should be created by build") + + var envOutput map[string]string + err = json.Unmarshal(envOutputBytes, &envOutput) + require.NoError(t, err, "env-output.json should be valid JSON") + + // Verify the environment variables were expanded correctly from azd env + require.Equal(t, "https://api.example.com", envOutput["VITE_API_URL"], + "VITE_API_URL should be expanded from ${TEST_API_URL}") + require.Equal(t, "MyTestApp", envOutput["VITE_APP_NAME"], + "VITE_APP_NAME should be expanded from ${TEST_APP_NAME}") + require.Equal(t, "test", envOutput["VITE_BUILD_ENV"], + "VITE_BUILD_ENV should be the static value 'test'") +} + +// Test_CLI_ServiceEnv_Package validates that service-level environment variables +// are passed to build tools during the package command. +func Test_CLI_ServiceEnv_Package(t *testing.T) { + t.Parallel() + ctx, cancel := newTestContext(t) + defer cancel() + + dir := tempDirWithDiagnostics(t) + t.Logf("DIR: %s", dir) + + envName := randomEnvName() + t.Logf("AZURE_ENV_NAME: %s", envName) + + cli := azdcli.NewCLI(t) + cli.WorkingDirectory = dir + cli.Env = append(os.Environ(), "AZURE_LOCATION=eastus2") + + err := copySample(dir, "swaenvtest") + require.NoError(t, err, "failed expanding sample") + + // Initialize the project + _, err = cli.RunCommandWithStdIn(ctx, stdinForInit(envName), "init") + require.NoError(t, err) + + // Set environment variables that will be referenced in azure.yaml + _, err = cli.RunCommand(ctx, "env", "set", "TEST_API_URL", "https://package.example.com") + require.NoError(t, err) + + _, err = cli.RunCommand(ctx, "env", "set", "TEST_APP_NAME", "PackageTestApp") + require.NoError(t, err) + + // Run the package command which includes building + result, err := cli.RunCommand(ctx, "package", "web") + require.NoError(t, err, "package failed: %s", result.Stdout) + + // Verify the env-output.json file was created with correct values + envOutputPath := filepath.Join(dir, "dist", "env-output.json") + envOutputBytes, err := os.ReadFile(envOutputPath) + require.NoError(t, err, "env-output.json should be created during package build step") + + var envOutput map[string]string + err = json.Unmarshal(envOutputBytes, &envOutput) + require.NoError(t, err, "env-output.json should be valid JSON") + + // Verify the environment variables were expanded correctly + require.Equal(t, "https://package.example.com", envOutput["VITE_API_URL"]) + require.Equal(t, "PackageTestApp", envOutput["VITE_APP_NAME"]) + require.Equal(t, "test", envOutput["VITE_BUILD_ENV"]) +} diff --git a/cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml b/cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml new file mode 100644 index 00000000000..bd2637a7ade --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml @@ -0,0 +1,16 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/main/schemas/v1.0/azure.yaml.json +name: swaenvtest +metadata: + template: azd-test/swaenvtest@v1 +services: + web: + project: . + language: js + host: appservice + # Service-level environment variables passed to build tools + env: + # Reference azd environment variables using ${VAR_NAME} syntax + VITE_API_URL: ${TEST_API_URL} + VITE_APP_NAME: ${TEST_APP_NAME} + # Static value + VITE_BUILD_ENV: "test" diff --git a/cli/azd/test/functional/testdata/samples/swaenvtest/build.js b/cli/azd/test/functional/testdata/samples/swaenvtest/build.js new file mode 100644 index 00000000000..faae169a4b7 --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/swaenvtest/build.js @@ -0,0 +1,29 @@ +// Simple build script that outputs environment variables to a file for testing +// This simulates what Vite would do with VITE_* environment variables +import { writeFileSync, mkdirSync, existsSync } from 'fs'; +import { join, dirname } from 'path'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = dirname(__filename); + +// Collect all VITE_* environment variables +const viteEnvVars = {}; +for (const [key, value] of Object.entries(process.env)) { + if (key.startsWith('VITE_')) { + viteEnvVars[key] = value; + } +} + +// Create dist directory if it doesn't exist +const distDir = join(__dirname, 'dist'); +if (!existsSync(distDir)) { + mkdirSync(distDir, { recursive: true }); +} + +// Write environment variables to a JSON file for verification +const outputPath = join(distDir, 'env-output.json'); +writeFileSync(outputPath, JSON.stringify(viteEnvVars, null, 2)); + +console.log('Build completed. Environment variables written to dist/env-output.json'); +console.log('VITE_* environment variables found:', Object.keys(viteEnvVars)); diff --git a/cli/azd/test/functional/testdata/samples/swaenvtest/package-lock.json b/cli/azd/test/functional/testdata/samples/swaenvtest/package-lock.json new file mode 100644 index 00000000000..9c56f47d645 --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/swaenvtest/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "swaenvtest", + "version": "1.0.0", + "lockfileVersion": 2, + "requires": true, + "packages": { + "": { + "name": "swaenvtest", + "version": "1.0.0", + "license": "MIT" + } + } +} diff --git a/cli/azd/test/functional/testdata/samples/swaenvtest/package.json b/cli/azd/test/functional/testdata/samples/swaenvtest/package.json new file mode 100644 index 00000000000..39fe1ccc50e --- /dev/null +++ b/cli/azd/test/functional/testdata/samples/swaenvtest/package.json @@ -0,0 +1,12 @@ +{ + "name": "swaenvtest", + "version": "1.0.0", + "description": "Test SWA app for validating service environment variables", + "type": "module", + "scripts": { + "build": "node build.js" + }, + "author": "", + "license": "MIT", + "devDependencies": {} +} From 90fd39911ecbdf2c36fc7c35f4d56450a2cd8042 Mon Sep 17 00:00:00 2001 From: Wallace Breza Date: Tue, 20 Jan 2026 09:35:27 -0800 Subject: [PATCH 3/3] Addresses PR feedback --- .../pkg/project/framework_service_maven.go | 6 +- cli/azd/pkg/project/service_config_test.go | 127 ++++++++++++++++++ cli/azd/pkg/tools/dotnet/dotnet.go | 6 + .../testdata/samples/swaenvtest/azure.yaml | 2 +- 4 files changed, 137 insertions(+), 4 deletions(-) diff --git a/cli/azd/pkg/project/framework_service_maven.go b/cli/azd/pkg/project/framework_service_maven.go index 8ed8ea96e88..4b839fd0e6a 100644 --- a/cli/azd/pkg/project/framework_service_maven.go +++ b/cli/azd/pkg/project/framework_service_maven.go @@ -72,7 +72,7 @@ func (m *mavenProject) Restore( env, err := serviceConfig.ExpandEnv(m.env.Getenv) if err != nil { - return nil, fmt.Errorf("getting service environment variables: %w", err) + return nil, fmt.Errorf("expanding service environment variables: %w", err) } if err := m.mavenCli.ResolveDependencies(ctx, serviceConfig.Path(), env); err != nil { @@ -107,7 +107,7 @@ func (m *mavenProject) Build( env, err := serviceConfig.ExpandEnv(m.env.Getenv) if err != nil { - return nil, fmt.Errorf("getting service environment variables: %w", err) + return nil, fmt.Errorf("expanding service environment variables: %w", err) } if err := m.mavenCli.Compile(ctx, serviceConfig.Path(), env); err != nil { @@ -140,7 +140,7 @@ func (m *mavenProject) Package( env, err := serviceConfig.ExpandEnv(m.env.Getenv) if err != nil { - return nil, fmt.Errorf("getting service environment variables: %w", err) + return nil, fmt.Errorf("expanding service environment variables: %w", err) } if err := m.mavenCli.Package(ctx, serviceConfig.Path(), env); err != nil { diff --git a/cli/azd/pkg/project/service_config_test.go b/cli/azd/pkg/project/service_config_test.go index 03496bfbbdb..5a9fa9f3297 100644 --- a/cli/azd/pkg/project/service_config_test.go +++ b/cli/azd/pkg/project/service_config_test.go @@ -7,9 +7,11 @@ import ( "context" "errors" "path/filepath" + "strings" "testing" "github.com/azure/azure-dev/cli/azd/pkg/ext" + "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/test/mocks" "github.com/stretchr/testify/require" ) @@ -326,3 +328,128 @@ func createTestServiceConfig(path string, host ServiceTargetKind, language Servi EventDispatcher: ext.NewEventDispatcher[ServiceLifecycleEventArgs](), } } + +func TestExpandEnv_EmptyEnvironment(t *testing.T) { + service := &ServiceConfig{ + Name: "api", + Environment: nil, + } + + env, err := service.ExpandEnv(func(key string) string { + return "" + }) + + require.NoError(t, err) + require.Nil(t, env) +} + +func TestExpandEnv_ExpandsVariables(t *testing.T) { + service := &ServiceConfig{ + Name: "api", + Environment: osutil.ExpandableMap{ + "API_URL": osutil.NewExpandableString("${TEST_URL}"), + "APP_NAME": osutil.NewExpandableString("${TEST_NAME}"), + "STATIC": osutil.NewExpandableString("static-value"), + }, + } + + lookup := func(key string) string { + switch key { + case "TEST_URL": + return "https://example.com" + case "TEST_NAME": + return "my-app" + default: + return "" + } + } + + env, err := service.ExpandEnv(lookup) + + require.NoError(t, err) + require.NotNil(t, env) + + // Check that service variables are present in the result + found := make(map[string]string) + for _, e := range env { + parts := strings.SplitN(e, "=", 2) + if len(parts) == 2 { + found[parts[0]] = parts[1] + } + } + + require.Equal(t, "https://example.com", found["API_URL"]) + require.Equal(t, "my-app", found["APP_NAME"]) + require.Equal(t, "static-value", found["STATIC"]) +} + +func TestExpandEnv_ServiceVarsTakePrecedence(t *testing.T) { + service := &ServiceConfig{ + Name: "api", + Environment: osutil.ExpandableMap{ + "PATH": osutil.NewExpandableString("custom-path"), + }, + } + + env, err := service.ExpandEnv(func(key string) string { + return "" + }) + + require.NoError(t, err) + require.NotNil(t, env) + + // Find the last occurrence of PATH - service vars are appended so they take precedence + var lastPathValue string + for _, e := range env { + if strings.HasPrefix(e, "PATH=") { + lastPathValue = strings.TrimPrefix(e, "PATH=") + } + } + + require.Equal(t, "custom-path", lastPathValue) +} + +func TestExpandEnv_MergesWithOSEnvironment(t *testing.T) { + service := &ServiceConfig{ + Name: "api", + Environment: osutil.ExpandableMap{ + "CUSTOM_VAR": osutil.NewExpandableString("custom-value"), + }, + } + + env, err := service.ExpandEnv(func(key string) string { + return "" + }) + + require.NoError(t, err) + require.NotNil(t, env) + + // Should have at least the OS environment variables plus the custom one + require.Greater(t, len(env), 1) + + // Check custom var is present + found := false + for _, e := range env { + if e == "CUSTOM_VAR=custom-value" { + found = true + break + } + } + require.True(t, found, "CUSTOM_VAR should be present in environment") +} + +func TestExpandEnv_PropagatesErrors(t *testing.T) { + service := &ServiceConfig{ + Name: "api", + Environment: osutil.ExpandableMap{ + // Invalid syntax that should cause an error + "BAD_VAR": osutil.NewExpandableString("${UNCLOSED"), + }, + } + + _, err := service.ExpandEnv(func(key string) string { + return "value" + }) + + require.Error(t, err) +} diff --git a/cli/azd/pkg/tools/dotnet/dotnet.go b/cli/azd/pkg/tools/dotnet/dotnet.go index 7eeabc22100..75272674bf2 100644 --- a/cli/azd/pkg/tools/dotnet/dotnet.go +++ b/cli/azd/pkg/tools/dotnet/dotnet.go @@ -90,6 +90,8 @@ func (cli *Cli) CheckInstalled(ctx context.Context) error { return nil } +// Restore runs dotnet restore on the specified project. +// Optional env parameter allows passing additional environment variables to the dotnet process. func (cli *Cli) Restore(ctx context.Context, project string, env []string) error { runArgs := newDotNetRunArgs("restore", project) if len(env) > 0 { @@ -102,6 +104,8 @@ func (cli *Cli) Restore(ctx context.Context, project string, env []string) error return nil } +// Build runs dotnet build on the specified project. +// Optional env parameter allows passing additional environment variables to the dotnet process. func (cli *Cli) Build(ctx context.Context, project string, configuration string, output string, env []string) error { runArgs := newDotNetRunArgs("build", project) if configuration != "" { @@ -123,6 +127,8 @@ func (cli *Cli) Build(ctx context.Context, project string, configuration string, return nil } +// Publish runs dotnet publish on the specified project. +// Optional env parameter allows passing additional environment variables to the dotnet process. func (cli *Cli) Publish(ctx context.Context, project string, configuration string, output string, env []string) error { runArgs := newDotNetRunArgs("publish", project) if configuration != "" { diff --git a/cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml b/cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml index bd2637a7ade..c4895c1484f 100644 --- a/cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml +++ b/cli/azd/test/functional/testdata/samples/swaenvtest/azure.yaml @@ -6,7 +6,7 @@ services: web: project: . language: js - host: appservice + host: staticwebapp # Service-level environment variables passed to build tools env: # Reference azd environment variables using ${VAR_NAME} syntax