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
2 changes: 1 addition & 1 deletion cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -404,7 +404,7 @@ overrides:
- filename: extensions/azure.ai.models/internal/cmd/custom_create.go
words:
- Qwen
- filename: pkg/infra/provisioning/bicep/local_preflight.go
- filename: pkg/infra/provisioning/bicep/provision_validation.go
words:
- actioned
- filename: pkg/project/project.go
Expand Down
2 changes: 2 additions & 0 deletions cli/azd/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@

### Breaking Changes

- [[#7113]](https://github.com/Azure/azure-dev/issues/7113) Rename azd's client-side "preflight" feature to **provision validation** to avoid confusion with the Azure ARM Preflight API. The local validation toggle moves from `provision.preflight` to a new `validation.provision` config key (`azd config set validation.provision off`), and `provision.preflight` now controls **only** the server-side ARM preflight call. Provisioning telemetry events/fields are renamed from `validation.preflight*` to `validation.provision*`, the validation outcome values `aborted_by_errors`/`aborted_by_user` are renamed to `canceled_by_errors`/`canceled_by_user`, and the extension validation `check_type` is renamed from `local-preflight` to `provision`. Users who previously set `provision.preflight off` to disable azd's local checks should now also set `validation.provision off`.

### Bugs Fixed

### Other Changes
Expand Down
4 changes: 2 additions & 2 deletions cli/azd/cmd/init_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -1060,7 +1060,7 @@ func Test_EnvSetSecretAction_VaultNotProvisioned_Cancel(t *testing.T) {

_, err := action.Run(t.Context())
require.Error(t, err)
assert.Contains(t, err.Error(), "operation cancelled by user")
assert.Contains(t, err.Error(), "operation canceled by user")
}

func Test_EnvSetSecretAction_VaultNotProvisioned_SelectError(t *testing.T) {
Expand Down Expand Up @@ -1559,7 +1559,7 @@ func Test_EnvSetSecretAction_VaultDefinedButNotProvisioned(t *testing.T) {

_, err := action.Run(t.Context())
require.Error(t, err)
require.Contains(t, err.Error(), "cancelled")
require.Contains(t, err.Error(), "canceled")
}

func Test_SelectDistinctExtension_ZeroMatches(t *testing.T) {
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/cmd/middleware/error_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -509,7 +509,7 @@ func Test_ShouldSkipAgentHandling_ControlFlow(t *testing.T) {

t.Run("Wrapped ErrAbortedByUser is skipped", func(t *testing.T) {
t.Parallel()
wrapped := fmt.Errorf("preflight declined: %w", internal.ErrAbortedByUser)
wrapped := fmt.Errorf("validation declined: %w", internal.ErrAbortedByUser)
require.True(t, shouldSkipAgentHandling(wrapped))
})

Expand Down

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ integration.
| `AZD_DEPLOY_CONCURRENCY` | Maximum number of services to deploy in parallel during `azd deploy`. Only takes effect when at least one service declares `uses:` targeting another service; without `uses:` edges, services deploy sequentially in alphabetical order for backward compatibility (see [concurrency model](concurrency-model.md)). Parsed as a positive integer; clamped to a maximum of `64`. When unset, concurrency is unlimited (bounded only by the number of services). |
| `AZD_DEPLOY_TIMEOUT` | Timeout for deployment operations, parsed as an integer number of seconds (for example, `1200`). Defaults to `1200` seconds (20 minutes). |
| `AZD_PROVISION_CONCURRENCY` | Maximum number of infrastructure layers to provision in parallel during `azd provision`. Parsed as a positive integer; clamped to a maximum of `64`. When unset, concurrency is unlimited (bounded only by the dependency graph). |
| `AZD_DEPLOYMENT_ID_FILE` | Absolute path of a file where `azd` writes ARM deployment IDs in NDJSON format (one JSON line per layer) during `azd provision` or `azd up`. The file is truncated at the start of each provisioning run, and each infrastructure layer appends one line as its ARM deployment starts. Each line has the shape `{"deploymentId":"/subscriptions/.../deployments/<name>","layer":"<layer-name>"}` — the `layer` field is empty for non-layered (single-module) provisioning. Consumers should tail/watch the file and parse each line independently; unknown fields must be ignored for forward compatibility. The path must be absolute (relative paths are ignored); the containing directory must already exist and be writable. Lines are only appended when an ARM deployment is actually started — runs short-circuited by the deployment-state cache or aborted by preflight do not produce output. A process-wide mutex serializes writes so each line is always complete. If the file cannot be written (for example, the parent directory does not exist, the path is not writable, or the path points to a directory rather than a file), provisioning continues and the failure is recorded via the standard log; that output is only visible when `--debug` or `AZD_DEBUG_LOG` is enabled. On Windows, consumers should use a file-watcher pattern that does not keep a read handle open, otherwise new appends may fail. Only Bicep deployments are supported. |
| `AZD_DEPLOYMENT_ID_FILE` | Absolute path of a file where `azd` writes ARM deployment IDs in NDJSON format (one JSON line per layer) during `azd provision` or `azd up`. The file is truncated at the start of each provisioning run, and each infrastructure layer appends one line as its ARM deployment starts. Each line has the shape `{"deploymentId":"/subscriptions/.../deployments/<name>","layer":"<layer-name>"}` — the `layer` field is empty for non-layered (single-module) provisioning. Consumers should tail/watch the file and parse each line independently; unknown fields must be ignored for forward compatibility. The path must be absolute (relative paths are ignored); the containing directory must already exist and be writable. Lines are only appended when an ARM deployment is actually started — runs short-circuited by the deployment-state cache or canceled by provision validation do not produce output. A process-wide mutex serializes writes so each line is always complete. If the file cannot be written (for example, the parent directory does not exist, the path is not writable, or the path points to a directory rather than a file), provisioning continues and the failure is recorded via the standard log; that output is only visible when `--debug` or `AZD_DEBUG_LOG` is enabled. On Windows, consumers should use a file-watcher pattern that does not keep a read handle open, otherwise new appends may fail. Only Bicep deployments are supported. |
| `AZD_UP_CONCURRENCY` | Maximum number of steps to run in parallel during `azd up`. Parsed as a positive integer; clamped to a maximum of `64`. Falls back to `AZD_DEPLOY_CONCURRENCY` when unset. When both are unset, concurrency is unlimited. |
| `AZD_DEPLOY_{SERVICE}_SLOT_NAME` | Sets the App Service deployment slot target for a service. Replace `{SERVICE}` with the uppercase service name (hyphens become underscores). Set to `production` to deploy to the main app, or a slot name (e.g., `staging`). When slots exist and this is not set, `--no-prompt` mode fails with an error listing available targets. |
| `AZD_DEPLOY_{SERVICE}_SKIP_STATUS_CHECK` | If `true`, skips runtime deployment status tracking for the named Linux App Service after zip deploy. Useful when the target web app is intentionally stopped. Parsed as a boolean (`true`/`false`/`1`/`0`). `{SERVICE}` follows the same naming rules as `AZD_DEPLOY_{SERVICE}_SLOT_NAME`. |
Expand Down
8 changes: 4 additions & 4 deletions cli/azd/docs/extensions/extension-framework.md
Original file line number Diff line number Diff line change
Expand Up @@ -946,7 +946,7 @@ Extensions can provide AI agent tools through the Model Context Protocol, enabli
Extensions can contribute validation checks to azd's validation pipeline. Currently
supported check types:

- **`local-preflight`** — Checks run during `azd provision` before deployment. The
- **`provision`** — Checks run during `azd provision` before deployment. The
extension receives the Bicep snapshot, ARM template, ARM parameters, and Azure
location as context.

Expand All @@ -958,15 +958,15 @@ changes.
```go
host := azdext.NewExtensionHost(azdClient).
WithValidationCheck(azdext.ValidationCheckRegistration{
CheckType: "local-preflight",
CheckType: "provision",
RuleID: "my_naming_rule",
Factory: func() azdext.ValidationCheckProvider {
return &MyNamingCheck{}
},
})
```

See [`local-preflight-validation.md`](../design/local-preflight-validation.md#extension-provided-checks)
See [`provision-validation.md`](../design/provision-validation.md#extension-provided-checks)
for full details on the check interface and context keys.

#### Future Considerations
Expand Down Expand Up @@ -1078,7 +1078,7 @@ Extensions can declare the following capabilities in their manifest:
- **`service-target-provider`**: Provide custom service deployment targets
- **`framework-service-provider`**: Provide custom language frameworks and build systems
- **`provisioning-provider`**: Provide a custom infrastructure provisioning experience (alternative to Bicep / Terraform)
- **`validation-provider`**: Contribute validation checks to azd's preflight and future validation pipelines
- **`validation-provider`**: Contribute validation checks to azd's provision and future validation pipelines
- **`metadata`**: Provide comprehensive metadata about commands and configuration schemas

#### Complete Extension Manifest Example
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ func newListenCommand() *cobra.Command {
return project.NewDemoProvisioningProvider(azdClient)
}).
WithValidationCheck(azdext.ValidationCheckRegistration{
CheckType: "local-preflight",
CheckType: "provision",
RuleID: "demo_warning",
Factory: func() azdext.ValidationCheckProvider {
return project.NewDemoValidationCheck()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,11 @@ func TestDemoValidationCheck_AlwaysReturnsWarning(t *testing.T) {
t.Run("no ARM template", func(t *testing.T) {
valCtx := &azdext.ValidationContext{
ContextID: "test-ctx",
CheckType: "local-preflight",
CheckType: "provision",
Data: map[string][]byte{},
}
req := &azdext.ValidationCheckRequest{
CheckType: "local-preflight",
CheckType: "provision",
RuleId: "demo_warning",
ContextId: "test-ctx",
}
Expand All @@ -42,13 +42,13 @@ func TestDemoValidationCheck_AlwaysReturnsWarning(t *testing.T) {
resourcesJSON := []byte(`[{"type": "a"}, {"type": "b"}, {"type": "c"}]`)
valCtx := &azdext.ValidationContext{
ContextID: "test-ctx-2",
CheckType: "local-preflight",
CheckType: "provision",
Data: map[string][]byte{
azdext.ValidationContextPredictedResources: resourcesJSON,
},
}
req := &azdext.ValidationCheckRequest{
CheckType: "local-preflight",
CheckType: "provision",
RuleId: "demo_warning",
ContextId: "test-ctx-2",
}
Expand Down
2 changes: 1 addition & 1 deletion cli/azd/grpc/proto/validation.proto
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ message ValidationMessage {
// RegisterValidationCheckRequest is sent by extensions to register a validation check.
message RegisterValidationCheckRequest {
// check_type identifies the validation context this check targets
// (e.g. "local-preflight", "project-config"). Future check types can be
// (e.g. "provision", "project-config"). Future check types can be
// added without changing the protocol.
string check_type = 1;
// rule_id is a stable, unique identifier for this check rule,
Expand Down
32 changes: 16 additions & 16 deletions cli/azd/internal/cmd/provision_graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,11 +38,11 @@ import (
"go.uber.org/multierr"
)

// errPreflightAbortedByUser is a sentinel returned from [provisionSingleLayer]
// when the underlying provider reports [provisioning.PreflightAbortedSkipped].
// errProvisionValidationCanceledByUser is a sentinel returned from [provisionSingleLayer]
// when the underlying provider reports [provisioning.ProvisionValidationCanceledSkipped].
// The caller translates it to [internal.ErrAbortedByUser] at the action
// boundary so the user sees a friendly "Provisioning was cancelled." message.
var errPreflightAbortedByUser = errors.New("provisioning aborted by user during preflight")
// boundary so the user sees a friendly "Provisioning was canceled." message.
var errProvisionValidationCanceledByUser = errors.New("provisioning canceled by user during validation")

// provisionLayersGraph is the single execution entry point for
// [ProvisionAction]. It dispatches to one of four disjoint paths:
Expand Down Expand Up @@ -152,11 +152,11 @@ func (p *ProvisionAction) provisionLayersGraph(
return hookErr
}

if deployResult.SkippedReason == provisioning.PreflightAbortedSkipped {
if deployResult.SkippedReason == provisioning.ProvisionValidationCanceledSkipped {
// Return the internal sentinel; wrapProvisionError at the
// outer boundary emits the "Provisioning was cancelled."
// outer boundary emits the "Provisioning was canceled."
// UX message and translates to internal.ErrAbortedByUser.
return errPreflightAbortedByUser
return errProvisionValidationCanceledByUser
}

skipped := deployResult.SkippedReason == provisioning.DeploymentStateSkipped
Expand Down Expand Up @@ -287,7 +287,7 @@ func (p *ProvisionAction) provisionLayersGraph(
if result.Error != nil {
// Peel the scheduler's `step "X" failed:` prefix and run the
// underlying error through the same wrapping used by the sequential
// path (OpenAI / Responsible AI translation, preflight-abort
// path (OpenAI / Responsible AI translation, validation-cancel
// ErrAbortedByUser, state dump on provider failure, etc.).
return nil, p.wrapProvisionError(ctx, unwrapStepErrors(result))
}
Expand Down Expand Up @@ -635,7 +635,7 @@ func (p *ProvisionAction) logProvisionGraphTimings(result *exegraph.RunResult) {

// wrapProvisionError replicates the sequential path's error-wrapping logic
// (provision.go:382-435): JSON state dump on failure, OpenAI access wrapper,
// Responsible AI wrapper, and the preflight-aborted translation.
// Responsible AI wrapper, and the validation-canceled translation.
func (p *ProvisionAction) wrapProvisionError(ctx context.Context, err error) error {
return wrapProvisionError(ctx, err, provisionErrorDeps{
console: p.console,
Expand Down Expand Up @@ -663,10 +663,10 @@ type provisionErrorDeps struct {
// provisionManager (the same one used for the primary layer) so that the
// JSON state dump on failure has something to render.
func wrapProvisionError(ctx context.Context, err error, deps provisionErrorDeps) error {
// Preflight-aborted → ErrAbortedByUser with success message.
if errors.Is(err, errPreflightAbortedByUser) {
// Validation-canceled → ErrAbortedByUser with success message.
if errors.Is(err, errProvisionValidationCanceledByUser) {
deps.console.MessageUxItem(ctx, &ux.ActionResult{
SuccessMessage: "Provisioning was cancelled.",
SuccessMessage: "Provisioning was canceled.",
})
Comment thread
vhvb1989 marked this conversation as resolved.
return internal.ErrAbortedByUser
}
Expand Down Expand Up @@ -850,8 +850,8 @@ func provisionSingleLayer(
// parallel and B's clone may pre-date A's reload.
//
// Returns the raw [provisioning.DeployResult] so callers can record skip
// semantics; on [provisioning.PreflightAbortedSkipped] it returns
// [errPreflightAbortedByUser] so [ProvisionAction] can translate it to
// semantics; on [provisioning.ProvisionValidationCanceledSkipped] it returns
// [errProvisionValidationCanceledByUser] so [ProvisionAction] can translate it to
// [internal.ErrAbortedByUser].
func runProvisionSingleLayer(
ctx context.Context,
Expand Down Expand Up @@ -961,8 +961,8 @@ func runProvisionSingleLayer(
return nil, fmt.Errorf("deploying layer %s: %w", stepName, err)
}

if deployResult.SkippedReason == provisioning.PreflightAbortedSkipped {
return deployResult, errPreflightAbortedByUser
if deployResult.SkippedReason == provisioning.ProvisionValidationCanceledSkipped {
return deployResult, errProvisionValidationCanceledByUser
}

// ── Step 4: Env merge ──
Expand Down
10 changes: 5 additions & 5 deletions cli/azd/internal/cmd/provision_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -104,23 +104,23 @@ func (p *mockProvider) PlannedOutputs(_ context.Context) ([]provisioning.Planned
return nil, nil
}

// TestProvisionAction_PreflightAborted verifies that when the user declines
// preflight warnings, ProvisionAction.Run returns ErrAbortedByUser and does NOT
// TestProvisionAction_ValidationCanceled verifies that when the user declines
// validation warnings, ProvisionAction.Run returns ErrAbortedByUser and does NOT
// attempt to read deployResult.Deployment.Outputs (which would nil-panic).
//
// Regression test for https://github.com/Azure/azure-dev/issues/7305
func TestProvisionAction_PreflightAborted(t *testing.T) {
func TestProvisionAction_ValidationCanceled(t *testing.T) {
t.Parallel()
// Set up a temp project with a minimal infra directory so ImportManager works.
projectDir := t.TempDir()
infraDir := filepath.Join(projectDir, "infra")
require.NoError(t, os.MkdirAll(infraDir, 0o755))
require.NoError(t, os.WriteFile(filepath.Join(infraDir, "main.bicep"), []byte("targetScope = 'subscription'\n"), 0o600))

// Mock provider that simulates preflight abort (user said No).
// Mock provider that simulates validation cancel (user said No).
provider := &mockProvider{
deployResult: &provisioning.DeployResult{
SkippedReason: provisioning.PreflightAbortedSkipped,
SkippedReason: provisioning.ProvisionValidationCanceledSkipped,
},
}

Expand Down
6 changes: 3 additions & 3 deletions cli/azd/internal/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -78,11 +78,11 @@ var (
var (
ErrNoArgsProvided = errors.New("required arguments not provided")
ErrInvalidArgValue = errors.New("invalid argument value")
ErrOperationCancelled = errors.New("operation cancelled by user")
ErrOperationCancelled = errors.New("operation canceled by user")

// ErrAbortedByUser indicates the user intentionally declined to proceed (e.g. preflight warnings).
// ErrAbortedByUser indicates the user intentionally declined to proceed (e.g. provision validation warnings).
// This is not a failure — the CLI should exit with code 0.

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.

After this change, both ErrOperationCancelled (line 81) and ErrAbortedByUser (line 85) produce the same .Error() string: "operation canceled by user". They serve different semantic roles:

  • ErrOperationCancelled: explicit cancellation (e.g., Ctrl+C, user hit cancel in a prompt)
  • ErrAbortedByUser: user intentionally declined to proceed (e.g., answered No to validation warnings, exit code 0)

While errors.Is() works on pointer identity (so no functional breakage), identical messages make debugging and log correlation harder. The test in runner_test.go:77 also asserts require.Equal(t, internal.ErrAbortedByUser.Error(), err.Error()) to verify no wrapping, which would now also match a (hypothetically unwrapped) ErrOperationCancelled.

Consider keeping distinct messages, e.g.:

Suggested change
// This is not a failure — the CLI should exit with code 0.
// ErrAbortedByUser indicates the user intentionally declined to proceed (e.g. provision validation warnings).
// This is not a failure - the CLI should exit with code 0.
ErrAbortedByUser = errors.New("operation declined by user")

ErrAbortedByUser = errors.New("operation aborted by user")
ErrAbortedByUser = errors.New("operation canceled by user")

@jongio jongio Jun 30, 2026

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.

Still unresolved from my earlier review: after this PR, both ErrOperationCancelled (line 81) and ErrAbortedByUser (line 85) resolve to the identical string "operation canceled by user".

These sentinels have different semantics:

  • ErrOperationCancelled: Ctrl+C or explicit cancellation (non-zero exit)
  • ErrAbortedByUser: user declined to proceed at a prompt (exit 0)

errors.Is() still works (different pointers), but identical messages make log grep, debugging, and user-facing output ambiguous. Previously they were distinguishable ("cancelled" vs "aborted").

Consider giving ErrAbortedByUser a distinct message, e.g. "operation declined by user" or "user chose not to proceed", so the two remain distinguishable in logs and output without needing to inspect the call stack.

)

// Config errors
Expand Down
Loading
Loading