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: 2 additions & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,8 @@ words:
- protoreflect
- SNAPPROCESS
- structpb
- subtest
- subtests
- syncmap
- syscall
- tsx
Expand Down
1 change: 1 addition & 0 deletions cli/azd/docs/environment-variables.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +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_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
7 changes: 7 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/bicep_provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -993,6 +993,13 @@ func (p *BicepProvider) Deploy(ctx context.Context) (*provisioning.DeployResult,
// Start the deployment
p.console.ShowSpinner(ctx, "Creating/Updating resources", input.Step)

// If AZD_DEPLOYMENT_ID_FILE is set, expose the ARM deployment ID to the caller now
// that we are actually about to start the ARM deployment. Doing this here (rather
// than immediately after generating the deployment object) avoids advertising a
// deployment ID that never exists in Azure when the run short-circuits via the
// deployment-state cache or is aborted by preflight validation.
writeDeploymentIdFile(deployment, p.layer)

deployCtx, interruptStarted, interruptCh, markDeployCompleted, interruptCleanup :=
p.installDeploymentInterruptHandler(ctx, deployment, cancelProgress)
cleanupOnce := sync.OnceFunc(interruptCleanup)
Expand Down
172 changes: 172 additions & 0 deletions cli/azd/pkg/infra/provisioning/bicep/deployment_id_file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the MIT License.

package bicep

import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"

"github.com/azure/azure-dev/cli/azd/pkg/azure"
"github.com/azure/azure-dev/cli/azd/pkg/infra"
)

// deploymentIdFileEnvVar is the name of the environment variable consumers can set to
// receive ARM deployment IDs as they become available during `azd provision`
// (or `azd up`). The value is the absolute path of a file that azd will write
// NDJSON (newline-delimited JSON) lines to — one per layer deployment.
//
// Each line has the shape:
//
// {"deploymentId":"/subscriptions/.../providers/Microsoft.Resources/deployments/<name>","layer":"<name>"}
//
// The file is truncated at the start of each provisioning run so it only contains
// deployments from the current invocation. Consumers should tail/watch the file and
// parse each line independently.
const deploymentIdFileEnvVar = "AZD_DEPLOYMENT_ID_FILE"

// deploymentIdFileMu serializes writes from sibling provisioning layers that may run
// concurrently and target the same path. This ensures each NDJSON line is written
// atomically without interleaving with other layers' writes. It also guards the
// truncation-state fields below.
var deploymentIdFileMu sync.Mutex

// deploymentIdFileTruncateAttempted is true once the truncation step has been run
// in this process invocation (regardless of whether it succeeded). The first write
// attempts truncation; subsequent writes do not.
//
// Both this flag and deploymentIdFileTruncateErr MUST only be read or written while
// holding deploymentIdFileMu so concurrent layers observe a consistent state.
var deploymentIdFileTruncateAttempted bool

// deploymentIdFileTruncateErr persists the result of the truncation attempt so that
// every subsequent caller sees the same outcome. If the first attempt failed we must
// not silently append to a file that was never truncated (which would mix stale
// content from a previous run with current-run lines).
var deploymentIdFileTruncateErr error

// resetDeploymentIdFileTruncation resets the truncation state so subsequent calls
// to writeDeploymentIdFile will truncate the file again. Used by tests to ensure
// each subtest starts fresh.
func resetDeploymentIdFileTruncation() {
deploymentIdFileMu.Lock()
defer deploymentIdFileMu.Unlock()
deploymentIdFileTruncateAttempted = false
deploymentIdFileTruncateErr = nil
}

// deploymentIdLine is a single NDJSON line written to the file identified by
// AZD_DEPLOYMENT_ID_FILE. New fields may be added in the future; consumers MUST
// ignore unknown fields.
type deploymentIdLine struct {
// DeploymentId is the ARM resource ID of the deployment, for example
// /subscriptions/{sub}/providers/Microsoft.Resources/deployments/{name}
// or /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.Resources/deployments/{name}.
DeploymentId string `json:"deploymentId"`
// Layer is the provisioning layer name that produced this deployment. It is
// empty for single-layer (non-layered) provisioning.
Layer string `json:"layer"`
}

// deploymentResourceID returns the ARM resource ID for the supplied deployment, or
// an error if the deployment scope is not recognized.
func deploymentResourceID(d infra.Deployment) (string, error) {
switch dep := d.(type) {
case *infra.SubscriptionDeployment:
return azure.SubscriptionDeploymentRID(dep.SubscriptionId(), dep.Name()), nil
case *infra.ResourceGroupDeployment:
return azure.ResourceGroupDeploymentRID(dep.SubscriptionId(), dep.ResourceGroupName(), dep.Name()), nil
default:
return "", fmt.Errorf("unsupported deployment type: %T", d)
}
}

// writeDeploymentIdFile appends an NDJSON line containing the ARM deployment ID and
// layer name to the file identified by AZD_DEPLOYMENT_ID_FILE. If the environment
// variable is not set, the function is a no-op.
//
// The path must be absolute; relative paths are rejected to avoid writing the file
// to an unexpected location relative to the process working directory. The
// containing directory is assumed to exist and be writable.
//
// On the first invocation in a process, the file is truncated so it only contains
// deployments from the current provisioning run. Subsequent invocations (e.g., from
// parallel layers) append to the file. A process-wide mutex serializes writes so
// each NDJSON line is always complete.
//
// Failures are not returned because the file is purely informational and must not
// abort provisioning. They are written via the standard log package, which only
// surfaces output when --debug or AZD_DEBUG_LOG is enabled.
func writeDeploymentIdFile(deployment infra.Deployment, layer string) {
path := os.Getenv(deploymentIdFileEnvVar)
if path == "" {
return
Comment thread
bwateratmsft marked this conversation as resolved.
}

if !filepath.IsAbs(path) {
log.Printf("ignoring %s=%q: path must be absolute", deploymentIdFileEnvVar, path) //nolint:gosec
return
}

id, err := deploymentResourceID(deployment)
if err != nil {
log.Printf("skipping %s: %v", deploymentIdFileEnvVar, err)
return
}

data, err := json.Marshal(deploymentIdLine{DeploymentId: id, Layer: layer})
if err != nil {
log.Printf("failed to marshal %s payload: %v", deploymentIdFileEnvVar, err)
return
}

// Trailing newline makes this a valid NDJSON line.
data = append(data, '\n')

// Serialize across sibling provisioning layers in this process.
deploymentIdFileMu.Lock()
defer deploymentIdFileMu.Unlock()

// Truncate on first write in this process so the file only contains deployments
// from the current provisioning run. We persist both the attempted flag and the
// error so that if truncation fails on the first call, every subsequent caller
// observes the failure and bails out — preventing appends to a file that still
// holds stale content from a previous run.
if !deploymentIdFileTruncateAttempted {
deploymentIdFileTruncateAttempted = true
// The path comes from an environment variable that the operator explicitly sets to opt
// in to this feature, so trusting the value is by design (G304).
err := os.Truncate(path, 0) //nolint:gosec // G304: operator-provided env var
if err != nil && !os.IsNotExist(err) {
// File doesn't exist yet — that's fine, we'll create it on append.
deploymentIdFileTruncateErr = err
}
}
if deploymentIdFileTruncateErr != nil {
//nolint:gosec // G706: path comes from operator-provided env var
log.Printf(
"failed to truncate %s=%q: %v",
deploymentIdFileEnvVar, path, deploymentIdFileTruncateErr)
return
}

// Append the NDJSON line. O_APPEND ensures the write is atomic at the OS level
// even without the mutex (but we keep the mutex for the truncate-then-append
// sequencing on the first call).
// The path comes from an environment variable that the operator explicitly sets to opt
// in to this feature, so trusting the value is by design (G304).
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) //nolint:gosec // G304: operator-provided env var
if err != nil {
log.Printf("failed to open %s=%q: %v", deploymentIdFileEnvVar, path, err) //nolint:gosec
return
}
defer f.Close()

if _, err := f.Write(data); err != nil {
log.Printf("failed to write %s=%q: %v", deploymentIdFileEnvVar, path, err) //nolint:gosec
}
}
Loading
Loading