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
6 changes: 6 additions & 0 deletions cli/azd/cmd/middleware/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import (
"github.com/azure/azure-dev/cli/azd/pkg/environment/azdcontext"
"github.com/azure/azure-dev/cli/azd/pkg/errorhandler"
"github.com/azure/azure-dev/cli/azd/pkg/extensions"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning"
"github.com/azure/azure-dev/cli/azd/pkg/infra/provisioning/bicep"
"github.com/azure/azure-dev/cli/azd/pkg/input"
"github.com/azure/azure-dev/cli/azd/pkg/output"
Expand Down Expand Up @@ -85,6 +86,11 @@ func shouldSkipAgentHandling(err error) bool {
errors.Is(err, consent.ErrElicitationDenied) ||
errors.Is(err, consent.ErrSamplingDenied) ||
errors.Is(err, internal.ErrAbortedByUser) ||
errors.Is(err, provisioning.ErrDeploymentInterruptedLeaveRunning) ||
errors.Is(err, provisioning.ErrDeploymentCanceledByUser) ||
errors.Is(err, provisioning.ErrDeploymentCancelTimeout) ||
errors.Is(err, provisioning.ErrDeploymentCancelTooLate) ||
errors.Is(err, provisioning.ErrDeploymentCancelFailed) ||

errors.Is(err, environment.ErrNotFound) ||
errors.Is(err, environment.ErrNameNotSpecified) ||
Expand Down
64 changes: 64 additions & 0 deletions cli/azd/docs/provision-cancellation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
# Provision cancellation (Ctrl+C)

When `azd provision` (or `azd up`) submits a Bicep deployment to Azure, the
deployment runs asynchronously on the Azure side. If the user presses
<kbd>Ctrl</kbd>+<kbd>C</kbd> while azd is waiting for that deployment to
finish, azd will pause and ask what to do instead of exiting immediately.

## Behavior

1. azd stops the live progress reporter and presents an interactive prompt
that includes the Azure portal URL of the running deployment.
2. The user picks one of:
- **Leave the Azure deployment running and stop azd** (default). azd
exits with a non-zero status; the Azure deployment continues to
completion. The user can monitor or cancel it from the portal link.
- **Cancel the Azure deployment**. azd submits an ARM cancel request
against the deployment and waits up to 2 minutes for Azure to confirm a
terminal state (`Canceled`, `Failed`, or `Succeeded`).
3. Additional <kbd>Ctrl</kbd>+<kbd>C</kbd> presses while the prompt is
showing (or while a cancel request is in flight) are ignored so the user
can finish reading and choose deliberately.

## Outcomes when "Cancel" is selected

| Outcome | When |
|---------|------|
| Cancellation confirmed | Azure transitions the deployment to `Canceled` within the wait budget. azd exits non-zero with a clear message. |
| Cancel arrived too late | Azure reports the deployment finished (`Succeeded` / `Failed`) before the cancel request took effect. azd surfaces the final state plus the portal URL. |
| Cancel still pending | Azure does not reach a terminal state within the wait budget. azd warns that cancellation is still in progress and prints the portal URL. |
| Cancel request failed | The ARM `Cancel` API itself returned an error. azd prints the error and the portal URL. |
Comment thread
vhvb1989 marked this conversation as resolved.

When the deployment URL is available, azd prints it so the user can follow
up manually from the browser. The URL is omitted if azd was unable to
resolve it (for example, when the ARM service is unreachable).

## Provider scope

| Provider | Behavior on Ctrl+C during provision |
|---------|--------------------------------------|
| Bicep (subscription scope) | Interactive prompt (described above). |
| Bicep (resource group scope) | Interactive prompt (described above). |
| Deployment Stacks | Currently treated as "leave running" — the stacks ARM API does not expose a per-deployment cancel surface today. |
| Terraform | Unchanged: the Terraform CLI does not expose a safe per-apply cancel; pressing Ctrl+C exits azd and Terraform handles its own teardown. |

## Telemetry

A `provision.cancellation` attribute is recorded on the provisioning span
with one of:

- `none` — provisioning completed normally without an interrupt.
- `leave_running` — user chose to let the Azure deployment continue.
- `canceled` — cancel request succeeded and Azure reached `Canceled`.
- `cancel_too_late` — Azure reached `Succeeded` / `Failed` before cancel
took effect.
- `cancel_timed_out` — Azure did not reach a terminal state within the
wait budget.
- `cancel_failed` — the ARM `Cancel` API call itself returned an error.

## Non-interactive mode

If azd is running without a TTY (e.g. CI), the prompt cannot be displayed.
In that case azd defaults to **leave running** behavior so that an
unattended deployment is never silently cancelled by an environment
signal.
12 changes: 12 additions & 0 deletions cli/azd/internal/cmd/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -284,8 +284,20 @@ func classifySentinel(err error) string {
return "internal.not_git_repo"
case errors.Is(err, azapi.ErrPreviewNotSupported):
return "internal.preview_not_supported"
case errors.Is(err, azapi.ErrCancelNotSupported):
return "internal.cancel_not_supported"
case errors.Is(err, provisioning.ErrBindMountOperationDisabled):
return "internal.bind_mount_disabled"
case errors.Is(err, provisioning.ErrDeploymentInterruptedLeaveRunning):
return "user.canceled.leave_running"
case errors.Is(err, provisioning.ErrDeploymentCanceledByUser):
return "user.canceled.deployment_canceled"
case errors.Is(err, provisioning.ErrDeploymentCancelTimeout):
return "user.canceled.cancel_timed_out"
case errors.Is(err, provisioning.ErrDeploymentCancelTooLate):
return "user.canceled.cancel_too_late"
case errors.Is(err, provisioning.ErrDeploymentCancelFailed):
return "user.canceled.cancel_failed"
case errors.Is(err, update.ErrNeedsElevation):
return "update.elevationRequired"
case errors.Is(err, pipeline.ErrRemoteHostIsNotAzDo):
Expand Down
18 changes: 18 additions & 0 deletions cli/azd/internal/tracing/fields/fields.go
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,24 @@ var (
}
)

// Provision-related fields
var (
// ProvisionCancellationKey records how a Ctrl+C interrupt during
// `azd provision` / `azd up` was handled.
//
// Example: "none" (no interrupt observed), "leave_running" (user chose to
// keep the Azure deployment running), "canceled" (Azure confirmed the
// deployment reached the Canceled state), "cancel_timed_out" (cancel was
// submitted but azd stopped waiting for the terminal state),
// "cancel_too_late" (Azure finished the deployment before the cancel took
// effect), "cancel_failed" (the cancel request itself returned an error).
ProvisionCancellationKey = AttributeKey{
Key: attribute.Key("provision.cancellation"),
Classification: SystemMetadata,
Purpose: FeatureInsight,
}
)

// The value used for ServiceNameKey
const ServiceNameAzd = "azd"

Expand Down
24 changes: 24 additions & 0 deletions cli/azd/pkg/azapi/deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,11 @@ const (

var ErrPreviewNotSupported = errors.New("preview not supported")

// ErrCancelNotSupported indicates that the deployment provider does not support
// cancelling an in-flight deployment (e.g. deployment stacks). Callers can use
// errors.Is to detect this case and fall back to "leave running" behavior.
var ErrCancelNotSupported = errors.New("cancel not supported for this deployment kind")

const emptySubscriptionArmTemplate = `{
"$schema": "https://schema.management.azure.com/schemas/2018-05-01/subscriptionDeploymentTemplate.json#",
"contentVersion": "1.0.0.0",
Expand Down Expand Up @@ -226,6 +231,25 @@ type DeploymentService interface {
options map[string]any,
progress *async.Progress[DeleteDeploymentProgress],
) error
// CancelSubscriptionDeployment requests Azure to cancel a running
// subscription-scoped deployment. The call returns immediately after the
// cancel request is accepted; callers should poll the deployment to observe
// the terminal state (Canceled, Failed, or Succeeded).
CancelSubscriptionDeployment(
ctx context.Context,
subscriptionId string,
deploymentName string,
) error
// CancelResourceGroupDeployment requests Azure to cancel a running
// resource-group-scoped deployment. The call returns immediately after the
// cancel request is accepted; callers should poll the deployment to observe
// the terminal state (Canceled, Failed, or Succeeded).
CancelResourceGroupDeployment(
ctx context.Context,
subscriptionId string,
resourceGroupName string,
deploymentName string,
) error
}

type DeleteResourceState string
Expand Down
23 changes: 23 additions & 0 deletions cli/azd/pkg/azapi/stack_deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,29 @@ func (d *StackDeployments) CalculateTemplateHash(
return d.standardDeployments.CalculateTemplateHash(ctx, subscriptionId, template)
}

// CancelSubscriptionDeployment is not supported for deployment stacks. The
// deployment stacks ARM API does not expose a per-stack cancel operation;
// stopping a stack mid-deployment requires deleting the stack itself. Returns
// ErrCancelNotSupported so callers can distinguish this from a real failure.
func (d *StackDeployments) CancelSubscriptionDeployment(
ctx context.Context,
subscriptionId string,
deploymentName string,
) error {
return ErrCancelNotSupported
}

// CancelResourceGroupDeployment is not supported for deployment stacks. See
// CancelSubscriptionDeployment for details.
func (d *StackDeployments) CancelResourceGroupDeployment(
ctx context.Context,
subscriptionId string,
resourceGroupName string,
deploymentName string,
) error {
return ErrCancelNotSupported
}
Comment thread
vhvb1989 marked this conversation as resolved.

func (d *StackDeployments) createClient(ctx context.Context, subscriptionId string) (*armdeploymentstacks.Client, error) {
credential, err := d.credentialProvider.CredentialForSubscription(ctx, subscriptionId)
if err != nil {
Expand Down
41 changes: 41 additions & 0 deletions cli/azd/pkg/azapi/standard_deployments.go
Original file line number Diff line number Diff line change
Expand Up @@ -551,6 +551,47 @@ func (ds *StandardDeployments) DeleteResourceGroupDeployment(
return nil
}

// CancelSubscriptionDeployment requests Azure to cancel a running
// subscription-scoped deployment. The ARM Cancel call returns immediately once
// the request is accepted; callers should poll the deployment to observe the
// terminal state (Canceled, Failed, or Succeeded).
func (ds *StandardDeployments) CancelSubscriptionDeployment(
ctx context.Context,
subscriptionId string,
deploymentName string,
) error {
deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId)
if err != nil {
return fmt.Errorf("creating deployments client: %w", err)
}

if _, err := deploymentClient.CancelAtSubscriptionScope(ctx, deploymentName, nil); err != nil {
return fmt.Errorf("cancelling subscription deployment: %w", err)
}
return nil
}

// CancelResourceGroupDeployment requests Azure to cancel a running
// resource-group-scoped deployment. The ARM Cancel call returns immediately
// once the request is accepted; callers should poll the deployment to observe
// the terminal state (Canceled, Failed, or Succeeded).
func (ds *StandardDeployments) CancelResourceGroupDeployment(
ctx context.Context,
subscriptionId string,
resourceGroupName string,
deploymentName string,
) error {
deploymentClient, err := ds.createDeploymentsClient(ctx, subscriptionId)
if err != nil {
return fmt.Errorf("creating deployments client: %w", err)
}

if _, err := deploymentClient.Cancel(ctx, resourceGroupName, deploymentName, nil); err != nil {
return fmt.Errorf("cancelling resource group deployment: %w", err)
}
return nil
}

func (ds *StandardDeployments) WhatIfDeployToSubscription(
ctx context.Context,
subscriptionId string,
Expand Down
30 changes: 29 additions & 1 deletion cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -767,18 +767,46 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult,
// Start the deployment
p.console.ShowSpinner(ctx, "Creating/Updating resources", input.Step)

deployCtx, interruptStarted, interruptCh, markDeployCompleted, interruptCleanup :=
p.installDeploymentInterruptHandler(ctx, deployment, cancelProgress)
cleanupOnce := sync.OnceFunc(interruptCleanup)
defer cleanupOnce()

deployResult, err := p.deployModule(
ctx,
deployCtx,
deployment,
planned.RawArmTemplate,
planned.Parameters,
deploymentTags,
optionsMap,
)

// Try to atomically claim the "completed" state. If the interrupt
// handler already claimed "interrupting", the CAS fails and we must
// wait for the handler's outcome so the user's Ctrl+C is never
// silently dropped.
if !markDeployCompleted() {
// Handler has claimed the interrupt — wait for its outcome.
<-interruptStarted
outcome := <-interruptCh
cleanupOnce()
tracing.SetUsageAttributes(
fields.ProvisionCancellationKey.String(outcome.telemetryValue))
return nil, applyInterruptOutcome(outcome, err)
}

// Deploy completed naturally — tear the handler down before
// post-processing to avoid resurfacing the cancel/leave prompt over
// subsequent output.
cleanupOnce()

if err != nil {
tracing.SetUsageAttributes(fields.ProvisionCancellationKey.String("none"))
return nil, err
}

tracing.SetUsageAttributes(fields.ProvisionCancellationKey.String("none"))

result.Outputs = provisioning.OutputParametersFromArmOutputs(
planned.Template.Outputs,
azapi.CreateDeploymentOutput(deployResult.Outputs),
Expand Down
Loading
Loading