From 428bf2ae0d83198308fa11fd1caccb90ffdea762 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 23 Mar 2026 10:54:16 -0400 Subject: [PATCH 01/11] Detect active deployments before provisioning (#7248) Before starting a Bicep deployment, check the target scope for in-progress ARM deployments and wait for them to complete. This avoids the DeploymentActive error that ARM returns after ~5 minutes when a concurrent deployment is already running on the same resource group. Changes: - Add IsActiveDeploymentState() helper in azapi to classify provisioning states as active or terminal. - Add ListActiveDeployments() to the infra.Scope interface and both ResourceGroupScope / SubscriptionScope implementations. - Add waitForActiveDeployments() in the Bicep provider, called after preflight validation and before deployment submission. It polls until active deployments clear or a 30-minute timeout is reached. - Add a DeploymentActive error suggestion rule to error_suggestions.yaml. - Add unit tests for state classification, polling, timeout, error handling, and context cancellation. Fixes #7248 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/azapi/deployment_state_test.go | 49 +++++ cli/azd/pkg/azapi/deployments.go | 22 ++ .../bicep/active_deployment_check_test.go | 193 ++++++++++++++++++ .../provisioning/bicep/bicep_provider.go | 92 +++++++++ .../provisioning/bicep/bicep_provider_test.go | 4 + cli/azd/pkg/infra/scope.go | 42 ++++ cli/azd/resources/error_suggestions.yaml | 12 ++ 7 files changed, 414 insertions(+) create mode 100644 cli/azd/pkg/azapi/deployment_state_test.go create mode 100644 cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go diff --git a/cli/azd/pkg/azapi/deployment_state_test.go b/cli/azd/pkg/azapi/deployment_state_test.go new file mode 100644 index 00000000000..ae100b98924 --- /dev/null +++ b/cli/azd/pkg/azapi/deployment_state_test.go @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azapi + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestIsActiveDeploymentState(t *testing.T) { + active := []DeploymentProvisioningState{ + DeploymentProvisioningStateAccepted, + DeploymentProvisioningStateCanceling, + DeploymentProvisioningStateCreating, + DeploymentProvisioningStateDeleting, + DeploymentProvisioningStateDeletingResources, + DeploymentProvisioningStateDeploying, + DeploymentProvisioningStateRunning, + DeploymentProvisioningStateUpdating, + DeploymentProvisioningStateUpdatingDenyAssignments, + DeploymentProvisioningStateValidating, + DeploymentProvisioningStateWaiting, + } + + for _, state := range active { + t.Run(string(state), func(t *testing.T) { + require.True(t, IsActiveDeploymentState(state), + "expected %s to be active", state) + }) + } + + inactive := []DeploymentProvisioningState{ + DeploymentProvisioningStateSucceeded, + DeploymentProvisioningStateFailed, + DeploymentProvisioningStateCanceled, + DeploymentProvisioningStateDeleted, + DeploymentProvisioningStateNotSpecified, + DeploymentProvisioningStateReady, + } + + for _, state := range inactive { + t.Run(string(state), func(t *testing.T) { + require.False(t, IsActiveDeploymentState(state), + "expected %s to be inactive", state) + }) + } +} diff --git a/cli/azd/pkg/azapi/deployments.go b/cli/azd/pkg/azapi/deployments.go index 1e079370a4c..886d1e7c47c 100644 --- a/cli/azd/pkg/azapi/deployments.go +++ b/cli/azd/pkg/azapi/deployments.go @@ -107,6 +107,28 @@ const ( DeploymentProvisioningStateUpdating DeploymentProvisioningState = "Updating" ) +// IsActiveDeploymentState reports whether the given provisioning state +// indicates a deployment that is still in progress, including transitional +// states like canceling or deleting that can still block new deployments. +func IsActiveDeploymentState(state DeploymentProvisioningState) bool { + switch state { + case DeploymentProvisioningStateAccepted, + DeploymentProvisioningStateCanceling, + DeploymentProvisioningStateCreating, + DeploymentProvisioningStateDeleting, + DeploymentProvisioningStateDeletingResources, + DeploymentProvisioningStateDeploying, + DeploymentProvisioningStateRunning, + DeploymentProvisioningStateUpdating, + DeploymentProvisioningStateUpdatingDenyAssignments, + DeploymentProvisioningStateValidating, + DeploymentProvisioningStateWaiting: + return true + default: + return false + } +} + type DeploymentService interface { GenerateDeploymentName(baseName string) string CalculateTemplateHash( diff --git a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go new file mode 100644 index 00000000000..4d608b2a688 --- /dev/null +++ b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go @@ -0,0 +1,193 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package bicep + +import ( + "context" + "fmt" + "sync/atomic" + "testing" + "time" + + "github.com/azure/azure-dev/cli/azd/pkg/azapi" + "github.com/azure/azure-dev/cli/azd/pkg/infra" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/require" +) + +// activeDeploymentScope is a test helper that implements infra.Scope and lets +// the caller control what ListActiveDeployments returns on each call. +type activeDeploymentScope struct { + // calls tracks how many times ListActiveDeployments has been invoked. + calls atomic.Int32 + // activePerCall maps a 0-based call index to the list of active deployments + // returned for that call. If the index is missing, nil is returned. + activePerCall map[int][]*azapi.ResourceDeployment + // errOnCall, if non-nil, maps a call index to an error to return. + errOnCall map[int]error +} + +func (s *activeDeploymentScope) SubscriptionId() string { return "test-sub" } + +func (s *activeDeploymentScope) Deployment(_ string) infra.Deployment { return nil } + +func (s *activeDeploymentScope) ListDeployments( + _ context.Context, +) ([]*azapi.ResourceDeployment, error) { + return nil, nil +} + +func (s *activeDeploymentScope) ListActiveDeployments( + ctx context.Context, +) ([]*azapi.ResourceDeployment, error) { + idx := int(s.calls.Add(1)) - 1 + if s.errOnCall != nil { + if e, ok := s.errOnCall[idx]; ok { + return nil, e + } + } + if s.activePerCall != nil { + return s.activePerCall[idx], nil + } + return nil, nil +} + +// newTestProvider returns a BicepProvider with fast poll settings for tests. +func newTestProvider() *BicepProvider { + return &BicepProvider{ + console: mockinput.NewMockConsole(), + activeDeployPollInterval: 10 * time.Millisecond, + activeDeployTimeout: 2 * time.Second, + } +} + +func TestWaitForActiveDeployments_NoActive(t *testing.T) { + scope := &activeDeploymentScope{} + p := newTestProvider() + + err := p.waitForActiveDeployments(t.Context(), scope) + require.NoError(t, err) + require.Equal(t, int32(1), scope.calls.Load(), + "should call ListActiveDeployments once") +} + +func TestWaitForActiveDeployments_InitialListError_NotFound(t *testing.T) { + scope := &activeDeploymentScope{ + errOnCall: map[int]error{ + 0: fmt.Errorf("listing: %w", infra.ErrDeploymentsNotFound), + }, + } + p := newTestProvider() + + // ErrDeploymentsNotFound (resource group doesn't exist yet) is safe to ignore. + err := p.waitForActiveDeployments(t.Context(), scope) + require.NoError(t, err) +} + +func TestWaitForActiveDeployments_InitialListError_Other(t *testing.T) { + scope := &activeDeploymentScope{ + errOnCall: map[int]error{ + 0: fmt.Errorf("auth failure: access denied"), + }, + } + p := newTestProvider() + + // Non-NotFound errors should propagate so the user knows the check failed. + err := p.waitForActiveDeployments(t.Context(), scope) + require.Error(t, err) + require.Contains(t, err.Error(), "checking for active deployments") +} + +func TestWaitForActiveDeployments_ActiveThenClear(t *testing.T) { + running := []*azapi.ResourceDeployment{ + { + Name: "deploy-1", + ProvisioningState: azapi.DeploymentProvisioningStateRunning, + }, + } + scope := &activeDeploymentScope{ + activePerCall: map[int][]*azapi.ResourceDeployment{ + 0: running, // first call: active + // second call (index 1): missing key → returns nil (no active) + }, + } + p := newTestProvider() + + err := p.waitForActiveDeployments(t.Context(), scope) + require.NoError(t, err) + require.Equal(t, int32(2), scope.calls.Load(), + "should poll once, then see clear") +} + +func TestWaitForActiveDeployments_CancelledContext(t *testing.T) { + ctx, cancel := context.WithCancel(t.Context()) + + running := []*azapi.ResourceDeployment{ + { + Name: "deploy-forever", + ProvisioningState: azapi.DeploymentProvisioningStateRunning, + }, + } + scope := &activeDeploymentScope{ + // Always return active deployments. + activePerCall: map[int][]*azapi.ResourceDeployment{ + 0: running, + }, + } + p := newTestProvider() + + // Cancel immediately so the wait loop exits on the first select. + cancel() + + err := p.waitForActiveDeployments(ctx, scope) + require.ErrorIs(t, err, context.Canceled) +} + +func TestWaitForActiveDeployments_PollError(t *testing.T) { + running := []*azapi.ResourceDeployment{ + { + Name: "deploy-1", + ProvisioningState: azapi.DeploymentProvisioningStateRunning, + }, + } + scope := &activeDeploymentScope{ + activePerCall: map[int][]*azapi.ResourceDeployment{ + 0: running, + }, + errOnCall: map[int]error{ + 1: fmt.Errorf("transient ARM failure"), + }, + } + p := newTestProvider() + + err := p.waitForActiveDeployments(t.Context(), scope) + require.Error(t, err) + require.Contains(t, err.Error(), "transient ARM failure") +} + +func TestWaitForActiveDeployments_Timeout(t *testing.T) { + running := []*azapi.ResourceDeployment{ + { + Name: "stuck-deploy", + ProvisioningState: azapi.DeploymentProvisioningStateRunning, + }, + } + // Return active on every call. + perCall := make(map[int][]*azapi.ResourceDeployment) + for i := range 200 { + perCall[i] = running + } + + scope := &activeDeploymentScope{activePerCall: perCall} + p := &BicepProvider{ + console: mockinput.NewMockConsole(), + activeDeployPollInterval: 5 * time.Millisecond, + activeDeployTimeout: 50 * time.Millisecond, + } + + err := p.waitForActiveDeployments(t.Context(), scope) + require.Error(t, err) + require.Contains(t, err.Error(), "timed out") + require.Contains(t, err.Error(), "stuck-deploy") +} diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 8f00c63a7ca..5bd0e7fdc87 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -91,6 +91,12 @@ type BicepProvider struct { // Internal state // compileBicepResult is cached to avoid recompiling the same bicep file multiple times in the same azd run. compileBicepMemoryCache *compileBicepResult + + // activeDeployPollInterval and activeDeployTimeout override the defaults + // for the active-deployment wait loop. Zero means use the default. These + // are only set in tests. + activeDeployPollInterval time.Duration + activeDeployTimeout time.Duration } // Name gets the name of the infra provider @@ -607,6 +613,87 @@ func logDS(msg string, v ...any) { log.Printf("%s : %s", "deployment-state: ", fmt.Sprintf(msg, v...)) } +const ( + // defaultActiveDeploymentPollInterval is how often we re-check for active deployments. + defaultActiveDeploymentPollInterval = 30 * time.Second + // defaultActiveDeploymentTimeout caps the total wait time for active deployments. + defaultActiveDeploymentTimeout = 30 * time.Minute +) + +// waitForActiveDeployments checks for deployments that are already in progress +// at the target scope. If any are found it logs a warning and polls until they +// finish or the timeout is reached. +func (p *BicepProvider) waitForActiveDeployments( + ctx context.Context, + scope infra.Scope, +) error { + active, err := scope.ListActiveDeployments(ctx) + if err != nil { + // If the resource group doesn't exist yet, there are no active + // deployments — proceed normally. + if errors.Is(err, infra.ErrDeploymentsNotFound) { + return nil + } + // For other errors (auth, throttling, transient), surface them + // so the user knows the pre-check couldn't run. + log.Printf( + "active-deployment-check: unable to list deployments: %v", err) + return fmt.Errorf("checking for active deployments: %w", err) + } + + if len(active) == 0 { + return nil + } + + names := make([]string, len(active)) + for i, d := range active { + names[i] = d.Name + } + p.console.MessageUxItem(ctx, &ux.WarningMessage{ + Description: fmt.Sprintf( + "Waiting for %d active deployment(s) to complete: %s", + len(active), strings.Join(names, ", ")), + }) + + p.console.ShowSpinner(ctx, + "Waiting for active deployment(s) to complete", input.Step) + defer p.console.StopSpinner(ctx, "", input.StepDone) + + pollInterval := p.activeDeployPollInterval + if pollInterval == 0 { + pollInterval = defaultActiveDeploymentPollInterval + } + timeout := p.activeDeployTimeout + if timeout == 0 { + timeout = defaultActiveDeploymentTimeout + } + + deadline := time.After(timeout) + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return ctx.Err() + case <-deadline: + return fmt.Errorf( + "timed out after %s waiting for active "+ + "deployment(s) to complete: %s", + timeout, strings.Join(names, ", ")) + case <-ticker.C: + active, err = scope.ListActiveDeployments(ctx) + if err != nil { + return fmt.Errorf( + "checking active deployments: %w", err) + } + if len(active) == 0 { + return nil + } + } + } +} + // Provisioning the infrastructure within the specified template func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, error) { if p.ignoreDeploymentState { @@ -718,6 +805,11 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, p.console.StopSpinner(ctx, "", input.StepDone) } + // Check for active deployments at the target scope and wait if any are in progress + if err := p.waitForActiveDeployments(ctx, deployment); err != nil { + return nil, err + } + progressCtx, cancelProgress := context.WithCancel(ctx) var wg sync.WaitGroup wg.Add(1) 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..007ac6c6649 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -1085,6 +1085,10 @@ func (m *mockedScope) ListDeployments(ctx context.Context) ([]*azapi.ResourceDep }, nil } +func (m *mockedScope) ListActiveDeployments(ctx context.Context) ([]*azapi.ResourceDeployment, error) { + return nil, nil +} + func TestUserDefinedTypes(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { diff --git a/cli/azd/pkg/infra/scope.go b/cli/azd/pkg/infra/scope.go index 303766d2d95..f7b63d6b74e 100644 --- a/cli/azd/pkg/infra/scope.go +++ b/cli/azd/pkg/infra/scope.go @@ -21,6 +21,8 @@ type Scope interface { SubscriptionId() string // ListDeployments returns all the deployments at this scope. ListDeployments(ctx context.Context) ([]*azapi.ResourceDeployment, error) + // ListActiveDeployments returns only the deployments that are currently in progress. + ListActiveDeployments(ctx context.Context) ([]*azapi.ResourceDeployment, error) Deployment(deploymentName string) Deployment } @@ -228,6 +230,26 @@ func (s *ResourceGroupScope) ListDeployments(ctx context.Context) ([]*azapi.Reso return deployments, err } +// ListActiveDeployments returns only the deployments in this resource group +// that are currently in progress (e.g. Running, Deploying, Accepted). +func (s *ResourceGroupScope) ListActiveDeployments( + ctx context.Context, +) ([]*azapi.ResourceDeployment, error) { + all, err := s.ListDeployments(ctx) + if err != nil { + return nil, err + } + + var active []*azapi.ResourceDeployment + for _, d := range all { + if azapi.IsActiveDeploymentState(d.ProvisioningState) { + active = append(active, d) + } + } + + return active, nil +} + // Deployment gets the deployment with the specified name. func (s *ResourceGroupScope) Deployment(deploymentName string) Deployment { return NewResourceGroupDeployment(s, deploymentName) @@ -381,6 +403,26 @@ func (s *SubscriptionScope) ListDeployments(ctx context.Context) ([]*azapi.Resou return s.deploymentService.ListSubscriptionDeployments(ctx, s.subscriptionId) } +// ListActiveDeployments returns only subscription-scoped deployments +// that are currently in progress (e.g. Running, Deploying, Accepted). +func (s *SubscriptionScope) ListActiveDeployments( + ctx context.Context, +) ([]*azapi.ResourceDeployment, error) { + all, err := s.ListDeployments(ctx) + if err != nil { + return nil, err + } + + var active []*azapi.ResourceDeployment + for _, d := range all { + if azapi.IsActiveDeploymentState(d.ProvisioningState) { + active = append(active, d) + } + } + + return active, nil +} + func newSubscriptionScope( deploymentsService azapi.DeploymentService, subscriptionId string, diff --git a/cli/azd/resources/error_suggestions.yaml b/cli/azd/resources/error_suggestions.yaml index 5dc30847f2f..15d4141ca1c 100644 --- a/cli/azd/resources/error_suggestions.yaml +++ b/cli/azd/resources/error_suggestions.yaml @@ -49,6 +49,18 @@ rules: # 4th most common error category (~128,054 errors in 90-day analysis) # ============================================================================ + - errorType: "DeploymentErrorLine" + properties: + Code: "DeploymentActive" + message: "Another deployment is already in progress on this resource group." + suggestion: > + Wait for the current deployment to complete, then retry. + You can check deployment status in the Azure portal under your + resource group's Deployments blade. + links: + - url: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/error-deployment-active" + title: "Troubleshoot DeploymentActive errors" + - errorType: "DeploymentErrorLine" properties: Code: "FlagMustBeSetForRestore" From c7c54e3144ff069b75e4461c0b04a28539056f3c Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 23 Mar 2026 11:59:28 -0400 Subject: [PATCH 02/11] Retrigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From a8ec3093549b0074a7208082d107c6636d3a0da6 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 23 Mar 2026 12:32:05 -0400 Subject: [PATCH 03/11] Address review: fix range, scope-agnostic wording, extract filter helper, refresh timeout names - Fix 'range 200' compile error (not valid in all Go versions) - Make DeploymentActive YAML rule scope-agnostic - Extract filterActiveDeployments helper to deduplicate scope logic - Refresh deployment names from latest poll on timeout message Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bicep/active_deployment_check_test.go | 2 +- .../provisioning/bicep/bicep_provider.go | 7 ++++++- cli/azd/pkg/infra/scope.go | 19 ++++++++----------- cli/azd/resources/error_suggestions.yaml | 5 ++--- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go index 4d608b2a688..094421932af 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go @@ -175,7 +175,7 @@ func TestWaitForActiveDeployments_Timeout(t *testing.T) { } // Return active on every call. perCall := make(map[int][]*azapi.ResourceDeployment) - for i := range 200 { + for i := 0; i < 200; i++ { perCall[i] = running } diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 5bd0e7fdc87..086271416a6 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -677,10 +677,15 @@ func (p *BicepProvider) waitForActiveDeployments( case <-ctx.Done(): return ctx.Err() case <-deadline: + // Refresh names from latest poll for an accurate timeout message + currentNames := make([]string, len(active)) + for i, d := range active { + currentNames[i] = d.Name + } return fmt.Errorf( "timed out after %s waiting for active "+ "deployment(s) to complete: %s", - timeout, strings.Join(names, ", ")) + timeout, strings.Join(currentNames, ", ")) case <-ticker.C: active, err = scope.ListActiveDeployments(ctx) if err != nil { diff --git a/cli/azd/pkg/infra/scope.go b/cli/azd/pkg/infra/scope.go index f7b63d6b74e..b6b09a87b14 100644 --- a/cli/azd/pkg/infra/scope.go +++ b/cli/azd/pkg/infra/scope.go @@ -240,14 +240,7 @@ func (s *ResourceGroupScope) ListActiveDeployments( return nil, err } - var active []*azapi.ResourceDeployment - for _, d := range all { - if azapi.IsActiveDeploymentState(d.ProvisioningState) { - active = append(active, d) - } - } - - return active, nil + return filterActiveDeployments(all), nil } // Deployment gets the deployment with the specified name. @@ -413,14 +406,18 @@ func (s *SubscriptionScope) ListActiveDeployments( return nil, err } + return filterActiveDeployments(all), nil +} + +// filterActiveDeployments returns only deployments with an active provisioning state. +func filterActiveDeployments(deployments []*azapi.ResourceDeployment) []*azapi.ResourceDeployment { var active []*azapi.ResourceDeployment - for _, d := range all { + for _, d := range deployments { if azapi.IsActiveDeploymentState(d.ProvisioningState) { active = append(active, d) } } - - return active, nil + return active } func newSubscriptionScope( diff --git a/cli/azd/resources/error_suggestions.yaml b/cli/azd/resources/error_suggestions.yaml index 15d4141ca1c..2008a226312 100644 --- a/cli/azd/resources/error_suggestions.yaml +++ b/cli/azd/resources/error_suggestions.yaml @@ -52,11 +52,10 @@ rules: - errorType: "DeploymentErrorLine" properties: Code: "DeploymentActive" - message: "Another deployment is already in progress on this resource group." + message: "Another deployment is already in progress on this scope." suggestion: > Wait for the current deployment to complete, then retry. - You can check deployment status in the Azure portal under your - resource group's Deployments blade. + You can check deployment status in the Azure portal under the Deployments blade. links: - url: "https://learn.microsoft.com/azure/azure-resource-manager/troubleshooting/error-deployment-active" title: "Troubleshoot DeploymentActive errors" From 4b822914bba55bb496db53bc258abce89c2eb820 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 23 Mar 2026 12:45:50 -0400 Subject: [PATCH 04/11] Restore range-over-int (Go 1.26, enforced by go fix) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../infra/provisioning/bicep/active_deployment_check_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go index 094421932af..4d608b2a688 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go @@ -175,7 +175,7 @@ func TestWaitForActiveDeployments_Timeout(t *testing.T) { } // Return active on every call. perCall := make(map[int][]*azapi.ResourceDeployment) - for i := 0; i < 200; i++ { + for i := range 200 { perCall[i] = running } From 176d01d5650576901fcbf66910a8acd7ca9f47dc Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 23 Mar 2026 11:33:13 -0700 Subject: [PATCH 05/11] Retrigger CI (ADO pool flake) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From 837270b9c0470be227ec9105769b066d85fadc6f Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Mon, 23 Mar 2026 14:32:03 -0700 Subject: [PATCH 06/11] Retrigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From a110dd6a5c87a789b6a1b82a66f0be03e06ca962 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 24 Mar 2026 06:57:34 -0700 Subject: [PATCH 07/11] Retrigger CI Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> From c79f4dee9aea9363c0439a141f5c44b8de87a84e Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 24 Mar 2026 08:06:18 -0700 Subject: [PATCH 08/11] Fix: remove ListActiveDeployments from Scope interface Move ListActiveDeployments to a standalone function instead of adding it to the exported Scope interface. Adding methods to exported interfaces is a breaking change for any external implementation (including test mocks in CI). The standalone infra.ListActiveDeployments(ctx, scope) function calls scope.ListDeployments and filters for active states, achieving the same result without widening the interface contract. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bicep/active_deployment_check_test.go | 13 ++-- .../provisioning/bicep/bicep_provider.go | 4 +- cli/azd/pkg/infra/scope.go | 64 ++++++++----------- 3 files changed, 31 insertions(+), 50 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go index 4d608b2a688..00d1359b657 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go @@ -17,11 +17,12 @@ import ( ) // activeDeploymentScope is a test helper that implements infra.Scope and lets -// the caller control what ListActiveDeployments returns on each call. +// the caller control what ListDeployments returns on each call. The standalone +// infra.ListActiveDeployments function filters these results. type activeDeploymentScope struct { - // calls tracks how many times ListActiveDeployments has been invoked. + // calls tracks how many times ListDeployments has been invoked. calls atomic.Int32 - // activePerCall maps a 0-based call index to the list of active deployments + // activePerCall maps a 0-based call index to the list of deployments // returned for that call. If the index is missing, nil is returned. activePerCall map[int][]*azapi.ResourceDeployment // errOnCall, if non-nil, maps a call index to an error to return. @@ -34,12 +35,6 @@ func (s *activeDeploymentScope) Deployment(_ string) infra.Deployment { return n func (s *activeDeploymentScope) ListDeployments( _ context.Context, -) ([]*azapi.ResourceDeployment, error) { - return nil, nil -} - -func (s *activeDeploymentScope) ListActiveDeployments( - ctx context.Context, ) ([]*azapi.ResourceDeployment, error) { idx := int(s.calls.Add(1)) - 1 if s.errOnCall != nil { diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 086271416a6..9a859cfa44c 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -627,7 +627,7 @@ func (p *BicepProvider) waitForActiveDeployments( ctx context.Context, scope infra.Scope, ) error { - active, err := scope.ListActiveDeployments(ctx) + active, err := infra.ListActiveDeployments(ctx, scope) if err != nil { // If the resource group doesn't exist yet, there are no active // deployments — proceed normally. @@ -687,7 +687,7 @@ func (p *BicepProvider) waitForActiveDeployments( "deployment(s) to complete: %s", timeout, strings.Join(currentNames, ", ")) case <-ticker.C: - active, err = scope.ListActiveDeployments(ctx) + active, err = infra.ListActiveDeployments(ctx, scope) if err != nil { return fmt.Errorf( "checking active deployments: %w", err) diff --git a/cli/azd/pkg/infra/scope.go b/cli/azd/pkg/infra/scope.go index b6b09a87b14..dcccc79c479 100644 --- a/cli/azd/pkg/infra/scope.go +++ b/cli/azd/pkg/infra/scope.go @@ -21,11 +21,34 @@ type Scope interface { SubscriptionId() string // ListDeployments returns all the deployments at this scope. ListDeployments(ctx context.Context) ([]*azapi.ResourceDeployment, error) - // ListActiveDeployments returns only the deployments that are currently in progress. - ListActiveDeployments(ctx context.Context) ([]*azapi.ResourceDeployment, error) Deployment(deploymentName string) Deployment } +// ListActiveDeployments lists all deployments at the given scope and returns +// only those with an active provisioning state (Running, Deploying, etc.). +func ListActiveDeployments( + ctx context.Context, + scope Scope, +) ([]*azapi.ResourceDeployment, error) { + all, err := scope.ListDeployments(ctx) + if err != nil { + return nil, err + } + + return filterActiveDeployments(all), nil +} + +// filterActiveDeployments returns only deployments with an active provisioning state. +func filterActiveDeployments(deployments []*azapi.ResourceDeployment) []*azapi.ResourceDeployment { + var active []*azapi.ResourceDeployment + for _, d := range deployments { + if azapi.IsActiveDeploymentState(d.ProvisioningState) { + active = append(active, d) + } + } + return active +} + type Deployment interface { Scope // Name is the name of this deployment. @@ -230,19 +253,6 @@ func (s *ResourceGroupScope) ListDeployments(ctx context.Context) ([]*azapi.Reso return deployments, err } -// ListActiveDeployments returns only the deployments in this resource group -// that are currently in progress (e.g. Running, Deploying, Accepted). -func (s *ResourceGroupScope) ListActiveDeployments( - ctx context.Context, -) ([]*azapi.ResourceDeployment, error) { - all, err := s.ListDeployments(ctx) - if err != nil { - return nil, err - } - - return filterActiveDeployments(all), nil -} - // Deployment gets the deployment with the specified name. func (s *ResourceGroupScope) Deployment(deploymentName string) Deployment { return NewResourceGroupDeployment(s, deploymentName) @@ -396,30 +406,6 @@ func (s *SubscriptionScope) ListDeployments(ctx context.Context) ([]*azapi.Resou return s.deploymentService.ListSubscriptionDeployments(ctx, s.subscriptionId) } -// ListActiveDeployments returns only subscription-scoped deployments -// that are currently in progress (e.g. Running, Deploying, Accepted). -func (s *SubscriptionScope) ListActiveDeployments( - ctx context.Context, -) ([]*azapi.ResourceDeployment, error) { - all, err := s.ListDeployments(ctx) - if err != nil { - return nil, err - } - - return filterActiveDeployments(all), nil -} - -// filterActiveDeployments returns only deployments with an active provisioning state. -func filterActiveDeployments(deployments []*azapi.ResourceDeployment) []*azapi.ResourceDeployment { - var active []*azapi.ResourceDeployment - for _, d := range deployments { - if azapi.IsActiveDeploymentState(d.ProvisioningState) { - active = append(active, d) - } - } - return active -} - func newSubscriptionScope( deploymentsService azapi.DeploymentService, subscriptionId string, From a50e6fd6d806a5a718b4537f0889e31e8640fa08 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 24 Mar 2026 08:54:47 -0700 Subject: [PATCH 09/11] Remove orphaned ListActiveDeployments from mockedScope Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go | 4 ---- 1 file changed, 4 deletions(-) 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 007ac6c6649..1bb52ef0a11 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider_test.go @@ -1085,10 +1085,6 @@ func (m *mockedScope) ListDeployments(ctx context.Context) ([]*azapi.ResourceDep }, nil } -func (m *mockedScope) ListActiveDeployments(ctx context.Context) ([]*azapi.ResourceDeployment, error) { - return nil, nil -} - func TestUserDefinedTypes(t *testing.T) { mockContext := mocks.NewMockContext(context.Background()) mockContext.CommandRunner.When(func(args exec.RunArgs, command string) bool { From 80088dbbc3a9b10ad5064fe553c886ad0e80ceca Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Tue, 24 Mar 2026 09:18:20 -0700 Subject: [PATCH 10/11] Fix: use scopeForTemplate instead of deployment for active check The deployment object returned by generateDeploymentObject embeds a Scope that can be nil in test environments (e.g. mockedScope returns an empty SubscriptionDeployment). Using scopeForTemplate resolves the scope from the provider's configuration, avoiding nil panics in existing tests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 9a859cfa44c..23af048a9bf 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -810,9 +810,13 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult, p.console.StopSpinner(ctx, "", input.StepDone) } - // Check for active deployments at the target scope and wait if any are in progress - if err := p.waitForActiveDeployments(ctx, deployment); err != nil { - return nil, err + // Check for active deployments at the target scope and wait if any are in progress. + // Use scopeForTemplate to get the raw scope — deployment.Scope may have a nil + // inner scope in test mocks. + if activeScope, err := p.scopeForTemplate(planned.Template); err == nil { + if err := p.waitForActiveDeployments(ctx, activeScope); err != nil { + return nil, err + } } progressCtx, cancelProgress := context.WithCancel(ctx) From 7e811411818a6c7894b2ad7022b7c5f32f41c593 Mon Sep 17 00:00:00 2001 From: Shayne Boyer Date: Wed, 25 Mar 2026 22:26:14 -0700 Subject: [PATCH 11/11] Fix review: handle ErrDeploymentsNotFound in poll loop If the resource group is deleted externally while waiting for active deployments to drain, the poll now returns nil instead of surfacing a hard error. This matches the initial check behavior. Known limitations documented: - Only queries the active deployment backend (standard or stacks) - Race window between wait completion and deploy request is inherent Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../bicep/active_deployment_check_test.go | 24 +++++++++++++++++++ .../provisioning/bicep/bicep_provider.go | 3 +++ 2 files changed, 27 insertions(+) diff --git a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go index 00d1359b657..9ff8b7aadde 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go +++ b/cli/azd/pkg/infra/provisioning/bicep/active_deployment_check_test.go @@ -161,6 +161,30 @@ func TestWaitForActiveDeployments_PollError(t *testing.T) { require.Contains(t, err.Error(), "transient ARM failure") } +func TestWaitForActiveDeployments_PollNotFound(t *testing.T) { + // If the resource group is deleted externally while polling, + // ListDeployments returns ErrDeploymentsNotFound. The wait should + // treat this as "no active deployments" and return nil. + running := []*azapi.ResourceDeployment{ + { + Name: "deploy-1", + ProvisioningState: azapi.DeploymentProvisioningStateRunning, + }, + } + scope := &activeDeploymentScope{ + activePerCall: map[int][]*azapi.ResourceDeployment{ + 0: running, + }, + errOnCall: map[int]error{ + 1: infra.ErrDeploymentsNotFound, + }, + } + p := newTestProvider() + + err := p.waitForActiveDeployments(t.Context(), scope) + require.NoError(t, err) +} + func TestWaitForActiveDeployments_Timeout(t *testing.T) { running := []*azapi.ResourceDeployment{ { diff --git a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go index 23af048a9bf..9c408f12faf 100644 --- a/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go +++ b/cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go @@ -689,6 +689,9 @@ func (p *BicepProvider) waitForActiveDeployments( case <-ticker.C: active, err = infra.ListActiveDeployments(ctx, scope) if err != nil { + if errors.Is(err, infra.ErrDeploymentsNotFound) { + return nil + } return fmt.Errorf( "checking active deployments: %w", err) }