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
12 changes: 4 additions & 8 deletions cli/azd/internal/grpcserver/external_provisioning_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -334,18 +334,14 @@ func (p *ExternalProvisioningProvider) PlannedOutputs(
func convertToProtoOptions(
options provisioning.Options,
) (*azdext.ProvisioningOptions, error) {
deploymentStacks := make(
map[string]string, len(options.DeploymentStacks),
)
for k, v := range options.DeploymentStacks {
deploymentStacks[k] = fmt.Sprintf("%v", v)
}

// deploymentStacks is intentionally not forwarded to external providers: it configures the
// Azure Deployment Stacks control-plane request and is only valid for the built-in Bicep
// provider. The proto field (deployment_stacks) is retained for wire compatibility but left
// empty for extension providers.
protoOptions := &azdext.ProvisioningOptions{
Provider: string(options.Provider),
Path: options.Path,
Module: options.Module,
DeploymentStacks: deploymentStacks,
IgnoreDeploymentState: options.IgnoreDeploymentState,
Name: options.Name,
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/azdext"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/prompt"
"github.com/azure/azure-dev/cli/azd/test/mocks/mockinput"
)

func Test_convertToProtoOptions(t *testing.T) {
Expand All @@ -35,10 +36,8 @@ func Test_convertToProtoOptions(t *testing.T) {
Path: "/infra",
Module: "main",
Name: "layer1",
DeploymentStacks: map[string]any{
"stackName": "my-stack",
"intVal": 42,
"boolVal": true,
DeploymentStacks: &provisioning.DeploymentStacksConfig{
DenySettings: &provisioning.DenySettingsConfig{Mode: "denyDelete"},
},
IgnoreDeploymentState: true,
Config: map[string]any{
Expand All @@ -56,9 +55,8 @@ func Test_convertToProtoOptions(t *testing.T) {
assert.Equal(t, "main", result.Module)
assert.Equal(t, "layer1", result.Name)
assert.True(t, result.IgnoreDeploymentState)
assert.Equal(t, "my-stack", result.DeploymentStacks["stackName"])
assert.Equal(t, "42", result.DeploymentStacks["intVal"])
assert.Equal(t, "true", result.DeploymentStacks["boolVal"])
// deploymentStacks is bicep-only and intentionally not forwarded to extensions.
assert.Empty(t, result.DeploymentStacks)
require.NotNil(t, result.Config)
assert.Equal(t, "value1", result.Config.Fields["key1"].GetStringValue())
nested := result.Config.Fields["nested"].GetStructValue()
Expand Down Expand Up @@ -124,6 +122,35 @@ func Test_convertToProtoOptions(t *testing.T) {
}
}

// Test_ExternalProvisioningProvider_NameAndProgress covers the broker-independent surface of the
// provider: the DI factory, Name(), and the reportProgress/stopProgress spinner helpers (including
// their nil-console fallbacks). The broker-backed forwarders (Deploy/Preview/Destroy/etc.) require
// a live gRPC broker and are tracked separately in issue #7480.
func Test_ExternalProvisioningProvider_NameAndProgress(t *testing.T) {
factory := NewExternalProvisioningProviderFactory("my-provider", nil, nil)

t.Run("Name returns provider name", func(t *testing.T) {
provider := factory(mockinput.NewMockConsole())
assert.Equal(t, "my-provider", provider.Name())
})

t.Run("progress with console", func(t *testing.T) {
provider := factory(mockinput.NewMockConsole()).(*ExternalProvisioningProvider)
// Should not panic and should route through the console spinner.
provider.reportProgress(context.Background(), "deploying")

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

context.Background() here and on 141-142 and 148-149. This package uses t.Context() almost everywhere (335 occurrences against 6), AGENTS.md calls it out, and the other tests added in this PR use it, so these stand out.

Separately, this test covers the DI factory and the progress spinner helpers, neither of which this PR touches. No harm in keeping it, but it's unrelated to the ${VAR} change despite landing in the "raise coverage for deployment stacks changes" commit.

provider.stopProgress(context.Background(), nil)
provider.stopProgress(context.Background(), assert.AnError)
})

t.Run("progress with nil console", func(t *testing.T) {
provider := &ExternalProvisioningProvider{providerName: "no-console"}
// Falls back to a debug log / no-op without a console.
provider.reportProgress(context.Background(), "deploying")
provider.stopProgress(context.Background(), nil)
assert.Equal(t, "no-console", provider.Name())
})
}

func Test_convertFromProtoStateResult(t *testing.T) {
tests := []struct {
name string
Expand Down
13 changes: 10 additions & 3 deletions cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -833,7 +833,14 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult,
logDS("%s", parametersHashErr.Error())
}

if !p.ignoreDeploymentState && parametersHashErr == nil {
useDeploymentState := p.useDeploymentStateShortcut(parametersHashErr)
if !useDeploymentState && !p.ignoreDeploymentState && parametersHashErr == nil {
// The only remaining reason the shortcut is disabled here is an active deployment-stacks
// configuration; surface it in the deployment-stacks debug log.
logDS("deployment stacks configuration present; bypassing deployment-state shortcut")
}

if useDeploymentState {
deploymentState, stateErr := p.deploymentState(ctx, planned, deployment, currentParamsHash)
if stateErr == nil {
// As a heuristic, we also check the existence of all resource groups
Expand Down Expand Up @@ -898,7 +905,7 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult,
deploymentTags[azure.TagKeyAzdDeploymentStateParamHashName] = new(currentParamsHash)
}

optionsMap, err := convert.ToMap(p.options)
optionsMap, err := p.deploymentOptionsMap(true)
Comment thread
vhvb1989 marked this conversation as resolved.
if err != nil {
return nil, err
}
Expand Down Expand Up @@ -1660,7 +1667,7 @@ func (p *BicepProvider) destroyDeployment(
p.console.StopSpinner(ctx, progressMessage.Message, input.StepFailed)
}
}, func(progress *async.Progress[azapi.DeleteDeploymentProgress]) error {
optionsMap, err := convert.ToMap(p.options)
optionsMap, err := p.deploymentOptionsMap(false)
if err != nil {
return err
}
Expand Down
198 changes: 198 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/deployment_stacks.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package bicep

import (
"fmt"
"strings"

"github.com/azure/azure-dev/cli/azd/pkg/alpha"
"github.com/azure/azure-dev/cli/azd/pkg/azapi"
"github.com/azure/azure-dev/cli/azd/pkg/convert"
"github.com/azure/azure-dev/cli/azd/pkg/osutil"
)

// deploymentStacksEnabled reports whether the Deployment Stacks alpha feature is active. This
// mirrors the deployment-service selection in cmd/container.go: only when the feature is enabled
// is the stacks deployment service used (and the deployment-stacks options actually consumed).
func (p *BicepProvider) deploymentStacksEnabled() bool {
var featureManager *alpha.FeatureManager
if err := p.serviceLocator.Resolve(&featureManager); err != nil {
return false
}

return featureManager.IsEnabled(azapi.FeatureDeploymentStacks)
}

// hasActiveDeploymentStacksConfig reports whether an effective deployment-stacks configuration is
// present (config supplied AND the alpha feature enabled). When true, the deployment-state shortcut
// must be bypassed: the stacks deny/unmanage settings — including ${VAR}-resolved deny lists — can
// change independently of the ARM template and parameters that the state hash covers. Skipping the
// deployment would otherwise leave stale deny settings on the stack and would also bypass the
// ${VAR} resolution/validation performed while building the options map.
func (p *BicepProvider) hasActiveDeploymentStacksConfig() bool {
return p.options.DeploymentStacks != nil && p.deploymentStacksEnabled()

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This gate cuts both ways and I don't think either direction lands right.

Too broad: any project with a deploymentStacks block and the alpha feature on loses the deployment-state shortcut entirely, so every azd provision re-deploys even when template, parameters and deny settings are all unchanged. That's a user-visible behavior change for stacks users who never touch ${VAR}, and it isn't called out in the PR description.

Too narrow: as the other comment on this line points out, dropping the deploymentStacks block from azure.yaml makes this return false, so the shortcut kicks back in and the old deny assignment stays on the stack.

Folding the resolved stacks map into currentParamsHash covers both. Removing the block changes the hash so the stale-deny case redeploys, and an unchanged config keeps the shortcut instead of forcing a redeploy on every run.

}

// useDeploymentStateShortcut reports whether the deployment-state shortcut (which skips a
// redeploy when the template and parameters are unchanged) may be used. It returns false when
// deployment-state tracking is disabled (--no-state), when the parameters hash could not be
// computed, or when an active deployment-stacks configuration is present. In the stacks case the
// deny/unmanage settings — including ${VAR}-resolved deny lists — can change independently of the
// template+parameters the state hash covers, so the shortcut must be bypassed to avoid leaving
// stale settings on the stack and to preserve ${VAR} resolution/validation.
func (p *BicepProvider) useDeploymentStateShortcut(parametersHashErr error) bool {
if p.ignoreDeploymentState || parametersHashErr != nil {
return false
}

return !p.hasActiveDeploymentStacksConfig()
}

// resolveDeploymentStacksMap resolves the typed deployment-stacks configuration into a
// camelCase map[string]any consumable by the deployment-stacks API layer
// (azapi.parseDeploymentStackOptions). It performs ${VAR} environment-variable substitution
// on denySettings.excludedPrincipals and denySettings.excludedActions, resolving values from
// plan-time layer outputs (VirtualEnv) first and then the azd environment.
//
// When includeDenySettings is false the denySettings block is omitted entirely (and therefore
// not resolved). The stack delete APIs only consume actionOnUnmanage and the bypass flag, so the
// destroy path passes false to avoid failing `azd down` when a ${VAR} referenced only by the deny
// lists is no longer available.
//
// It returns nil when no deployment-stacks configuration is present, in which case the caller
// should omit the DeploymentStacks key entirely so the API layer applies its defaults.
func (p *BicepProvider) resolveDeploymentStacksMap(includeDenySettings bool) (map[string]any, error) {
cfg := p.options.DeploymentStacks
if cfg == nil {
return nil, nil
}

result := map[string]any{}

if cfg.ActionOnUnmanage != nil {
actionOnUnmanage := map[string]any{}
if cfg.ActionOnUnmanage.Resources != "" {
actionOnUnmanage["resources"] = cfg.ActionOnUnmanage.Resources
}
if cfg.ActionOnUnmanage.ResourceGroups != "" {
actionOnUnmanage["resourceGroups"] = cfg.ActionOnUnmanage.ResourceGroups
}
if cfg.ActionOnUnmanage.ManagementGroups != "" {
actionOnUnmanage["managementGroups"] = cfg.ActionOnUnmanage.ManagementGroups
}
result["actionOnUnmanage"] = actionOnUnmanage
}

if includeDenySettings && cfg.DenySettings != nil {
denySettings := map[string]any{}
if cfg.DenySettings.Mode != "" {
denySettings["mode"] = cfg.DenySettings.Mode
}
if cfg.DenySettings.ApplyToChildScopes != nil {
denySettings["applyToChildScopes"] = *cfg.DenySettings.ApplyToChildScopes
}

excludedActions, err := p.resolveDeploymentStacksValues(cfg.DenySettings.ExcludedActions)
if err != nil {
return nil, err
}
if excludedActions != nil {
denySettings["excludedActions"] = excludedActions
}

excludedPrincipals, err := p.resolveDeploymentStacksValues(cfg.DenySettings.ExcludedPrincipals)
if err != nil {
return nil, err
}
if excludedPrincipals != nil {
denySettings["excludedPrincipals"] = excludedPrincipals
}

result["denySettings"] = denySettings
}

return result, nil
}

// resolveDeploymentStacksValues evaluates ${VAR} references in each value, resolving from the
// plan-time layer outputs (VirtualEnv) first and then the azd environment. A value that resolves
// to an empty string is treated as a misconfiguration (a blank deny-list entry, for example an
// empty excluded principal, is almost always a mistake) and returns an error. Shell-style default
// expressions such as ${VAR:-fallback} are honored: Envsubst applies the default before this check,
// so a reference with a usable default yields a non-blank value and is accepted.
func (p *BicepProvider) resolveDeploymentStacksValues(values []osutil.ExpandableString) ([]string, error) {
if len(values) == 0 {
return nil, nil
}

resolved := make([]string, 0, len(values))
for _, value := range values {
var lookedUpEmpty []string
substituted, err := value.Envsubst(func(name string) string {
if p.options.VirtualEnv != nil {
if v, has := p.options.VirtualEnv[name]; has {
return v
}
}

v, ok := p.env.LookupEnv(name)
if !ok || v == "" {
lookedUpEmpty = append(lookedUpEmpty, name)
}
return v
})
if err != nil {
return nil, fmt.Errorf("resolving deploymentStacks value: %w", err)
}

if strings.TrimSpace(substituted) == "" {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The unset-variable check only fires when the entire value trims to empty, so partial resolution slips through. Microsoft.Authorization/${UNSET_SCOPE}/write resolves to Microsoft.Authorization//write and goes to ARM, even though lookedUpEmpty recorded UNSET_SCOPE.

For excludedPrincipals the usual shape is a bare ${VAR} so this rarely bites, but excludedActions entries are more likely to be composite.

If you tighten it, len(lookedUpEmpty) > 0 on its own isn't a safe condition. The callback also records the name for ${VAR:-fallback} before Envsubst applies the default, so keying off it directly would break TestResolveDeploymentStacksMap_DefaultExpression.

if len(lookedUpEmpty) > 0 {
return nil, fmt.Errorf(
"deploymentStacks references unset environment variable(s): %s",
strings.Join(lookedUpEmpty, ", "))
}
return nil, fmt.Errorf("deploymentStacks value resolved to an empty string")
}

resolved = append(resolved, substituted)
}

return resolved, nil
}

// deploymentOptionsMap builds the generic options map handed to the deployment API layer,
// with the deployment-stacks configuration resolved (including ${VAR} substitution). Standard
// (non-stack) deployments ignore this map; stack deployments read only the DeploymentStacks key.
//
// Resolution is skipped entirely when the Deployment Stacks alpha feature is inactive, so an
// otherwise-valid standard provision can't be failed by an unavailable ${VAR} in an inactive
// deploymentStacks block. includeDenySettings is false on the destroy path, where the deny lists
// are not consumed by the stack delete APIs.
func (p *BicepProvider) deploymentOptionsMap(includeDenySettings bool) (map[string]any, error) {
optionsMap, err := convert.ToMap(p.options)
if err != nil {
return nil, err
}

// The typed DeploymentStacksConfig is not JSON-serializable in a form the API layer can
// consume (ExpandableString has no exported fields), so always drop whatever convert.ToMap
// produced for it and only re-add a resolved map when stacks are actually in play.
delete(optionsMap, "DeploymentStacks")

if !p.deploymentStacksEnabled() {
return optionsMap, nil
}

stacks, err := p.resolveDeploymentStacksMap(includeDenySettings)
if err != nil {
return nil, err
}

if stacks != nil {
optionsMap["DeploymentStacks"] = stacks
}

return optionsMap, nil
}
Loading
Loading