From f64ddafd9d02639fa96103531bcc3a3e625e03c5 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Mon, 23 Mar 2026 16:44:34 -0700 Subject: [PATCH 1/2] Support virtual layer outputs during provisioning parameter planning --- cli/azd/cmd/pipeline.go | 20 ++- cli/azd/pkg/devcenter/provision_provider.go | 5 + .../provisioning/bicep/bicep_provider.go | 60 ++++++++- .../provisioning/bicep/bicep_provider_test.go | 122 ++++++++++++++++++ cli/azd/pkg/infra/provisioning/manager.go | 8 ++ cli/azd/pkg/infra/provisioning/provider.go | 13 ++ .../terraform/terraform_provider.go | 5 + .../infra/provisioning/test/test_provider.go | 5 + 8 files changed, 234 insertions(+), 4 deletions(-) diff --git a/cli/azd/cmd/pipeline.go b/cli/azd/cmd/pipeline.go index 1bd591f2c13..55bed4da11d 100644 --- a/cli/azd/cmd/pipeline.go +++ b/cli/azd/cmd/pipeline.go @@ -177,19 +177,37 @@ func (p *pipelineConfigAction) Run(ctx context.Context) (*actions.ActionResult, layers := infra.Options.GetLayers() allParameters := []provisioning.Parameter{} + // virtualEnv contains all accumulated outputs from previous layers + virtualEnv := map[string]string{} + for _, layer := range layers { + if len(layers) > 1 { + // update current environment with accumulated outputs + layer.VirtualEnv = virtualEnv + } + err = p.provisioningManager.Initialize(ctx, p.projectConfig.Path, layer) if err != nil { return nil, err } - // Pull provider specific parameters providerParameters, err := p.provisioningManager.Parameters(ctx) if err != nil { return nil, fmt.Errorf("failed to get parameters for provider %s: %w", pipelineProviderName, err) } allParameters = append(allParameters, providerParameters...) + + outputs, err := p.provisioningManager.PlannedOutputs(ctx) + if err != nil { + return nil, fmt.Errorf("failed to get outputs for provider %s: %w", pipelineProviderName, err) + } + + // Save current outputs + for _, output := range outputs { + // save a dummy value that is easily looked at + virtualEnv[output.Name] = fmt.Sprintf("%s--%s", layer.Name, output.Name) + } } p.manager.SetParameters(allParameters) diff --git a/cli/azd/pkg/devcenter/provision_provider.go b/cli/azd/pkg/devcenter/provision_provider.go index 4428f712ad3..02ba6b658be 100644 --- a/cli/azd/pkg/devcenter/provision_provider.go +++ b/cli/azd/pkg/devcenter/provision_provider.go @@ -536,3 +536,8 @@ func (p *ProvisionProvider) Parameters(ctx context.Context) ([]provisioning.Para // not supported (no-op) return nil, nil } + +func (p *ProvisionProvider) PlannedOutputs(ctx context.Context) ([]provisioning.PlannedOutput, error) { + // not supported (no-op) + return nil, nil +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 8f00c63a7ca..00f837eafcc 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -1726,11 +1726,18 @@ type loadParametersResult struct { // This information is useful for setting a CI/CD automatically. Each env var // will be set to the value of the parameter as variable or secret. envMapping map[string][]string + + // parameters that should be considered virtual, i.e. their values are only + // known at runtime. + // + // These parameters should not be prompted and treated as they have values. + virtualMapping map[string]struct{} } // envSubstResult contains the results of environment variable substitution type envSubstResult struct { hasUnsetEnvVar bool + hasVirtualEnvVar bool mappedEnvVars []string parametersMappedToAzureLocation []string } @@ -1743,6 +1750,7 @@ func evalParamEnvSubst( principalType string, paramName string, env *environment.Environment, + virtualEnv map[string]string, ) (string, envSubstResult, error) { result := envSubstResult{} @@ -1759,10 +1767,19 @@ func evalParamEnvSubst( // principalId and locations are intentionally excluded from the mapped env vars as // they are global env vars result.mappedEnvVars = append(result.mappedEnvVars, name) - if _, isDefined := env.LookupEnv(name); !isDefined { + + if virtualEnv != nil { + if value, has := virtualEnv[name]; has { + result.hasVirtualEnvVar = true + return value + } + } + + value, isDefined := env.LookupEnv(name) + if !isDefined { result.hasUnsetEnvVar = true } - return env.Getenv(name) + return value }) return replaced, result, err } @@ -1816,6 +1833,7 @@ func (p *BicepProvider) loadParameters(ctx context.Context, template *azure.ArmT parametersMappedToAzureLocation := []string{} resolvedParams := map[string]azure.ArmParameter{} envMapping := map[string][]string{} + virtualMapping := map[string]struct{}{} // resolving each parameter to keep track of the name during the resolution. // We used to resolve all the file before, supporting env var substitution at any part of the file. @@ -1843,6 +1861,7 @@ func (p *BicepProvider) loadParameters(ctx context.Context, template *azure.ArmT string(principalType), paramName, p.env, + p.options.VirtualEnv, ) if err != nil { return loadParametersResult{}, err @@ -1852,6 +1871,12 @@ func (p *BicepProvider) loadParameters(ctx context.Context, template *azure.ArmT parametersMappedToAzureLocation = append( parametersMappedToAzureLocation, substResult.parametersMappedToAzureLocation...) + if substResult.hasVirtualEnvVar { + // Record the parameter as virtual, skip further processing + virtualMapping[paramName] = struct{}{} + continue + } + // Omit unset parameters if replaced == "" && substResult.hasUnsetEnvVar { continue @@ -1897,6 +1922,7 @@ func (p *BicepProvider) loadParameters(ctx context.Context, template *azure.ArmT string(principalType), paramName, p.env, + p.options.VirtualEnv, ) if err != nil { return loadParametersResult{}, fmt.Errorf("substituting environment variables for %s: %w", paramName, err) @@ -1906,12 +1932,17 @@ func (p *BicepProvider) loadParameters(ctx context.Context, template *azure.ArmT parametersMappedToAzureLocation = append( parametersMappedToAzureLocation, substResult.parametersMappedToAzureLocation...) + if substResult.hasVirtualEnvVar { + // Record the parameter as virtual, skip further processing + virtualMapping[paramName] = struct{}{} + continue + } + // resolve command substitutions like `secretOrRandomPassword` replaced, err = p.evalCommandSubstitution(ctx, replaced) if err != nil { return loadParametersResult{}, err } - var resolvedParam azure.ArmParameter if err := json.Unmarshal([]byte(replaced), &resolvedParam); err != nil { return loadParametersResult{}, fmt.Errorf("error unmarshalling Bicep template parameters: %w", err) @@ -1948,6 +1979,7 @@ func (p *BicepProvider) loadParameters(ctx context.Context, template *azure.ArmT parameters: resolvedParams, locationParams: parametersMappedToAzureLocation, envMapping: envMapping, + virtualMapping: virtualMapping, }, nil } @@ -2403,6 +2435,7 @@ func (p *BicepProvider) ensureParameters( } parameters := parametersResult.parameters locationParameters := parametersResult.locationParams + virtualParameters := parametersResult.virtualMapping if len(template.Parameters) == 0 { return azure.ArmParameters{}, nil @@ -2456,6 +2489,11 @@ func (p *BicepProvider) ensureParameters( parameterType := provisioning.ParameterTypeFromArmType(param.Type) azdMetadata, hasMetadata := param.AzdMetadata() + // If a value is marked virtual, we skip configuration entirely + if _, has := virtualParameters[key]; has { + continue + } + // If a value is explicitly configured via a parameters file, use it. if v, has := parameters[key]; has { // Directly pass through Key Vault references without prompting. @@ -2791,3 +2829,19 @@ func (p *BicepProvider) Parameters(ctx context.Context) ([]provisioning.Paramete return provisionParameters, nil } + +func (p *BicepProvider) PlannedOutputs(ctx context.Context) ([]provisioning.PlannedOutput, error) { + compileResult, err := p.compileBicep(ctx) + if err != nil { + return nil, fmt.Errorf("creating template: %w", err) + } + + var outputs []provisioning.PlannedOutput + for key := range compileResult.Template.Outputs { + outputs = append(outputs, provisioning.PlannedOutput{ + Name: key, + }) + } + + return outputs, nil +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go index 1bb52ef0a11..2d55a808344 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -12,6 +12,8 @@ import ( "io" "maps" "net/http" + "os" + "path/filepath" "strings" "testing" "time" @@ -1691,6 +1693,7 @@ func TestArrayParameterViaEnvVarSimple(t *testing.T) { "ServicePrincipal", "testParam", env, + nil, ) require.Nil(t, err) @@ -1701,6 +1704,18 @@ func TestArrayParameterViaEnvVarSimple(t *testing.T) { func createBicepProviderWithEnv( t *testing.T, mockContext *mocks.MockContext, armTemplate azure.ArmTemplate, envVars map[string]string) *BicepProvider { + return createBicepProviderWithEnvAndMode(t, mockContext, armTemplate, envVars, provisioning.ModeDeploy) +} + +func createBicepProviderWithEnvAndMode( + t *testing.T, + mockContext *mocks.MockContext, + armTemplate azure.ArmTemplate, + envVars map[string]string, + mode provisioning.Mode, +) *BicepProvider { + t.Helper() + bicepBytes, _ := json.Marshal(armTemplate) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { @@ -1721,6 +1736,7 @@ func createBicepProviderWithEnv( options := provisioning.Options{ Path: "infra", Module: "main", + Mode: mode, } baseEnvVars := map[string]string{ @@ -1802,6 +1818,7 @@ func TestObjectParameterEnvSubst(t *testing.T) { "ServicePrincipal", "testParam", env, + nil, ) require.Nil(t, err) @@ -1901,6 +1918,7 @@ func TestHelperEvalParamEnvSubst(t *testing.T) { "ServicePrincipal", "testParam", env, + nil, ) require.Nil(t, err) @@ -1910,3 +1928,107 @@ func TestHelperEvalParamEnvSubst(t *testing.T) { require.Contains(t, substResult.mappedEnvVars, "VAR2") require.False(t, substResult.hasUnsetEnvVar) } + +func TestEvalParamEnvSubstUsesVirtualEnv(t *testing.T) { + env := environment.NewWithValues("test-env", map[string]string{}) + virtualKey := "AZD_TEST_VIRTUAL_LAYER_OUTPUT" + virtualValue := "layer1--WEBSITE_URL" + virtualEnv := map[string]string{virtualKey: virtualValue} + + testCases := []struct { + name string + value string + want string + }{ + { + name: "simple substitution", + value: "${AZD_TEST_VIRTUAL_LAYER_OUTPUT}", + want: "layer1--WEBSITE_URL", + }, + { + name: "mixed expression substitution", + value: "prefix-${AZD_TEST_VIRTUAL_LAYER_OUTPUT}-suffix", + want: "prefix-layer1--WEBSITE_URL-suffix", + }, + } + + for _, tc := range testCases { + t.Run(tc.name, func(t *testing.T) { + result, substResult, err := evalParamEnvSubst( + tc.value, + "principal-id", + "ServicePrincipal", + "testParam", + env, + virtualEnv, + ) + + require.NoError(t, err) + require.Equal(t, tc.want, result) + require.True(t, substResult.hasVirtualEnvVar) + require.False(t, substResult.hasUnsetEnvVar) + require.Contains(t, substResult.mappedEnvVars, virtualKey) + }) + } +} + +func TestEnsureParametersSkipsVirtualEnvMappedRequiredParameters(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + armTemplate := azure.ArmTemplate{ + Schema: "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + ContentVersion: "1.0.0.0", + Parameters: azure.ArmTemplateParameterDefinitions{ + "environmentName": {Type: "string", DefaultValue: "test-env"}, + "location": {Type: "string", DefaultValue: "westus2"}, + "dependentValue": {Type: "string"}, + "compositeValue": {Type: "string"}, + }, + Outputs: azure.ArmTemplateOutputs{}, + } + + infraProvider := createBicepProviderWithEnvAndMode( + t, + mockContext, + armTemplate, + map[string]string{}, + provisioning.ModeDestroy, + ) + + tmpInfraDir := filepath.Join(t.TempDir(), "infra") + require.NoError(t, os.MkdirAll(tmpInfraDir, 0o755)) + + const virtualEnvKey = "AZD_TEST_VIRTUAL_LAYER_OUTPUT" + require.NoError(t, os.WriteFile(filepath.Join(tmpInfraDir, "main.parameters.json"), []byte(`{ + "$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentParameters.json#", + "contentVersion": "1.0.0.0", + "parameters": { + "dependentValue": { + "value": "${AZD_TEST_VIRTUAL_LAYER_OUTPUT}" + }, + "compositeValue": { + "value": "prefix-${AZD_TEST_VIRTUAL_LAYER_OUTPUT}-suffix" + } + } + }`), 0o600)) + + infraProvider.path = filepath.Join(tmpInfraDir, "main.bicep") + infraProvider.options.VirtualEnv = map[string]string{ + virtualEnvKey: "layer1--WEBSITE_URL", + } + + compileResult, err := infraProvider.compileBicep(*mockContext.Context) + require.NoError(t, err) + + loadResult, err := infraProvider.loadParameters(*mockContext.Context, &compileResult.Template) + require.NoError(t, err) + require.Contains(t, loadResult.virtualMapping, "dependentValue") + require.Contains(t, loadResult.virtualMapping, "compositeValue") + require.NotContains(t, loadResult.parameters, "dependentValue") + require.NotContains(t, loadResult.parameters, "compositeValue") + + configuredParameters, err := infraProvider.ensureParameters(*mockContext.Context, compileResult.Template) + require.NoError(t, err) + require.NotContains(t, configuredParameters, "dependentValue") + require.NotContains(t, configuredParameters, "compositeValue") +} diff --git a/cli/azd/pkg/infra/provisioning/manager.go b/cli/azd/pkg/infra/provisioning/manager.go index 9f1892b946d..5586341d75b 100644 --- a/cli/azd/pkg/infra/provisioning/manager.go +++ b/cli/azd/pkg/infra/provisioning/manager.go @@ -75,6 +75,14 @@ func (m *Manager) Parameters(ctx context.Context) ([]Parameter, error) { return m.provider.Parameters(ctx) } +// PlannedOutputs returns the list of outputs in the current plan. +func (m *Manager) PlannedOutputs(ctx context.Context) ([]PlannedOutput, error) { + if m.provider == nil { + panic("called PlannedOutputs() with provider not initialized. Make sure to call manager.Initialize() first.") + } + return m.provider.PlannedOutputs(ctx) +} + // Gets the latest deployment details for the specified scope func (m *Manager) State(ctx context.Context, options *StateOptions) (*StateResult, error) { result, err := m.provider.State(ctx, options) diff --git a/cli/azd/pkg/infra/provisioning/provider.go b/cli/azd/pkg/infra/provisioning/provider.go index 4da7dfbe1a8..91be9b8f76e 100644 --- a/cli/azd/pkg/infra/provisioning/provider.go +++ b/cli/azd/pkg/infra/provisioning/provider.go @@ -49,6 +49,11 @@ type Options struct { IgnoreDeploymentState bool `yaml:"-"` // The mode in which the deployment is being run. Mode Mode `yaml:"-"` + // Environment variables that should be considered as resolved when prompting for parameters. + // + // This is used when planning multiple layers, and would be set to plan-time outputs + // from previous layers. + VirtualEnv map[string]string `yaml:"-"` } // GetWithDefaults merges the provided infra options with the default provisioning options @@ -179,6 +184,13 @@ type Parameter struct { UsingEnvVarMapping bool } +// PlannedOutput represents a plan-time output. +// It does not contain the actual output value. +type PlannedOutput struct { + // The name of the planned output + Name string +} + type Provider interface { Name() string Initialize(ctx context.Context, projectPath string, options Options) error @@ -188,4 +200,5 @@ type Provider interface { Destroy(ctx context.Context, options DestroyOptions) (*DestroyResult, error) EnsureEnv(ctx context.Context) error Parameters(ctx context.Context) ([]Parameter, error) + PlannedOutputs(ctx context.Context) ([]PlannedOutput, error) } diff --git a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go index 82eb3e20f42..87718099505 100644 --- a/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go +++ b/cli/azd/pkg/infra/provisioning/terraform/terraform_provider.go @@ -811,3 +811,8 @@ func (t *TerraformProvider) Parameters(ctx context.Context) ([]provisioning.Para // not supported (no-op) return nil, nil } + +func (t *TerraformProvider) PlannedOutputs(ctx context.Context) ([]provisioning.PlannedOutput, error) { + // not supported (no-op) + return nil, nil +} diff --git a/cli/azd/pkg/infra/provisioning/test/test_provider.go b/cli/azd/pkg/infra/provisioning/test/test_provider.go index 332c60dc2d0..e833853a770 100644 --- a/cli/azd/pkg/infra/provisioning/test/test_provider.go +++ b/cli/azd/pkg/infra/provisioning/test/test_provider.go @@ -134,6 +134,11 @@ func (p *TestProvider) Parameters(ctx context.Context) ([]provisioning.Parameter return nil, nil } +func (p *TestProvider) PlannedOutputs(ctx context.Context) ([]provisioning.PlannedOutput, error) { + // not supported (no-op) + return nil, nil +} + func NewTestProvider( envManager environment.Manager, env *environment.Environment, From 5d5b215b29cec78224bbd1463855642d22979020 Mon Sep 17 00:00:00 2001 From: Wei Lim Date: Thu, 26 Mar 2026 14:11:05 -0700 Subject: [PATCH 2/2] address feedback --- cli/azd/cmd/pipeline.go | 72 +++++++++++++------ .../provisioning/bicep/bicep_provider.go | 10 ++- .../provisioning/bicep/bicep_provider_test.go | 32 +++++++++ 3 files changed, 91 insertions(+), 23 deletions(-) diff --git a/cli/azd/cmd/pipeline.go b/cli/azd/cmd/pipeline.go index 55bed4da11d..6a3ef01b27d 100644 --- a/cli/azd/cmd/pipeline.go +++ b/cli/azd/cmd/pipeline.go @@ -177,36 +177,68 @@ func (p *pipelineConfigAction) Run(ctx context.Context) (*actions.ActionResult, layers := infra.Options.GetLayers() allParameters := []provisioning.Parameter{} - // virtualEnv contains all accumulated outputs from previous layers - virtualEnv := map[string]string{} - - for _, layer := range layers { - if len(layers) > 1 { - // update current environment with accumulated outputs - layer.VirtualEnv = virtualEnv - } - + inputParameters := func(layer provisioning.Options) ([]provisioning.Parameter, error) { err = p.provisioningManager.Initialize(ctx, p.projectConfig.Path, layer) if err != nil { - return nil, err + return nil, fmt.Errorf( + "layer %q: failed to initialize infra provider %s: %w", + layer.Name, + layer.Provider, + err, + ) } - providerParameters, err := p.provisioningManager.Parameters(ctx) + parameters, err := p.provisioningManager.Parameters(ctx) if err != nil { - return nil, fmt.Errorf("failed to get parameters for provider %s: %w", pipelineProviderName, err) + return nil, fmt.Errorf( + "layer %q: failed to get parameters for infra provider %s: %w", + layer.Name, + layer.Provider, + err, + ) } - allParameters = append(allParameters, providerParameters...) + return parameters, nil + } - outputs, err := p.provisioningManager.PlannedOutputs(ctx) - if err != nil { - return nil, fmt.Errorf("failed to get outputs for provider %s: %w", pipelineProviderName, err) + if len(layers) <= 1 { + for _, layer := range layers { + parameters, err := inputParameters(layer) + if err != nil { + return nil, err + } + + allParameters = append(allParameters, parameters...) } + } else { + // virtualEnv contains all accumulated outputs from previous layers. + virtualEnv := map[string]string{} + + for _, layer := range layers { + layer.VirtualEnv = virtualEnv + + parameters, err := inputParameters(layer) + if err != nil { + return nil, fmt.Errorf("layer '%s': %w", layer.Name, err) + } + + allParameters = append(allParameters, parameters...) + + outputs, err := p.provisioningManager.PlannedOutputs(ctx) + if err != nil { + return nil, fmt.Errorf( + "layer %q: failed to get planned outputs for infra provider %s: %w", + layer.Name, + layer.Provider, + err, + ) + } - // Save current outputs - for _, output := range outputs { - // save a dummy value that is easily looked at - virtualEnv[output.Name] = fmt.Sprintf("%s--%s", layer.Name, output.Name) + // Save current outputs + for _, output := range outputs { + // save a dummy value that is easily looked at + virtualEnv[output.Name] = fmt.Sprintf("%s--%s", layer.Name, output.Name) + } } } diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 00f837eafcc..bd3b3a656c6 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -1764,8 +1764,8 @@ func evalParamEnvSubst( if name == environment.LocationEnvVarName { result.parametersMappedToAzureLocation = append(result.parametersMappedToAzureLocation, paramName) } - // principalId and locations are intentionally excluded from the mapped env vars as - // they are global env vars + // principalId and principalType are intentionally excluded from the mapped env vars + // as they are global env vars. result.mappedEnvVars = append(result.mappedEnvVars, name) if virtualEnv != nil { @@ -2837,7 +2837,11 @@ func (p *BicepProvider) PlannedOutputs(ctx context.Context) ([]provisioning.Plan } var outputs []provisioning.PlannedOutput - for key := range compileResult.Template.Outputs { + for key, output := range compileResult.Template.Outputs { + if azure.IsSecuredARMType(output.Type) { + continue + } + outputs = append(outputs, provisioning.PlannedOutput{ Name: key, }) diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go index 2d55a808344..60a195b81b9 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -2032,3 +2032,35 @@ func TestEnsureParametersSkipsVirtualEnvMappedRequiredParameters(t *testing.T) { require.NotContains(t, configuredParameters, "dependentValue") require.NotContains(t, configuredParameters, "compositeValue") } + +func TestPlannedOutputsSkipsSecureOutputs(t *testing.T) { + mockContext := mocks.NewMockContext(context.Background()) + + armTemplate := azure.ArmTemplate{ + Schema: "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#", + ContentVersion: "1.0.0.0", + Outputs: azure.ArmTemplateOutputs{ + "publicUrl": { + Type: "string", + }, + "connectionString": { + Type: "secureString", + }, + "config": { + Type: "object", + }, + "secretConfig": { + Type: "secureObject", + }, + }, + } + + infraProvider := createBicepProviderWithEnv(t, mockContext, armTemplate, map[string]string{}) + + outputs, err := infraProvider.PlannedOutputs(*mockContext.Context) + require.NoError(t, err) + require.ElementsMatch(t, []provisioning.PlannedOutput{ + {Name: "publicUrl"}, + {Name: "config"}, + }, outputs) +}