Runbook — Deploy failure¶
This is the short triage card. The full failure taxonomy — eight classes, the ARM codes that map to each, what retries, what the platform can fix itself, and which of this repo's own error messages are currently untrue — is Failure recovery. Start here for a known symptom; go there for anything not in the table below.
On Azure Government, none of this is automated (#3017). The classifier and the bounded-retry harness are wired into the two Commercial deploy workflows only;
deploy-fiab-gcch.ymldoes not invokescripts/ci/deploy-retry.mjs, anddeploy-fiab-il5.ymlhas never run. The class column below is still the right way to triage a Gov failure — you just do it by hand, and nodeploy-failure.jsonis produced.
Symptom¶
azd up or Loom Setup Wizard deploy fails with a non-Succeeded ARM deployment state. Console URL doesn't return 200.
Diagnosis¶
Start here. The top-level ARM error on a failed subscription deploy is content-free — it is always DeploymentFailed: At least one resource deployment operation failed — and az deployment sub create writes ~200 lines of bicep linter warnings to stderr, so the real cause is in neither place. It sits two or more levels down, inside the failed deployment operations. Drill to it:
It walks the failed operations recursively (following nested Microsoft.Resources/deployments targets) and prints every leaf code: message with the resource it belongs to. It has three outcomes and only one is a pass: found, none (ARM answered and nothing failed), and unreadable (ARM did not answer — nothing is asserted). No workflow invokes this script directly; six deploy workflows reach the same walk through scripts/ci/deploy-retry.mjs --arm-deployment, which runs it on failure and feeds the leaves to the classifier, so a live run's annotation names the cause instead of reporting "could not classify" (#3039).
--json output is UNREDACTED — do not paste it into an issue (#3829)
The default (human-readable) render above is redacted: subscription and tenant ids collapse to <redacted> and any GUID — including the object id in a flexibleServers/administrators leaf name, <server>/<objectId> — becomes <guid>. Paste it freely.
--json is deliberately raw. It exists so the operator keeps the full ARM ids that some remediations in the table below actually need (the RoleAssignmentExists row's az role assignment delete --ids <id> is the one that matters). That means its output carries subscription ids, tenant ids, full resource ids and Entra object ids. This repo is public — treat --json output as local-only. If you need to attach evidence to a public issue, attach the default render, or the deploy-failure.json artifact, which is redacted at its own boundary.
What the lane redacts for you, and the ONE thing it does not (#3829)
Everything the deploy lane composes is redacted at the boundary it leaves through, and there is exactly one boundary per surface:
| surface | boundary |
|---|---|
| the auto-filed notice issue's title and body | deploy-notify-failure.mjs — notifyFailure() |
| that script's stdout / stderr | formatStdout() / formatStderr() |
::error:: / ::warning:: / ::notice:: annotations | deploy-retry.mjs — formatAnnotation() |
deploy-retry.mjs's own run-log lines | formatStderr() |
the deploy-failure.json artifact | one redact() over the whole serialization |
deploy-arm-errors.mjs's default render, and its usage errors | renderLeaves(), formatStdout() / formatStderr() |
So a workflow or resource name can read back as deploy-fiab-<guid> or uami-loom-directlake<guid>. That is the redactor substituting in place, not a corrupted name.
The exception, stated plainly: the deployed command's OWN output is NOT redacted. deploy-retry.mjs streams the child's stdout live and echoes its stderr back verbatim — that is deliberate, because rewriting a command's own output would make the wrapper's log disagree with the command's and send an investigation somewhere the evidence does not support (R7). If az itself prints a subscription, tenant or object id, that id is in the public run log — with or without this harness, exactly as it would be under a bare az deployment sub create. Treat a failed deploy's raw run log the same way you treat --json: read it, do not paste it.
Two narrower residuals in the redactor itself, disclosed rather than implied away (scripts/ci/_azure-redact.mjs carries the measurements): an undashed 32-hex run is left alone on purpose, because ARM prints the blocking role-assignment id that way and the #3439 auto-converger reads it back; and a GUID immediately followed by a hex character (<guid>abc) is not matched.
By hand, the same walk is:
# List recent sub-scoped deployments
az deployment sub list \
--query "[?starts_with(name, 'csa-loom')] | [?properties.provisioningState != 'Succeeded'] | [].{name:name,state:properties.provisioningState,error:properties.error.message}" \
-o table
# The top-level error — expect it to say nothing useful
az deployment sub show --name <deployment-name> --query "properties.error"
# The operations. Repeat at group scope for every nested deployment that failed:
az deployment operation sub list --name <deployment-name> -o json
az deployment operation group list -g <rg> --name <nested-deployment-name> -o json
Common failure modes:
| Symptom | Class | Likely cause | Fix |
|---|---|---|---|
Quota exceeded for Databricks Premium | quota | Region quota | Request quota via Azure portal → Subscriptions → Usage + quotas; or pick a different region. Do not retry — it is deterministic |
QuotaExceeded: standardDDSv5Family Cores during an image build | quota | The ACR-task agent pool has no cores | Raise the VM-family quota for that region, or build without the dedicated pool |
RoleAssignmentExists | config | The grant is already in place under a different assignment NAME. ARM enforces uniqueness on the (scope, principalId, roleDefinitionId) triple, not on the name, so a name-seed change (e.g. the Website Contributor role id correction …706ee → …84772) or a grant created out-of-band by az role assignment create (random GUID) blocks the template's create forever | Converge the name: az role assignment delete --ids <the id printed in the error message>, then re-run — the template recreates it under its deterministic name. skip_role_grants=true suppresses the symptom and leaves the estate un-reconciled; it is a workaround, not a fix |
BadRequest: A virtual network cannot be linked to multiple zones with overlapping namespaces | config | The hub VNet is already linked to a different Private DNS zone of the same namespace — usually a leftover zone from a superseded design in another resource group or subscription. Azure permits one link per namespace per VNet, so the admin plane's own link can never be created | Do not delete the existing link first — the A records live in that zone and unlinking takes the service dark. Run node scripts/csa-loom/migrate-private-dns-zone-owner.mjs … (dry-run by default; --apply to execute). It dual-registers under a new zone-group config name, moves the links, removes only the config that still points at the stale zone, refuses any removal that would empty the group, and re-verifies the group and the record before deleting the stale zone — see Private DNS zone-owner migration. node scripts/csa-loom/preflight-private-dns-links.mjs detects it before the deploy, and deploy-fiab-commercial.yml runs that preflight automatically |
InvalidTemplateDeployment on Container Apps in IL4 | config | Container Apps not at IL4 | Set containerPlatform = 'aks' in .bicepparam |
Forbidden on Key Vault Premium HSM | permission | Lacks Microsoft.KeyVault/managedHsms/write | Request elevated role |
VnetAddressRangeInUse | config | CIDR conflict | Pick a different CIDR; update hubVnetCidr. The DLZ spoke CIDR (10.100.0.0/16) is not settable from the root template today |
PrivateDnsZoneAlreadyExists | config | Re-deploy after a previous failure | az network private-dns zone delete the conflicts, or deploy into a clean resource group. There is no existingPrivateDnsZones parameter — an earlier version of this table said there was; it has never existed in main.bicep |
EnterpriseTenantAlreadyExists | config | A Purview account already exists in the tenant — only one is allowed | Adopt it: EXISTING_PURVIEW=<name> (+ _RG, _SUB). No enable-flag override is needed — provisionPurview is already false for an adopt decision. See Brownfield |
ManagedIdentityRoleAssignmentDelay / PrincipalNotFound | eventual-consistency | RBAC replication lag | Wait 5 min; re-run |
MANIFEST_UNKNOWN / image pull failure on a Container App | config | Phase 1 ran with deployAppsEnabled=true against an empty ACR | Re-run phase 1 with false, then the image phase. See Greenfield |
ResourceGroupNotFound early in full-app-deploy-commercial | config | The workflow's region input does not match the estate's region | Pass -f region=<your-region> |
ResourceGroupNotFound on a multi-sub DLZ | config | A sub-scoped deploy cannot create an RG in a remote subscription | bash scripts/csa-loom/bootstrap-dlz-rgs.sh first |
ContainerAppOperationInProgress | transient | A previous roll is still settling | Wait and retry; serialize rolls, never cancel an in-flight roll of the same SHA |
Remediation¶
- Triage — note the failed module + Azure error code
- Classify — match the code to a class in Failure recovery. Retrying a
quotaorconfigfailure cannot help - Apply fix — per table above
- Resume — re-run the same
az deployment sub createwith the fix added. The deployment is incremental and idempotent, so it resumes rather than restarts. Two exceptions: a partially-created Private DNS zone or a taken global name must be removed first, and reconciling an existing hub through the workflow needsallow_existing_hub=trueand aregionthat matches the estate (a mismatched region is refused — #3029).keep_resourcesdefaults totrue; a teardown also needsconfirm_teardown_rg=rg-csa-loom-admin-<region>(#3028) - Verify —
curl <console-url>/api/healthreturns 200, then open the Console and confirm sign-in and/admin/readiness
Private DNS zone-owner migration (#3039 / #3046)¶
scripts/csa-loom/migrate-private-dns-zone-owner.mjs adopts a privatelink.* namespace onto the zone that should own it, without taking the service dark. It is dry-run by default; --apply executes.
node scripts/csa-loom/migrate-private-dns-zone-owner.mjs \
--namespace privatelink.azuredatabricks.net \
--keep-zone-rg rg-csa-loom-admin-<region> --keep-zone-subscription <sub> \
--stale-zone-rg rg-csa-loom-dlz-default-<region> --stale-zone-subscription <sub> \
[--pe-resource-group <rg> …] # extra scopes to search for private endpoints
The plan is 10 steps; the two terminal verifications and the removal guard are the part to understand, because their absence caused a live outage.
| # | step | what it does |
|---|---|---|
| 1 | ensure-keep-zone | creates the surviving zone if it is missing |
| 2 | dual-register | adds a config pointing at the keep zone, under a name nothing else in the group owns (<ns>-loom-keep). The record is then in both zones |
| 3 | verify-record-in-keep-zone | hard gate — refuses to unlink anything until the record is provably in the keep zone |
| 4–5 | unlink-stale → link-keep | moves each VNet link. A resolution gap of seconds: Azure permits one link per namespace per VNet |
| 6 | verify-links-on-keep-zone | hard gate — every VNet the stale zone served resolves on the keep zone |
| 7 | single-register | removes only the config that still points at the stale zone, selected by the name read from the estate |
| 8 | verify-zone-group-bound-to-keep | terminal gate — the group still carries a config on the keep zone |
| 9 | verify-record-after-single-register | terminal gate — the A records are still in the keep zone |
| 10 | delete-stale-zone | the stale zone goes last |
Why steps 2, 7, 8 and 9 look the way they do (#3046)
az network private-endpoint dns-zone-group add|remove --zone-name selects the config by its name, never by the zone id (measured from the az CLI source: _add.py / _remove.py, SubresourceSelector). add replaces a config whose name matches and only appends when it does not.
The first cut of this script passed <namespace with dots→dashes> for both. That string is byte-identical to the config name the bicep creates (landing-zone/databricks.bicep:135), so step 2 replaced the stale config instead of appending — dual-registration never happened — and step 7 then removed the only remaining config, leaving the group empty, which deregisters the endpoint's A record from every zone. The service went dark and the script exited 0, because nothing ran after step 7.
Now: step 2 uses a name nothing owns and refuses if that name is taken; step 7 refuses to remove a config that no longer points at the stale zone, that it cannot re-read, or whose removal would leave the group empty or with no config on the keep zone; steps 8 and 9 re-check the outcome before anything is deleted. Every destructive step re-reads its subject immediately before acting, so a re-run converges rather than re-applying.
What the verifications do NOT prove. They read ARM — the record set exists, the links exist, the group is bound. They do not prove the name resolves from inside the hub VNet; a hosted runner is not in the VNet. For that, run a lookup from the in-VNet runner (loom-aca-runner-smoke.yml) or over the admin P2S VPN.
Empty-zone shadowing. Step 1 creates the keep zone but does not link it, and step 5 links it only after step 3 has proved the record is there — so the migration never leaves an empty zone linked to a VNet. That matters: a linked but empty privatelink.* zone shadows public resolution for the whole namespace and returns NXDOMAIN rather than falling through (the Gov Purview incident). If you stop the migration part-way, an unlinked empty keep zone is harmless; a linked empty one is not.
Prevention¶
- Run
bicep what-if(az deployment sub create … --what-if) before every deploy - Pre-check quotas:
az vm list-usage -l <region> - Pre-check the deploy identity's roles and the six resource-provider registrations — see Failure recovery → preflight
- On a brownfield estate, validate adoption candidates (HNS, SKU, deployments, metastore assignment) before the deploy — see Brownfield → what is validated
- Keep
.bicepparamfiles under Git review
Escalation¶
If the error doesn't match the table above: - Open GitHub issue with label csa-loom + csa-bug. Attach the redacted evidence: the default (non---json) deploy-arm-errors.mjs render, or the deploy-failure.json artifact. Do not paste raw az deployment operation … -o json or --json output, and do not paste the run log's "full captured stderr" block — this repo is public, that output is the command's own and is therefore unredacted, and it carries subscription, tenant and Entra object ids (#3829) - An unclassifiable failure is also a gap in the taxonomy — say so in the issue so the class gets added - Internal Microsoft: #csa-loom-build Teams channel