Skip to content
Draft
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")
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()
}

// 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) == "" {
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