Lab 20: Configure Azure DNS and secure administration with Bastion¶
Previous: Lab 19 · Catalog · Next: Lab 21
This self-contained lab uses Azure CLI commands hosted in PowerShell. Complete the guided lane or the automated lane—not both with the same run ID.
Scenario, role, and outcome¶
Scenario: You are a network administrator providing secure management and authoritative DNS. Build and prove this disposable service path: Azure Bastion consumes the dedicated AzureBastionSubnet and Standard public IP while workload access stays private; the child Azure DNS zone hosts A/CNAME/TXT records and, only behind an exact gate, its NS set is delegated from a verified Azure DNS parent.
Learner role: A network administrator providing secure management and authoritative DNS.
Outcome: Use Azure CLI in PowerShell to create the VNet, AzureBastionSubnet, and DNS zone, publish and resolve run-owned DNS records, and reconcile DNS authority, records, and secure administration, with recoverable state and deterministic cleanup.
| Item | Value |
|---|---|
| Duration | 120 minutes |
| Difficulty | applied |
| Cost class | elevated |
| Command surface | Azure CLI (az, az rest, Bicep, AzCopy, or KQL where required) hosted in PowerShell |
| Live state | Not executed during this offline rebuild |
Completion criteria:
- All required checkpoints report pass.
- The deterministic break/fix is injected, diagnosed, and repaired.
- cleanup.json reports no active run-owned resources, with any retained item explicitly documented.
Objectives and checkpoints¶
| Objective | Skill | Checkpoints |
|---|---|---|
NW-SECURE-03 |
Implement Azure Bastion | LAB20-CP01, LAB20-CP03, LAB20-CP05 |
NW-DNSLB-01 |
Configure Azure DNS | LAB20-CP02, LAB20-CP04, LAB20-CP05 |
The authoritative objective wording comes from the Microsoft AZ-104 study guide, skills measured as of 2026-04-17.
Architecture and service topology¶
The editable source is architecture.mmd. Azure Bastion consumes the dedicated AzureBastionSubnet and Standard public IP while workload access stays private; the child Azure DNS zone hosts A/CNAME/TXT records and, only behind an exact gate, its NS set is delegated from a verified Azure DNS parent.
Concept primer and design decisions¶
Azure Bastion requires a correctly named subnet and Standard public IP. Azure DNS hosts authoritative records, but public resolution depends on delegation from the parent zone; an isolated example zone can teach record management without changing a real domain.
Design decisions:
- Use AzureBastionSubnet /26 or larger.
- Use Bastion Standard for scale and native-client features.
- Delegate only an explicitly owned DNS zone.
Required and optional inputs¶
| Input | Required | Source | Safe example | Gate behavior |
|---|---|---|---|---|
run-id — Unique lowercase run ownership identifier |
Yes | parameter | az104l20-01 |
block |
subscription-id — Expected disposable subscription ID |
Yes | parameter-or-environment (AZ104_SUBSCRIPTION_ID) |
00000000-0000-0000-0000-000000000000 |
block |
location — Approved primary Azure region |
Yes | parameter-or-environment (AZ104_LOCATION) |
westeurope |
block |
dns-parent-zone — Existing Azure DNS parent zone that the learner is authorized to change |
No | environment (AZ104_DNS_PARENT_ZONE) |
lab.example.com |
skip-checkpoint |
dns-parent-resource-group — Resource group containing the authorized parent zone |
No | environment (AZ104_DNS_PARENT_RESOURCE_GROUP) |
rg-dns-authority |
skip-checkpoint |
dns-parent-zone-id — Exact ARM ID used to prove the supplied parent-zone boundary |
No | environment (AZ104_DNS_PARENT_ZONE_ID) |
/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-dns-authority/providers/Microsoft.Network/dnsZones/lab.example.com |
skip-checkpoint |
authorize-dns-delegation — Exact YES authorization to add the run-owned child NS set to the verified parent |
No | environment (AZ104_AUTHORIZE_DNS_DELEGATION) |
YES |
skip-checkpoint |
Secrets stay in temporary environment variables and are never written to run.json, validation evidence, or Git. A skip-checkpoint gate produces a visible partial result; it never becomes a pass.
Read-only preflight¶
Sign in deliberately, inspect the active context, then run the lab preflight. It never signs in, changes context, installs an extension, registers a provider, or creates a resource.
az login
az account show --query '{cloud:environmentName,subscription:id,tenant:tenantId,user:user.name}' --output json
./scripts/cli/Preflight.ps1 -SubscriptionId $env:AZ104_SUBSCRIPTION_ID -RunId 'az104l20-01' -Location 'westeurope'
Representative redacted output:
context True cloud=AzureCloud; tenant=<redacted>; subscription=<redacted>
azure-cli True 2.88.x
provider:<namespace> True Registered
region True westeurope
Preflight passed. It performed no Azure mutation.
If a required row fails, stop. Correct the local tool, context, provider, quota, SKU, region, permission, or input outside the lab, then rerun preflight.
Task 1 — Verify DNS-zone authority and Bastion regional support¶
Checkpoint: LAB20-CP01
Purpose and operational relevance: Read the provider, region, SKU, feature, quota, and companion-tool signals needed for verify dns-zone authority and bastion regional support, without changing Azure state. The change is unsafe when attempting public delegation without authority over the parent zone produces unverifiable DNS state. This checkpoint therefore proves: The region supports Azure Bastion and the DNS exercise uses either example.invalid or an explicitly owned parent-zone gate.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
Expected state: The region supports Azure Bastion and the DNS exercise uses either example.invalid or an explicitly owned parent-zone gate.
Representative redacted output:
The region supports Azure Bastion and the DNS exercise uses either example.invalid or an explicitly owned parent-zone gate.
Positive validation:
$registration = az provider show --namespace Microsoft.Network --query registrationState --output tsv
if ($registration -ne 'Registered') { throw 'Microsoft.Network is not registered.' }
Expected positive result: The region supports Azure Bastion and the DNS exercise uses either example.invalid or an explicitly owned parent-zone gate.
Negative validation:
$locationKnown = az account list-locations --query "[?name=='$Location'] | length(@)" --output tsv
if ([int]$locationKnown -ne 1) { throw 'The selected location is unavailable.' }
Expected negative result: LAB20-CP01 negative boundary: The undesired state is absent; the negative assertion must not report: The selected location is unavailable.
Evidence to retain:
- LAB20-CP01 UTC result for verify dns-zone authority and bastion regional support.
- Exact run-owned resource/object ID returned by the commands in LAB20-CP01.
- Positive assertion proving: The region supports Azure Bastion and the DNS exercise uses either example.invalid or an explicitly owned parent-zone gate.
- Negative assertion proving absence of: The selected location is unavailable.
Common failure and safe retry: Attempting public delegation without authority over the parent zone produces unverifiable DNS state. Retry LAB20-CP01 for verify dns-zone authority and bastion regional support only after its exact recorded ID and current property are read. If the ID exists, reissue only the idempotent update; if it is absent, repeat only the owning create command, then rerun both assertions.
Cleanup dependency: After LAB20-CP02, LAB20-CP03, LAB20-CP04, LAB20-CP05, remove the exact manifest IDs created or configured by LAB20-CP01 for verify dns-zone authority and bastion regional support; its residual probe must then return zero active IDs.
Task 2 — Create the VNet, AzureBastionSubnet, and DNS zone¶
Checkpoint: LAB20-CP02
Purpose and operational relevance: Provision and immediately inventory the exact run-owned resources needed to create the VNet, AzureBastionSubnet, and DNS zone, so partial completion remains recoverable. The change is unsafe when Azure Bastion rejects an incorrectly named or undersized AzureBastionSubnet. This checkpoint therefore proves: The Bastion subnet uses the required name and prefix, while the run-owned DNS zone exposes authoritative name servers.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
$vnet = "vnet-$suffix"; $publicIp = "pip-$suffix"; $bastion = "bas-$suffix"
$childLabel = "az104-$suffix"
$zone = $(if ($env:AZ104_DNS_PARENT_ZONE) { "$childLabel.$($env:AZ104_DNS_PARENT_ZONE)" } else { "lab$suffix.example.invalid" })
az network vnet create --resource-group $ResourceGroupName --name $vnet --address-prefixes 10.20.0.0/16 --subnet-name AzureBastionSubnet --subnet-prefixes 10.20.0.0/26 --output none
az network public-ip create --resource-group $ResourceGroupName --name $publicIp --sku Standard --allocation-method Static --output none
az network bastion create --resource-group $ResourceGroupName --name $bastion --vnet-name $vnet --public-ip-address $publicIp --location $Location --sku Standard --scale-units 2 --output none
az network dns zone create --resource-group $ResourceGroupName --name $zone --output none
az network dns record-set a add-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name app --ipv4-address 192.0.2.10 --output none
az network dns record-set txt add-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name verify --value "az104-$RunId" --output none
Expected state: The Bastion subnet uses the required name and prefix, while the run-owned DNS zone exposes authoritative name servers.
Representative redacted output:
The Bastion subnet uses the required name and prefix, while the run-owned DNS zone exposes authoritative name servers.
Positive validation:
$bastionState = az network bastion show --resource-group $ResourceGroupName --name $bastion --query provisioningState --output tsv
if ($bastionState -ne 'Succeeded') { throw "Bastion state is $bastionState." }
Expected positive result: The Bastion subnet uses the required name and prefix, while the run-owned DNS zone exposes authoritative name servers.
Negative validation:
$prefix = az network vnet subnet show --resource-group $ResourceGroupName --vnet-name $vnet --name AzureBastionSubnet --query addressPrefix --output tsv
if ($prefix -ne '10.20.0.0/26') { throw "Bastion subnet prefix is $prefix." }
Expected negative result: LAB20-CP02 negative boundary: The undesired state is absent; the negative assertion must not report: Bastion subnet prefix is reported prefix.
Evidence to retain:
- LAB20-CP02 UTC result for create the VNet, AzureBastionSubnet, and DNS zone.
- Exact run-owned resource/object ID returned by the commands in LAB20-CP02.
- Positive assertion proving: The Bastion subnet uses the required name and prefix, while the run-owned DNS zone exposes authoritative name servers.
- Negative assertion proving absence of: Bastion subnet prefix is reported prefix.
Common failure and safe retry: Azure Bastion rejects an incorrectly named or undersized AzureBastionSubnet. Retry LAB20-CP02 for create the VNet, AzureBastionSubnet, and DNS zone only after its exact recorded ID and current property are read. If the ID exists, reissue only the idempotent update; if it is absent, repeat only the owning create command, then rerun both assertions.
Cleanup dependency: After LAB20-CP03, LAB20-CP04, LAB20-CP05, remove the exact manifest IDs created or configured by LAB20-CP02 for create the VNet, AzureBastionSubnet, and DNS zone; its residual probe must then return zero active IDs.
Task 3 — Publish and resolve run-owned DNS records¶
Checkpoint: LAB20-CP03
Purpose and operational relevance: Configure and independently inspect the control-plane and data-path properties needed to publish and resolve run-owned dns records. The change is unsafe when record creation in the child zone does not by itself configure delegation at the parent. This checkpoint therefore proves: The A and CNAME records return the documented values and no record is added to an unverified parent zone.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
az network dns record-set cname set-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name www --cname "app.$zone" --output none
az network dns record-set list --resource-group $ResourceGroupName --zone-name $zone --query "[?name=='app' || name=='verify' || name=='www'].{name:name,type:type}" --output table
Expected state: The A and CNAME records return the documented values and no record is added to an unverified parent zone.
Representative redacted output:
The A and CNAME records return the documented values and no record is added to an unverified parent zone.
Positive validation:
$cname = az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
if ($cname -ne "app.$zone") { throw "CNAME target is $cname." }
Expected positive result: The A and CNAME records return the documented values and no record is added to an unverified parent zone.
Negative validation:
$nameServerCount = az network dns zone show --resource-group $ResourceGroupName --name $zone --query "nameServers | length(@)" --output tsv
if ([int]$nameServerCount -lt 4) { throw "Only $nameServerCount authoritative name servers were returned." }
Expected negative result: LAB20-CP03 negative boundary: The undesired state is absent; the negative assertion must not report: Only reported nameServerCount authoritative name servers were returned.
Evidence to retain:
- LAB20-CP03 UTC result for publish and resolve run-owned dns records.
- Exact run-owned resource/object ID returned by the commands in LAB20-CP03.
- Positive assertion proving: The A and CNAME records return the documented values and no record is added to an unverified parent zone.
- Negative assertion proving absence of: Only reported nameServerCount authoritative name servers were returned.
Common failure and safe retry: Record creation in the child zone does not by itself configure delegation at the parent. Retry LAB20-CP03 for publish and resolve run-owned dns records only after its exact recorded ID and current property are read. If the ID exists, reissue only the idempotent update; if it is absent, repeat only the owning create command, then rerun both assertions.
Cleanup dependency: After LAB20-CP04, LAB20-CP05, remove the exact manifest IDs created or configured by LAB20-CP03 for publish and resolve run-owned dns records; its residual probe must then return zero active IDs.
Task 4 — Delegate the run-owned child zone from a verified Azure DNS parent¶
Checkpoint: LAB20-CP04
Purpose and operational relevance: Exercise the deterministic operational or denied path needed to delegate the run-owned child zone from a verified azure dns parent, then retain comparable before-and-after results. The change is unsafe when a child zone can contain correct records while public queries still fail because its parent lacks the matching NS delegation. This checkpoint therefore proves: The optional parent NS record contains exactly the child zone's authoritative name servers and its exact returned ID is recorded for cleanup.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
$external.parentZoneName = $env:AZ104_DNS_PARENT_ZONE
$external.parentZoneResourceGroup = $env:AZ104_DNS_PARENT_RESOURCE_GROUP
$external.parentZoneId = $env:AZ104_DNS_PARENT_ZONE_ID
$external.delegationRecordName = "az104-$suffix"
$liveParentId = az network dns zone show --resource-group $external.parentZoneResourceGroup --name $external.parentZoneName --query id --output tsv
if (-not [string]::Equals($liveParentId, $external.parentZoneId, [StringComparison]::OrdinalIgnoreCase)) {
throw 'The live parent-zone ID does not match the explicitly authorized ARM ID.'
}
$childServers = @(az network dns zone show --resource-group $ResourceGroupName --name $zone --query nameServers --output tsv)
if ($childServers.Count -lt 4) { throw 'The child zone did not return its authoritative name servers.' }
az network dns record-set ns create --resource-group $external.parentZoneResourceGroup --zone-name $external.parentZoneName --record-set-name $external.delegationRecordName --ttl 300 --output none
foreach ($server in $childServers) {
az network dns record-set ns add-record --resource-group $external.parentZoneResourceGroup --zone-name $external.parentZoneName --record-set-name $external.delegationRecordName --nsdname $server --output none
}
$external.delegationRecordId = az network dns record-set ns show --resource-group $external.parentZoneResourceGroup --zone-name $external.parentZoneName --name $external.delegationRecordName --query id --output tsv
Expected state: The optional parent NS record contains exactly the child zone's authoritative name servers and its exact returned ID is recorded for cleanup.
Representative redacted output:
Parent and child zone IDs match the approved subscription; the child-label NS set contains the four Azure DNS authorities.
Positive validation:
$delegatedServers = @(az network dns record-set ns show --resource-group $state.inputs['external-parent-zone-resource-group'] --zone-name $state.inputs['external-parent-zone-name'] --name $state.inputs['external-delegation-record-name'] --query "NSRecords[].nsdname" --output tsv)
$childServers = @(az network dns zone show --resource-group $ResourceGroupName --name $zone --query nameServers --output tsv)
if (@($childServers | Where-Object { $_ -notin $delegatedServers }).Count -ne 0) { throw 'The parent delegation omits a child authoritative server.' }
Expected positive result: The optional parent NS record contains exactly the child zone's authoritative name servers and its exact returned ID is recorded for cleanup.
Negative validation:
$liveDelegationId = az network dns record-set ns show --resource-group $state.inputs['external-parent-zone-resource-group'] --zone-name $state.inputs['external-parent-zone-name'] --name $state.inputs['external-delegation-record-name'] --query id --output tsv
if (-not [string]::Equals($liveDelegationId, $state.inputs['external-delegation-record-id'], [StringComparison]::OrdinalIgnoreCase)) { throw 'The parent NS record is outside the recorded boundary.' }
Expected negative result: LAB20-CP04 negative boundary: The undesired state is absent; the negative assertion must not report: The parent NS record is outside the recorded boundary.
Evidence to retain:
- LAB20-CP04 UTC result for delegate the run-owned child zone from a verified azure dns parent.
- Exact run-owned resource/object ID returned by the commands in LAB20-CP04.
- Positive assertion proving: The optional parent NS record contains exactly the child zone's authoritative name servers and its exact returned ID is recorded for cleanup.
- Negative assertion proving absence of: The parent NS record is outside the recorded boundary.
Common failure and safe retry: A child zone can contain correct records while public queries still fail because its parent lacks the matching NS delegation. Retry LAB20-CP04 for delegate the run-owned child zone from a verified azure dns parent only after its exact recorded ID and current property are read. If the ID exists, reissue only the idempotent update; if it is absent, repeat only the owning create command, then rerun both assertions.
Cleanup dependency: After LAB20-CP05, remove the exact manifest IDs created or configured by LAB20-CP04 for delegate the run-owned child zone from a verified azure dns parent; its residual probe must then return zero active IDs.
Task 5 — Reconcile DNS authority, records, and secure administration¶
Checkpoint: LAB20-CP05
Purpose and operational relevance: Reconcile live service properties, returned IDs, and dependency-aware removal needed to reconcile dns authority, records, and secure administration before handoff. The change is unsafe when a working A record does not prove public delegation; the authoritative parent NS set must be checked separately when gated. This checkpoint therefore proves: Zone name servers, record answers, Bastion provisioning state, and subnet/public-IP placement match the intended topology.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
az network dns zone show --resource-group $ResourceGroupName --name $zone --query "{zone:name,nameServers:nameServers,recordSets:numberOfRecordSets}" --output json
Expected state: Zone name servers, record answers, Bastion provisioning state, and subnet/public-IP placement match the intended topology.
Representative redacted output:
Zone name servers, record answers, Bastion provisioning state, and subnet/public-IP placement match the intended topology.
Positive validation:
$allocation = az network public-ip show --resource-group $ResourceGroupName --name $publicIp --query publicIPAllocationMethod --output tsv
if ($allocation -ne 'Static') { throw "Bastion public IP allocation is $allocation." }
Expected positive result: Zone name servers, record answers, Bastion provisioning state, and subnet/public-IP placement match the intended topology.
Negative validation:
$wrongTarget = az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
if ($wrongTarget -ne "app.$zone") { throw 'The CNAME target does not match the application label.' }
Expected negative result: LAB20-CP05 negative boundary: The undesired state is absent; the negative assertion must not report: The CNAME target does not match the application label.
Evidence to retain:
- LAB20-CP05 UTC result for reconcile dns authority, records, and secure administration.
- Exact run-owned resource/object ID returned by the commands in LAB20-CP05.
- Positive assertion proving: Zone name servers, record answers, Bastion provisioning state, and subnet/public-IP placement match the intended topology.
- Negative assertion proving absence of: The CNAME target does not match the application label.
Common failure and safe retry: A working A record does not prove public delegation; the authoritative parent NS set must be checked separately when gated. Retry LAB20-CP05 for reconcile dns authority, records, and secure administration only after its exact recorded ID and current property are read. If the ID exists, reissue only the idempotent update; if it is absent, repeat only the owning create command, then rerun both assertions.
Cleanup dependency: After all service validation and evidence capture, remove the exact manifest IDs created or configured by LAB20-CP05 for reconcile dns authority, records, and secure administration; its residual probe must then return zero active IDs.
Final validation and result interpretation¶
Run independent deployment validation after all required checkpoints:
./scripts/cli/Validate.ps1 -RunId 'az104l20-01' -Mode Deployment
az resource list --resource-group 'rg-az104-l20-az104l20-01' --query '[].{name:name,type:type}' --output table
pass: every required positive and negative check passed.partial: every required check passed, but at least one optional gate was deliberately skipped.fail: a required checkpoint failed; do not record the lab as complete.
Keep the redacted validation.json; do not retain credentials, tokens, access keys, certificate material, email addresses, tenant IDs, or unredacted command output.
Deterministic break/fix exercise¶
Injection: Create a CNAME that points to a misspelled target label.
Inject the bounded fault:
az network dns record-set cname set-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name www --cname "missing-app.$zone" --output none
Capture the failed state with a read-only query:
az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
Repair only the injected setting, then repeat the same query:
az network dns record-set cname set-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name www --cname "app.$zone" --output none
az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
Expected symptom: Resolution returns the wrong chain or NXDOMAIN at the target.
Diagnose with a read-only service query:
az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
Diagnosis: List record sets and query authoritative name servers.
Repair: Correct the CNAME target and verify after TTL propagation.
Before/after evidence must show the failed negative or positive check before repair and the same check passing afterward. Do not inject a second fault until the first is removed.
Optional job-style challenge¶
Produce a secure administrator-access design with Bastion, least-privilege RBAC, NSGs, and DNS.
Deliver a short change record containing assumptions, exact commands, redacted evidence, cost and risk notes, rollback, and residual results. The challenge is optional and never changes required checkpoint status.
Troubleshooting¶
| Symptom | Likely cause | Safe next step |
|---|---|---|
| Context mismatch | Active CLI context differs from run.json |
Stop, inspect az account show, and select the intended disposable context yourself. |
| Provider or feature unavailable | Registration, region, feature, or SKU gate is unmet | Read the preflight result; do not register or enable tenant features implicitly. |
| Name already exists | The run ID is reused or a globally unique name collided | Keep existing state intact and choose a new run ID. |
| Expected state is delayed | The service is converging asynchronously | Repeat only the read-only query with bounded retries; do not duplicate creation. |
| Permission denied | Role, Graph scope, or data-plane authorization is insufficient | Confirm the declared least-privilege boundary; do not broaden access automatically. |
| Cleanup refuses ownership | ID, context, or tags differ from the manifest | Investigate the mismatch; never bypass ownership verification. |
Cleanup and residual verification¶
Preview exact targets first, execute only after ownership review, then validate post-cleanup:
./scripts/cli/Cleanup.ps1 -RunId 'az104l20-01'
./scripts/cli/Cleanup.ps1 -RunId 'az104l20-01' -Execute
./scripts/cli/Validate.ps1 -RunId 'az104l20-01' -Mode PostCleanup
$wrongTarget = az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
if ($wrongTarget -ne "app.$zone") { throw 'The CNAME target does not match the application label.' }
cleanup.json passes only when no active manifest-managed object remains. Soft-deleted or intentionally retained items must be listed with their reason and expected disposition. The lifecycle never performs irreversible purge automatically.
Exam debrief and assessment¶
Explain why the expected state, negative check, and cleanup boundary matter—not only which command was used. Map any missed concept back to its task anchor before reviewing the answer key.
Complete QUESTIONS.md, then use ANSWERS.md for option-by-option remediation. Scores of 85–100% indicate mastery, 70–84% indicate targeted review, and below 70% means repeat the mapped tasks.
Microsoft Learn sources¶
- Microsoft AZ-104 study guide
- Create Azure Bastion with Azure CLI
- Azure Bastion configuration settings
- Create an Azure DNS zone and record with Azure CLI
- Delegate an Azure DNS subdomain with Azure CLI
Lifecycle script appendix¶
The guided task blocks above and these executable scripts are generated from the same checkpoint data. The scripts are an optional automated lane; use a different run ID if you already completed the guided lane.
Preflight.ps1¶
# BEGIN GENERATED AZ104 V2
#requires -Version 7.4
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive lab progress is intentionally written to the host.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'All lifecycle scripts expose a stable cross-lab interface.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Generated task variables are intentionally shared across checkpoint blocks.')]
param(
[string]$SubscriptionId = $env:AZ104_SUBSCRIPTION_ID,
[Parameter(Mandatory)][ValidatePattern('^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$')][string]$RunId,
[Parameter(Mandatory)][ValidatePattern('^[a-z0-9]+$')][string]$Location,
[string]$SecondaryLocation = $env:AZ104_SECONDARY_LOCATION
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is required. Run the repository readiness initializer, then retry.'
}
$account = az account show --output json | ConvertFrom-Json
if (-not $account) { throw 'No active Azure CLI context. Run az login deliberately before this lab.' }
if ($SubscriptionId -and [string]$account.id -ne $SubscriptionId) {
throw "Context mismatch: active subscription is $($account.id), expected $SubscriptionId. Preflight will not switch it."
}
if (-not $SubscriptionId) { $SubscriptionId = [string]$account.id }
# The account read above is the mandatory context gate. All remaining Azure
# probes are collected as results instead of allowing one unavailable SKU or
# provider query to abort the readiness report before it can explain the gap.
$PSNativeCommandUseErrorActionPreference = $false
$suffix = (($RunId -replace '[^a-z0-9]', '') + '000000000000').Substring(0, 12)
$ResourceGroupName = $(if ('20' -in @('00', '01', '02')) { $null } else { "rg-az104-l20-$RunId" })
$checks = [System.Collections.Generic.List[object]]::new()
function Add-PreflightResult {
param([string]$Id, [bool]$Required, [bool]$Passed, [string]$Actual)
$checks.Add([pscustomobject]@{ id = $Id; required = $Required; passed = $Passed; actual = $Actual })
}
Add-PreflightResult -Id 'context' -Required $true -Passed $true -Actual "cloud=$($account.environmentName); tenant=<redacted>; subscription=<redacted>"
$azVersion = (az version --query '"azure-cli"' --output tsv)
$bicepVersion = (az bicep version 2>&1 | Out-String).Trim()
Add-PreflightResult -Id 'azure-cli' -Required $true -Passed ([bool]$azVersion) -Actual $azVersion
Add-PreflightResult -Id 'powershell' -Required $true -Passed ($PSVersionTable.PSVersion -ge [version]'7.4') -Actual $PSVersionTable.PSVersion.ToString()
Add-PreflightResult -Id 'bicep' -Required $true -Passed ([bool]$bicepVersion) -Actual $bicepVersion
$requiredEnvironmentVariables = @()
foreach ($variableName in $requiredEnvironmentVariables) {
$present = -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable($variableName))
Add-PreflightResult -Id "input:$variableName" -Required $true -Passed $present -Actual $(if ($present) { 'present (value redacted)' } else { 'missing' })
}
$providers = @(
'Microsoft.Network'
)
foreach ($provider in $providers) {
$registrationState = az provider show --namespace $provider --query registrationState --output tsv 2>$null
Add-PreflightResult -Id "provider:$provider" -Required $true -Passed ($registrationState -eq 'Registered') -Actual $(if ($registrationState) { $registrationState } else { 'Unavailable' })
}
$knownLocation = az account list-locations --query "[?name=='$Location'].name | [0]" --output tsv
Add-PreflightResult -Id 'region' -Required $true -Passed ($knownLocation -eq $Location) -Actual $(if ($knownLocation) { $knownLocation } else { 'not available' })
$gatePresent = -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable('AZ104_DNS_PARENT_RESOURCE_GROUP'))
Add-PreflightResult -Id 'optional-gate:AZ104_DNS_PARENT_RESOURCE_GROUP' -Required $false -Passed $gatePresent -Actual $(if ($gatePresent) { 'present (value redacted)' } else { 'absent; checkpoint will be skipped' })
$gatePresent = -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable('AZ104_DNS_PARENT_ZONE'))
Add-PreflightResult -Id 'optional-gate:AZ104_DNS_PARENT_ZONE' -Required $false -Passed $gatePresent -Actual $(if ($gatePresent) { 'present (value redacted)' } else { 'absent; checkpoint will be skipped' })
$gatePresent = -not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable('AZ104_DNS_PARENT_ZONE_ID'))
Add-PreflightResult -Id 'optional-gate:AZ104_DNS_PARENT_ZONE_ID' -Required $false -Passed $gatePresent -Actual $(if ($gatePresent) { 'present (value redacted)' } else { 'absent; checkpoint will be skipped' })
$gateMatches = [string]::Equals([Environment]::GetEnvironmentVariable('AZ104_AUTHORIZE_DNS_DELEGATION'), 'YES', [StringComparison]::Ordinal)
Add-PreflightResult -Id 'optional-gate:AZ104_AUTHORIZE_DNS_DELEGATION' -Required $false -Passed $gateMatches -Actual $(if ($gateMatches) { 'exact affirmative value supplied (value redacted)' } else { 'absent or not exact; checkpoint will be skipped' })
$probePassed = $true
$probeActual = 'assertion passed (output not persisted)'
try {
$LASTEXITCODE = 0
& {
$registration = az provider show --namespace Microsoft.Network --query registrationState --output tsv
if ($registration -ne 'Registered') { throw 'Microsoft.Network is not registered.' }
$locationKnown = az account list-locations --query "[?name=='$Location'] | length(@)" --output tsv
if ([int]$locationKnown -ne 1) { throw 'The selected location is unavailable.' }
} | Out-Null
if ($LASTEXITCODE -ne 0) { throw "native exit code $LASTEXITCODE" }
} catch {
$probePassed = $false
$probeActual = "assertion failed: $($_.Exception.GetType().Name)"
}
Add-PreflightResult -Id 'preflight.authored.service' -Required $true -Passed $probePassed -Actual $probeActual
$checks | Format-Table -AutoSize
$failedRequired = @($checks | Where-Object { $_.required -and -not $_.passed })
if ($failedRequired.Count -gt 0) {
throw "Preflight blocked: $($failedRequired.id -join ', ')"
}
Write-Host 'Preflight passed. It performed no sign-in, context switch, provider registration, or Azure mutation.'
# END GENERATED AZ104 V2
Setup.ps1¶
# BEGIN GENERATED AZ104 V2
#requires -Version 7.4
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive lab progress is intentionally written to the host.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'All lifecycle scripts expose a stable cross-lab interface.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Generated task variables are intentionally shared across checkpoint blocks.')]
param(
[string]$SubscriptionId = $env:AZ104_SUBSCRIPTION_ID,
[Parameter(Mandatory)][ValidatePattern('^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$')][string]$RunId,
[Parameter(Mandatory)][ValidatePattern('^[a-z0-9]+$')][string]$Location,
[string]$SecondaryLocation = $env:AZ104_SECONDARY_LOCATION,
[switch]$AcknowledgeCost,
[switch]$AcknowledgeTenantChange,
[switch]$Execute
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is required. Run the repository readiness initializer, then retry.'
}
$LabRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
$StateDir = Join-Path $LabRoot ".state/$RunId"
$Manifest = Join-Path $StateDir 'run.json'
$ResourceGroupName = $(if ('20' -in @('00', '01', '02')) { $null } else { "rg-az104-l20-$RunId" })
$suffix = (($RunId -replace '[^a-z0-9]', '') + '000000000000').Substring(0, 12)
Write-Host 'LAB-20 execution plan'
Write-Host " subscription: $SubscriptionId"
Write-Host " location: $Location"
Write-Host ' command surface: Azure CLI hosted in PowerShell'
Write-Host ' state: run.json, validation.json, cleanup.json'
if (-not $Execute) {
Write-Host 'Preview only. Review context, inputs, cost, tenant scope, and cleanup before using -Execute.'
return
}
if ($true -and -not $AcknowledgeCost) { throw 'This lab requires -AcknowledgeCost before execution.' }
if ($true -and -not $AcknowledgeTenantChange) { throw 'This lab requires -AcknowledgeTenantChange before execution.' }
& (Join-Path $PSScriptRoot 'Preflight.ps1') -SubscriptionId $SubscriptionId -RunId $RunId -Location $Location -SecondaryLocation $SecondaryLocation
if (Test-Path -LiteralPath $Manifest) { throw "State already exists at $Manifest. Choose a new run ID." }
New-Item -ItemType Directory -Path $StateDir -Force | Out-Null
$account = az account show --output json | ConvertFrom-Json
if (-not $SubscriptionId) { $SubscriptionId = [string]$account.id }
$now = (Get-Date).ToUniversalTime().ToString('o')
$state = @{
schemaVersion = '1.0.0'
labId = 'LAB-20'
runId = $RunId
createdAt = $now
updatedAt = $now
status = 'initialized'
context = @{ cloud = [string]$account.environmentName; tenantId = [string]$account.tenantId; subscriptionId = [string]$SubscriptionId }
acknowledgements = @{ cost = [bool]$AcknowledgeCost; tenantChange = [bool]$AcknowledgeTenantChange }
inputs = @{ 'location' = $Location; 'secondary-location' = $SecondaryLocation }
checkpointStates = @(
@{ checkpointId = 'LAB20-CP01'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB20-CP02'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB20-CP03'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB20-CP04'; required = $false; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB20-CP05'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
)
managedObjects = @()
originalSettings = @()
}
$external = @{}
function Save-RunState {
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
}
function Save-ExternalState {
foreach ($key in @($external.Keys)) {
$value = $external[$key]
if ($null -eq $value -or $value -is [string] -or $value -is [ValueType]) {
$inputKey = 'external-' + (([string]$key -creplace '([a-z0-9])([A-Z])', '$1-$2') -replace '[^a-zA-Z0-9-]', '-').ToLowerInvariant()
$state.inputs[$inputKey] = $value
}
}
Save-RunState
}
function Write-CheckpointState {
param([string]$CheckpointId, [string]$Status, [string]$Message)
$entry = @($state.checkpointStates | Where-Object { $_.checkpointId -eq $CheckpointId })[0]
$entry.status = $Status
$entry.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$entry.message = $Message
}
function Add-ManagedObject {
param([string]$CheckpointId, [string]$Kind, [string]$Id, [string]$Name, [string]$Type, [string]$Scope, [string]$OwnershipMethod)
if ([string]::IsNullOrWhiteSpace($Id)) { return }
$existing = @($state.managedObjects | Where-Object { $_.id -eq $Id })
if ($existing.Count -gt 0) { return }
$expectedTags = @{}
if ($OwnershipMethod -eq 'manifest-id-and-tags') {
$expectedTags = @{ purpose = 'az104-lab'; labId = '20'; runId = $RunId }
}
$state.managedObjects += @{
checkpointId = $CheckpointId
kind = $Kind
id = $Id
name = $Name
type = $Type
scope = $Scope
ownership = @{ method = $OwnershipMethod; expectedTags = $expectedTags }
recordedAt = (Get-Date).ToUniversalTime().ToString('o')
lifecycleStatus = 'active'
}
Save-RunState
}
function Sync-ManagedResource {
param([string]$CheckpointId)
# The entire recovery inventory is best effort so a state-helper or Azure
# query failure cannot mask the original mutation error. Every object that
# can be recovered is still persisted immediately as it is discovered.
$nativePreference = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
try {
Save-ExternalState
$resourceGroupNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
if ($ResourceGroupName) { $null = $resourceGroupNames.Add([string]$ResourceGroupName) }
foreach ($key in @($external.Keys | Where-Object { [string]$_ -match '(?i)ResourceGroupName$' })) {
if ($external[$key]) { $null = $resourceGroupNames.Add([string]$external[$key]) }
}
foreach ($groupName in $resourceGroupNames) {
$groupJson = az group show --subscription $SubscriptionId --name $groupName --output json 2>$null
$group = $(if ($LASTEXITCODE -eq 0 -and $groupJson) { $groupJson | ConvertFrom-Json } else { $null })
if ($group) {
$groupCheckpoint = $(if ([string]$group.name -eq [string]$ResourceGroupName) { 'LAB20-CP01' } else { $CheckpointId })
Add-ManagedObject -CheckpointId $groupCheckpoint -Kind 'azure-resource' -Id ([string]$group.id) -Name ([string]$group.name) -Type 'Microsoft.Resources/resourceGroups' -Scope "/subscriptions/$SubscriptionId" -OwnershipMethod 'manifest-id-and-tags'
$resourcesJson = az resource list --subscription $SubscriptionId --resource-group $groupName --output json 2>$null
$resources = @($(if ($LASTEXITCODE -eq 0 -and $resourcesJson) { $resourcesJson | ConvertFrom-Json } else { @() }))
foreach ($resource in $resources) {
Add-ManagedObject -CheckpointId $CheckpointId -Kind 'azure-resource' -Id ([string]$resource.id) -Name ([string]$resource.name) -Type ([string]$resource.type) -Scope ([string]$group.id) -OwnershipMethod 'manifest-id-and-tags'
}
}
}
if ($external.ContainsKey('groupId') -and $external.groupId) { Add-ManagedObject -CheckpointId $CheckpointId -Kind 'entra-object' -Id ([string]$external.groupId) -Name 'lab-group' -Type 'Microsoft.Graph/group' -Scope $state.context.tenantId -OwnershipMethod 'manifest-id' }
if ($external.ContainsKey('guestUserId') -and $external.guestUserId) { Add-ManagedObject -CheckpointId $CheckpointId -Kind 'entra-object' -Id ([string]$external.guestUserId) -Name 'guest-user' -Type 'Microsoft.Graph/user' -Scope $state.context.tenantId -OwnershipMethod 'manifest-id' }
if ($external.ContainsKey('userIds')) {
foreach ($userId in @($external.userIds)) { Add-ManagedObject -CheckpointId $CheckpointId -Kind 'entra-object' -Id ([string]$userId) -Name 'lab-user' -Type 'Microsoft.Graph/user' -Scope $state.context.tenantId -OwnershipMethod 'manifest-id' }
}
if ($external.ContainsKey('delegationRecordId') -and $external.delegationRecordId) {
Add-ManagedObject -CheckpointId $CheckpointId -Kind 'azure-resource' -Id ([string]$external.delegationRecordId) -Name ([string]$external.delegationRecordName) -Type 'Microsoft.Network/dnsZones/NS' -Scope ([string]$external.parentZoneId) -OwnershipMethod 'manifest-id'
}
if ($external.ContainsKey('connectionMonitorId') -and $external.connectionMonitorId) {
Add-ManagedObject -CheckpointId $CheckpointId -Kind 'azure-resource' -Id ([string]$external.connectionMonitorId) -Name ([string]$external.connectionMonitorName) -Type 'Microsoft.Network/networkWatchers/connectionMonitors' -Scope ([string]$external.networkWatcherId) -OwnershipMethod 'manifest-id'
}
} catch {
Write-Warning "Recovery inventory for $CheckpointId was incomplete: $($_.Exception.Message)"
} finally {
$PSNativeCommandUseErrorActionPreference = $nativePreference
}
}
# The manifest exists before the first Azure mutation.
Save-RunState
$state.status = 'setup-in-progress'
Save-RunState
$expiresOn = (Get-Date).ToUniversalTime().AddDays(1).ToString('yyyy-MM-dd')
try {
az group create --subscription $SubscriptionId --name $ResourceGroupName --location $Location --tags purpose=az104-lab labId=20 runId=$RunId expiresOn=$expiresOn --output none
} catch {
Write-CheckpointState -CheckpointId 'LAB20-CP01' -Status 'fail' -Message "Resource-group boundary creation failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
} finally {
Sync-ManagedResource -CheckpointId 'LAB20-CP01'
}
# CHECKPOINT LAB20-CP01 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB20-CP01' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB20-CP01'
Save-RunState
az provider show --namespace Microsoft.Network --query registrationState --output tsv
Sync-ManagedResource -CheckpointId 'LAB20-CP01'
Write-CheckpointState -CheckpointId 'LAB20-CP01' -Status 'pass' -Message 'The region supports Azure Bastion and the DNS exercise uses either example.invalid or an explicitly owned parent-zone gate.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB20-CP01'
Write-CheckpointState -CheckpointId 'LAB20-CP01' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB20-CP01 END
# CHECKPOINT LAB20-CP02 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB20-CP02' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB20-CP02'
Save-RunState
$vnet = "vnet-$suffix"; $publicIp = "pip-$suffix"; $bastion = "bas-$suffix"
$childLabel = "az104-$suffix"
$zone = $(if ($env:AZ104_DNS_PARENT_ZONE) { "$childLabel.$($env:AZ104_DNS_PARENT_ZONE)" } else { "lab$suffix.example.invalid" })
try {
az network vnet create --resource-group $ResourceGroupName --name $vnet --address-prefixes 10.20.0.0/16 --subnet-name AzureBastionSubnet --subnet-prefixes 10.20.0.0/26 --output none
} finally {
Sync-ManagedResource -CheckpointId 'LAB20-CP02'
}
try {
az network public-ip create --resource-group $ResourceGroupName --name $publicIp --sku Standard --allocation-method Static --output none
} finally {
Sync-ManagedResource -CheckpointId 'LAB20-CP02'
}
try {
az network bastion create --resource-group $ResourceGroupName --name $bastion --vnet-name $vnet --public-ip-address $publicIp --location $Location --sku Standard --scale-units 2 --output none
} finally {
Sync-ManagedResource -CheckpointId 'LAB20-CP02'
}
try {
az network dns zone create --resource-group $ResourceGroupName --name $zone --output none
} finally {
Sync-ManagedResource -CheckpointId 'LAB20-CP02'
}
az network dns record-set a add-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name app --ipv4-address 192.0.2.10 --output none
az network dns record-set txt add-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name verify --value "az104-$RunId" --output none
Sync-ManagedResource -CheckpointId 'LAB20-CP02'
Write-CheckpointState -CheckpointId 'LAB20-CP02' -Status 'pass' -Message 'The Bastion subnet uses the required name and prefix, while the run-owned DNS zone exposes authoritative name servers.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB20-CP02'
Write-CheckpointState -CheckpointId 'LAB20-CP02' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB20-CP02 END
# CHECKPOINT LAB20-CP03 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB20-CP03' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB20-CP03'
Save-RunState
az network dns record-set cname set-record --resource-group $ResourceGroupName --zone-name $zone --record-set-name www --cname "app.$zone" --output none
az network dns record-set list --resource-group $ResourceGroupName --zone-name $zone --query "[?name=='app' || name=='verify' || name=='www'].{name:name,type:type}" --output table
Sync-ManagedResource -CheckpointId 'LAB20-CP03'
Write-CheckpointState -CheckpointId 'LAB20-CP03' -Status 'pass' -Message 'The A and CNAME records return the documented values and no record is added to an unverified parent zone.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB20-CP03'
Write-CheckpointState -CheckpointId 'LAB20-CP03' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB20-CP03 END
# CHECKPOINT LAB20-CP04 BEGIN
if (-not ((-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable('AZ104_DNS_PARENT_ZONE'))) -and (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable('AZ104_DNS_PARENT_RESOURCE_GROUP'))) -and (-not [string]::IsNullOrWhiteSpace([Environment]::GetEnvironmentVariable('AZ104_DNS_PARENT_ZONE_ID'))) -and [string]::Equals([Environment]::GetEnvironmentVariable('AZ104_AUTHORIZE_DNS_DELEGATION'), 'YES', [StringComparison]::Ordinal))) {
Write-CheckpointState -CheckpointId 'LAB20-CP04' -Status 'skipped' -Message 'Optional input or exact authorization gate was not satisfied.'
Save-RunState
} else {
try {
Write-CheckpointState -CheckpointId 'LAB20-CP04' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB20-CP04'
Save-RunState
$external.parentZoneName = $env:AZ104_DNS_PARENT_ZONE
$external.parentZoneResourceGroup = $env:AZ104_DNS_PARENT_RESOURCE_GROUP
$external.parentZoneId = $env:AZ104_DNS_PARENT_ZONE_ID
$external.delegationRecordName = "az104-$suffix"
$liveParentId = az network dns zone show --resource-group $external.parentZoneResourceGroup --name $external.parentZoneName --query id --output tsv
if (-not [string]::Equals($liveParentId, $external.parentZoneId, [StringComparison]::OrdinalIgnoreCase)) {
throw 'The live parent-zone ID does not match the explicitly authorized ARM ID.'
}
$childServers = @(az network dns zone show --resource-group $ResourceGroupName --name $zone --query nameServers --output tsv)
if ($childServers.Count -lt 4) { throw 'The child zone did not return its authoritative name servers.' }
try {
az network dns record-set ns create --resource-group $external.parentZoneResourceGroup --zone-name $external.parentZoneName --record-set-name $external.delegationRecordName --ttl 300 --output none
} finally {
Sync-ManagedResource -CheckpointId 'LAB20-CP04'
}
foreach ($server in $childServers) {
az network dns record-set ns add-record --resource-group $external.parentZoneResourceGroup --zone-name $external.parentZoneName --record-set-name $external.delegationRecordName --nsdname $server --output none
}
$external.delegationRecordId = az network dns record-set ns show --resource-group $external.parentZoneResourceGroup --zone-name $external.parentZoneName --name $external.delegationRecordName --query id --output tsv
Sync-ManagedResource -CheckpointId 'LAB20-CP04'
Write-CheckpointState -CheckpointId 'LAB20-CP04' -Status 'pass' -Message 'The optional parent NS record contains exactly the child zone''s authoritative name servers and its exact returned ID is recorded for cleanup.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB20-CP04'
Write-CheckpointState -CheckpointId 'LAB20-CP04' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
}
# CHECKPOINT LAB20-CP04 END
# CHECKPOINT LAB20-CP05 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB20-CP05' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB20-CP05'
Save-RunState
az network dns zone show --resource-group $ResourceGroupName --name $zone --query "{zone:name,nameServers:nameServers,recordSets:numberOfRecordSets}" --output json
Sync-ManagedResource -CheckpointId 'LAB20-CP05'
Write-CheckpointState -CheckpointId 'LAB20-CP05' -Status 'pass' -Message 'Zone name servers, record answers, Bastion provisioning state, and subnet/public-IP placement match the intended topology.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB20-CP05'
Write-CheckpointState -CheckpointId 'LAB20-CP05' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB20-CP05 END
$skippedRequired = @($state.checkpointStates | Where-Object { $_.required -and $_.status -eq 'skipped' })
if ($skippedRequired.Count -gt 0) {
$state.status = 'failed'
Save-RunState
throw "Required checkpoints were skipped: $($skippedRequired.checkpointId -join ', ')"
}
$skippedOptional = @($state.checkpointStates | Where-Object { -not $_.required -and $_.status -eq 'skipped' })
$state.status = $(if ($skippedOptional.Count -gt 0) { 'partial' } else { 'setup-complete' })
Save-RunState
Write-Host "Setup result: $($state.status). State: $Manifest"
Write-Host "Next: ./Validate.ps1 -RunId $RunId -Mode Deployment"
# END GENERATED AZ104 V2
Validate.ps1¶
# BEGIN GENERATED AZ104 V2
#requires -Version 7.4
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive lab progress is intentionally written to the host.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'All lifecycle scripts expose a stable cross-lab interface.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Generated task variables are intentionally shared across checkpoint blocks.')]
param(
[string]$SubscriptionId = $env:AZ104_SUBSCRIPTION_ID,
[Parameter(Mandatory)][ValidatePattern('^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$')][string]$RunId,
[ValidateSet('Deployment', 'PostCleanup')][string]$Mode = 'Deployment'
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is required. Run the repository readiness initializer, then retry.'
}
$LabRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
$StateDir = Join-Path $LabRoot ".state/$RunId"
$Manifest = Join-Path $StateDir 'run.json'
$ValidationPath = Join-Path $StateDir 'validation.json'
if (-not (Test-Path -LiteralPath $Manifest)) { throw "Run manifest not found: $Manifest" }
$state = Get-Content -LiteralPath $Manifest -Raw | ConvertFrom-Json -AsHashtable
if ($state.labId -ne 'LAB-20' -or $state.runId -ne $RunId) { throw 'Manifest ownership does not match this lab and run ID.' }
$PSNativeCommandUseErrorActionPreference = $false
$accountJson = az account show --output json 2>$null
$account = $(if ($LASTEXITCODE -eq 0 -and $accountJson) { $accountJson | ConvertFrom-Json } else { $null })
if (-not $account -or [string]$account.tenantId -ne [string]$state.context.tenantId -or [string]$account.id -ne [string]$state.context.subscriptionId) {
$capturedAt = (Get-Date).ToUniversalTime().ToString('o')
$actualContext = $(if ($account) { "tenant=$($account.tenantId); subscription=$($account.id)" } else { 'active context unavailable' })
$contextFailure = @{
schemaVersion = '1.0.0'; labId = 'LAB-20'; runId = $RunId; mode = $Mode
generatedAt = $capturedAt; result = 'fail'
summary = @{ required = 1; passed = 0; failed = 1; skipped = 0 }
checks = @(@{ id = 'context.active'; checkpointId = 'LAB20-CP01'; kind = 'context'; required = $true; status = 'fail'; message = 'Active Azure context does not match the run manifest.'; evidence = @{ command = 'az account show --output json'; expected = 'tenant and subscription exactly match run.json'; actual = $actualContext; capturedAt = $capturedAt; redacted = $true } })
}
$contextFailure | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $ValidationPath -Encoding utf8
Write-Host "Validation $Mode result: fail"
Write-Host "Artifact: $ValidationPath"
exit 1
}
$SubscriptionId = [string]$state.context.subscriptionId
$Location = [string]$state.inputs['location']
$SecondaryLocation = [string]$state.inputs['secondary-location']
$ResourceGroupName = $(if ('20' -in @('00', '01', '02')) { $null } else { "rg-az104-l20-$RunId" })
$suffix = (($RunId -replace '[^a-z0-9]', '') + '000000000000').Substring(0, 12)
$vnet = "vnet-$suffix"; $publicIp = "pip-$suffix"; $bastion = "bas-$suffix"; $childLabel = "az104-$suffix"
$zone = $(if ($state.inputs['external-parent-zone-name']) { "$childLabel.$($state.inputs['external-parent-zone-name'])" } elseif ($env:AZ104_DNS_PARENT_ZONE) { "$childLabel.$($env:AZ104_DNS_PARENT_ZONE)" } else { "lab$suffix.example.invalid" })
$checks = [System.Collections.Generic.List[object]]::new()
function Add-ValidationCheck {
param([string]$Id, [string]$CheckpointId, [string]$Kind, [bool]$Required, [bool]$Passed, [string]$Command, [string]$Expected, [string]$Actual, [bool]$Skipped = $false)
$capturedAt = (Get-Date).ToUniversalTime().ToString('o')
$checks.Add(@{
id = $Id
checkpointId = $CheckpointId
kind = $Kind
required = $Required
status = $(if ($Skipped) { 'skipped' } elseif ($Passed) { 'pass' } else { 'fail' })
message = $(if ($Skipped) { 'Optional gate was deliberately skipped.' } elseif ($Passed) { 'Expected state observed.' } else { 'Expected state was not observed.' })
evidence = @{ command = $Command; expected = $Expected; actual = $Actual; capturedAt = $capturedAt; redacted = $true }
})
}
# CHECKPOINT LAB20-CP01 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB20-CP01' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP01' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab20-cp01.positive' -CheckpointId 'LAB20-CP01' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'The optional checkpoint is explicitly skipped when its documented input is absent.' -Actual 'optional gate absent'
Add-ValidationCheck -Id 'lab20-cp01.negative' -CheckpointId 'LAB20-CP01' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'No mutation occurs for a skipped optional checkpoint.' -Actual 'optional gate absent'
} else {
$positivePassed = $checkpointState.status -eq 'pass'
$positiveActual = "checkpoint=$($checkpointState.status); managedObjects=$($checkpointObjects.Count)"
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
}
}
# AUTHORED SERVICE ASSERTION: positive
try {
$LASTEXITCODE = 0
& {
$registration = az provider show --namespace Microsoft.Network --query registrationState --output tsv
if ($registration -ne 'Registered') { throw 'Microsoft.Network is not registered.' }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$positivePassed = $false
$positiveActual += "; serviceProbeExit=$LASTEXITCODE"
} else {
$positiveActual += '; serviceProbe=pass (output not persisted)'
}
} catch {
$positivePassed = $false
$positiveActual += "; serviceProbeError=$($_.Exception.GetType().Name)"
}
Add-ValidationCheck -Id 'lab20-cp01.positive' -CheckpointId 'LAB20-CP01' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $positivePassed -Command 'query each manifest-recorded object by exact ID and run the authored service probe' -Expected 'Checkpoint state is pass, recorded objects exist, and service state matches.' -Actual $positiveActual
$unsafe = @($checkpointObjects | Where-Object { $_.ownership.method -eq 'manifest-id-and-tags' -and ($_.ownership.expectedTags.runId -ne $RunId) })
# AUTHORED SERVICE ASSERTION: negative
$negativeProbePassed = $true
$negativeProbeActual = 'serviceProbe=pass (output not persisted)'
try {
$LASTEXITCODE = 0
& {
$locationKnown = az account list-locations --query "[?name=='$Location'] | length(@)" --output tsv
if ([int]$locationKnown -ne 1) { throw 'The selected location is unavailable.' }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeExit=$LASTEXITCODE"
}
} catch {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeError=$($_.Exception.GetType().Name)"
}
$negativePassed = $unsafe.Count -eq 0 -and $negativeProbePassed
$negativeActual = "unsafeObjects=$($unsafe.Count)" + "; $negativeProbeActual"
Add-ValidationCheck -Id 'lab20-cp01.negative' -CheckpointId 'LAB20-CP01' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $negativePassed -Command 'run the authored denied-state probe and compare manifest ownership' -Expected 'The service boundary and ownership checks both pass.' -Actual $negativeActual
}
} else {
$active = [System.Collections.Generic.List[string]]::new()
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
}
}
Add-ValidationCheck -Id 'lab20-cp01.residual' -CheckpointId 'LAB20-CP01' -Kind 'residual' -Required ([bool]$checkpointState.required) -Passed ($active.Count -eq 0) -Command 'query every recorded object by exact ID after cleanup' -Expected 'No active manifest-managed object remains.' -Actual "active=$($active -join ',')"
}
# CHECKPOINT LAB20-CP01 END
# CHECKPOINT LAB20-CP02 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB20-CP02' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP02' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab20-cp02.positive' -CheckpointId 'LAB20-CP02' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'The optional checkpoint is explicitly skipped when its documented input is absent.' -Actual 'optional gate absent'
Add-ValidationCheck -Id 'lab20-cp02.negative' -CheckpointId 'LAB20-CP02' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'No mutation occurs for a skipped optional checkpoint.' -Actual 'optional gate absent'
} else {
$positivePassed = $checkpointState.status -eq 'pass'
$positiveActual = "checkpoint=$($checkpointState.status); managedObjects=$($checkpointObjects.Count)"
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
}
}
# AUTHORED SERVICE ASSERTION: positive
try {
$LASTEXITCODE = 0
& {
$bastionState = az network bastion show --resource-group $ResourceGroupName --name $bastion --query provisioningState --output tsv
if ($bastionState -ne 'Succeeded') { throw "Bastion state is $bastionState." }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$positivePassed = $false
$positiveActual += "; serviceProbeExit=$LASTEXITCODE"
} else {
$positiveActual += '; serviceProbe=pass (output not persisted)'
}
} catch {
$positivePassed = $false
$positiveActual += "; serviceProbeError=$($_.Exception.GetType().Name)"
}
Add-ValidationCheck -Id 'lab20-cp02.positive' -CheckpointId 'LAB20-CP02' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $positivePassed -Command 'query each manifest-recorded object by exact ID and run the authored service probe' -Expected 'Checkpoint state is pass, recorded objects exist, and service state matches.' -Actual $positiveActual
$unsafe = @($checkpointObjects | Where-Object { $_.ownership.method -eq 'manifest-id-and-tags' -and ($_.ownership.expectedTags.runId -ne $RunId) })
# AUTHORED SERVICE ASSERTION: negative
$negativeProbePassed = $true
$negativeProbeActual = 'serviceProbe=pass (output not persisted)'
try {
$LASTEXITCODE = 0
& {
$prefix = az network vnet subnet show --resource-group $ResourceGroupName --vnet-name $vnet --name AzureBastionSubnet --query addressPrefix --output tsv
if ($prefix -ne '10.20.0.0/26') { throw "Bastion subnet prefix is $prefix." }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeExit=$LASTEXITCODE"
}
} catch {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeError=$($_.Exception.GetType().Name)"
}
$negativePassed = $unsafe.Count -eq 0 -and $negativeProbePassed
$negativeActual = "unsafeObjects=$($unsafe.Count)" + "; $negativeProbeActual"
Add-ValidationCheck -Id 'lab20-cp02.negative' -CheckpointId 'LAB20-CP02' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $negativePassed -Command 'run the authored denied-state probe and compare manifest ownership' -Expected 'The service boundary and ownership checks both pass.' -Actual $negativeActual
}
} else {
$active = [System.Collections.Generic.List[string]]::new()
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
}
}
Add-ValidationCheck -Id 'lab20-cp02.residual' -CheckpointId 'LAB20-CP02' -Kind 'residual' -Required ([bool]$checkpointState.required) -Passed ($active.Count -eq 0) -Command 'query every recorded object by exact ID after cleanup' -Expected 'No active manifest-managed object remains.' -Actual "active=$($active -join ',')"
}
# CHECKPOINT LAB20-CP02 END
# CHECKPOINT LAB20-CP03 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB20-CP03' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP03' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab20-cp03.positive' -CheckpointId 'LAB20-CP03' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'The optional checkpoint is explicitly skipped when its documented input is absent.' -Actual 'optional gate absent'
Add-ValidationCheck -Id 'lab20-cp03.negative' -CheckpointId 'LAB20-CP03' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'No mutation occurs for a skipped optional checkpoint.' -Actual 'optional gate absent'
} else {
$positivePassed = $checkpointState.status -eq 'pass'
$positiveActual = "checkpoint=$($checkpointState.status); managedObjects=$($checkpointObjects.Count)"
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
}
}
# AUTHORED SERVICE ASSERTION: positive
try {
$LASTEXITCODE = 0
& {
$cname = az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
if ($cname -ne "app.$zone") { throw "CNAME target is $cname." }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$positivePassed = $false
$positiveActual += "; serviceProbeExit=$LASTEXITCODE"
} else {
$positiveActual += '; serviceProbe=pass (output not persisted)'
}
} catch {
$positivePassed = $false
$positiveActual += "; serviceProbeError=$($_.Exception.GetType().Name)"
}
Add-ValidationCheck -Id 'lab20-cp03.positive' -CheckpointId 'LAB20-CP03' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $positivePassed -Command 'query each manifest-recorded object by exact ID and run the authored service probe' -Expected 'Checkpoint state is pass, recorded objects exist, and service state matches.' -Actual $positiveActual
$unsafe = @($checkpointObjects | Where-Object { $_.ownership.method -eq 'manifest-id-and-tags' -and ($_.ownership.expectedTags.runId -ne $RunId) })
# AUTHORED SERVICE ASSERTION: negative
$negativeProbePassed = $true
$negativeProbeActual = 'serviceProbe=pass (output not persisted)'
try {
$LASTEXITCODE = 0
& {
$nameServerCount = az network dns zone show --resource-group $ResourceGroupName --name $zone --query "nameServers | length(@)" --output tsv
if ([int]$nameServerCount -lt 4) { throw "Only $nameServerCount authoritative name servers were returned." }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeExit=$LASTEXITCODE"
}
} catch {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeError=$($_.Exception.GetType().Name)"
}
$negativePassed = $unsafe.Count -eq 0 -and $negativeProbePassed
$negativeActual = "unsafeObjects=$($unsafe.Count)" + "; $negativeProbeActual"
Add-ValidationCheck -Id 'lab20-cp03.negative' -CheckpointId 'LAB20-CP03' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $negativePassed -Command 'run the authored denied-state probe and compare manifest ownership' -Expected 'The service boundary and ownership checks both pass.' -Actual $negativeActual
}
} else {
$active = [System.Collections.Generic.List[string]]::new()
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
}
}
Add-ValidationCheck -Id 'lab20-cp03.residual' -CheckpointId 'LAB20-CP03' -Kind 'residual' -Required ([bool]$checkpointState.required) -Passed ($active.Count -eq 0) -Command 'query every recorded object by exact ID after cleanup' -Expected 'No active manifest-managed object remains.' -Actual "active=$($active -join ',')"
}
# CHECKPOINT LAB20-CP03 END
# CHECKPOINT LAB20-CP04 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB20-CP04' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP04' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab20-cp04.positive' -CheckpointId 'LAB20-CP04' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'The optional checkpoint is explicitly skipped when its documented input is absent.' -Actual 'optional gate absent'
Add-ValidationCheck -Id 'lab20-cp04.negative' -CheckpointId 'LAB20-CP04' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'No mutation occurs for a skipped optional checkpoint.' -Actual 'optional gate absent'
} else {
$positivePassed = $checkpointState.status -eq 'pass'
$positiveActual = "checkpoint=$($checkpointState.status); managedObjects=$($checkpointObjects.Count)"
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
}
}
# AUTHORED SERVICE ASSERTION: positive
try {
$LASTEXITCODE = 0
& {
$delegatedServers = @(az network dns record-set ns show --resource-group $state.inputs['external-parent-zone-resource-group'] --zone-name $state.inputs['external-parent-zone-name'] --name $state.inputs['external-delegation-record-name'] --query "NSRecords[].nsdname" --output tsv)
$childServers = @(az network dns zone show --resource-group $ResourceGroupName --name $zone --query nameServers --output tsv)
if (@($childServers | Where-Object { $_ -notin $delegatedServers }).Count -ne 0) { throw 'The parent delegation omits a child authoritative server.' }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$positivePassed = $false
$positiveActual += "; serviceProbeExit=$LASTEXITCODE"
} else {
$positiveActual += '; serviceProbe=pass (output not persisted)'
}
} catch {
$positivePassed = $false
$positiveActual += "; serviceProbeError=$($_.Exception.GetType().Name)"
}
Add-ValidationCheck -Id 'lab20-cp04.positive' -CheckpointId 'LAB20-CP04' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $positivePassed -Command 'query each manifest-recorded object by exact ID and run the authored service probe' -Expected 'Checkpoint state is pass, recorded objects exist, and service state matches.' -Actual $positiveActual
$unsafe = @($checkpointObjects | Where-Object { $_.ownership.method -eq 'manifest-id-and-tags' -and ($_.ownership.expectedTags.runId -ne $RunId) })
# AUTHORED SERVICE ASSERTION: negative
$negativeProbePassed = $true
$negativeProbeActual = 'serviceProbe=pass (output not persisted)'
try {
$LASTEXITCODE = 0
& {
$liveDelegationId = az network dns record-set ns show --resource-group $state.inputs['external-parent-zone-resource-group'] --zone-name $state.inputs['external-parent-zone-name'] --name $state.inputs['external-delegation-record-name'] --query id --output tsv
if (-not [string]::Equals($liveDelegationId, $state.inputs['external-delegation-record-id'], [StringComparison]::OrdinalIgnoreCase)) { throw 'The parent NS record is outside the recorded boundary.' }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeExit=$LASTEXITCODE"
}
} catch {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeError=$($_.Exception.GetType().Name)"
}
$negativePassed = $unsafe.Count -eq 0 -and $negativeProbePassed
$negativeActual = "unsafeObjects=$($unsafe.Count)" + "; $negativeProbeActual"
Add-ValidationCheck -Id 'lab20-cp04.negative' -CheckpointId 'LAB20-CP04' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $negativePassed -Command 'run the authored denied-state probe and compare manifest ownership' -Expected 'The service boundary and ownership checks both pass.' -Actual $negativeActual
}
} else {
$active = [System.Collections.Generic.List[string]]::new()
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
}
}
Add-ValidationCheck -Id 'lab20-cp04.residual' -CheckpointId 'LAB20-CP04' -Kind 'residual' -Required ([bool]$checkpointState.required) -Passed ($active.Count -eq 0) -Command 'query every recorded object by exact ID after cleanup' -Expected 'No active manifest-managed object remains.' -Actual "active=$($active -join ',')"
}
# CHECKPOINT LAB20-CP04 END
# CHECKPOINT LAB20-CP05 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB20-CP05' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP05' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab20-cp05.positive' -CheckpointId 'LAB20-CP05' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'The optional checkpoint is explicitly skipped when its documented input is absent.' -Actual 'optional gate absent'
Add-ValidationCheck -Id 'lab20-cp05.negative' -CheckpointId 'LAB20-CP05' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $true -Skipped $true -Command 'optional gate evaluation' -Expected 'No mutation occurs for a skipped optional checkpoint.' -Actual 'optional gate absent'
} else {
$positivePassed = $checkpointState.status -eq 'pass'
$positiveActual = "checkpoint=$($checkpointState.status); managedObjects=$($checkpointObjects.Count)"
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -ne 0) { $positivePassed = $false; $positiveActual += "; missing=$($managedObject.id)" }
}
}
# AUTHORED SERVICE ASSERTION: positive
try {
$LASTEXITCODE = 0
& {
$allocation = az network public-ip show --resource-group $ResourceGroupName --name $publicIp --query publicIPAllocationMethod --output tsv
if ($allocation -ne 'Static') { throw "Bastion public IP allocation is $allocation." }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$positivePassed = $false
$positiveActual += "; serviceProbeExit=$LASTEXITCODE"
} else {
$positiveActual += '; serviceProbe=pass (output not persisted)'
}
} catch {
$positivePassed = $false
$positiveActual += "; serviceProbeError=$($_.Exception.GetType().Name)"
}
Add-ValidationCheck -Id 'lab20-cp05.positive' -CheckpointId 'LAB20-CP05' -Kind 'positive' -Required ([bool]$checkpointState.required) -Passed $positivePassed -Command 'query each manifest-recorded object by exact ID and run the authored service probe' -Expected 'Checkpoint state is pass, recorded objects exist, and service state matches.' -Actual $positiveActual
$unsafe = @($checkpointObjects | Where-Object { $_.ownership.method -eq 'manifest-id-and-tags' -and ($_.ownership.expectedTags.runId -ne $RunId) })
# AUTHORED SERVICE ASSERTION: negative
$negativeProbePassed = $true
$negativeProbeActual = 'serviceProbe=pass (output not persisted)'
try {
$LASTEXITCODE = 0
& {
$wrongTarget = az network dns record-set cname show --resource-group $ResourceGroupName --zone-name $zone --name www --query CNAMERecord.cname --output tsv
if ($wrongTarget -ne "app.$zone") { throw 'The CNAME target does not match the application label.' }
} | Out-Null
if ($LASTEXITCODE -ne 0) {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeExit=$LASTEXITCODE"
}
} catch {
$negativeProbePassed = $false
$negativeProbeActual = "serviceProbeError=$($_.Exception.GetType().Name)"
}
$negativePassed = $unsafe.Count -eq 0 -and $negativeProbePassed
$negativeActual = "unsafeObjects=$($unsafe.Count)" + "; $negativeProbeActual"
Add-ValidationCheck -Id 'lab20-cp05.negative' -CheckpointId 'LAB20-CP05' -Kind 'negative' -Required ([bool]$checkpointState.required) -Passed $negativePassed -Command 'run the authored denied-state probe and compare manifest ownership' -Expected 'The service boundary and ownership checks both pass.' -Actual $negativeActual
}
} else {
$active = [System.Collections.Generic.List[string]]::new()
foreach ($managedObject in $checkpointObjects) {
if ($managedObject.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $managedObject.name --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'azure-resource') {
$null = az resource show --ids $managedObject.id --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
} elseif ($managedObject.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($managedObject.id)" --output none 2>$null
if ($LASTEXITCODE -eq 0) { $active.Add([string]$managedObject.id) }
}
}
Add-ValidationCheck -Id 'lab20-cp05.residual' -CheckpointId 'LAB20-CP05' -Kind 'residual' -Required ([bool]$checkpointState.required) -Passed ($active.Count -eq 0) -Command 'query every recorded object by exact ID after cleanup' -Expected 'No active manifest-managed object remains.' -Actual "active=$($active -join ',')"
}
# CHECKPOINT LAB20-CP05 END
$requiredChecks = @($checks | Where-Object { $_.required })
$failedChecks = @($checks | Where-Object { $_.status -eq 'fail' })
$requiredSkippedChecks = @($checks | Where-Object { $_.required -and $_.status -eq 'skipped' })
$skippedChecks = @($checks | Where-Object { $_.status -eq 'skipped' })
$result = if ($failedChecks.Count -gt 0 -or $requiredSkippedChecks.Count -gt 0) { 'fail' } elseif ($skippedChecks.Count -gt 0) { 'partial' } else { 'pass' }
$document = @{
schemaVersion = '1.0.0'
labId = 'LAB-20'
runId = $RunId
mode = $Mode
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
result = $result
summary = @{
required = $requiredChecks.Count
passed = @($checks | Where-Object { $_.status -eq 'pass' }).Count
failed = $failedChecks.Count
skipped = $skippedChecks.Count
}
checks = @($checks)
}
$document | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $ValidationPath -Encoding utf8
Write-Host "Validation $Mode result: $result"
Write-Host "Artifact: $ValidationPath"
if ($result -eq 'fail') { exit 1 }
# END GENERATED AZ104 V2
Cleanup.ps1¶
# BEGIN GENERATED AZ104 V2
#requires -Version 7.4
[CmdletBinding()]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Interactive lab progress is intentionally written to the host.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', '', Justification = 'All lifecycle scripts expose a stable cross-lab interface.')]
[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '', Justification = 'Generated task variables are intentionally shared across checkpoint blocks.')]
param(
[string]$SubscriptionId = $env:AZ104_SUBSCRIPTION_ID,
[Parameter(Mandatory)][ValidatePattern('^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$')][string]$RunId,
[switch]$Execute
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $true
if (-not (Get-Command az -ErrorAction SilentlyContinue)) {
throw 'Azure CLI is required. Run the repository readiness initializer, then retry.'
}
$LabRoot = (Resolve-Path (Join-Path $PSScriptRoot '../..')).Path
$StateDir = Join-Path $LabRoot ".state/$RunId"
$Manifest = Join-Path $StateDir 'run.json'
$CleanupPath = Join-Path $StateDir 'cleanup.json'
if (-not (Test-Path -LiteralPath $Manifest)) { throw "Run manifest not found: $Manifest" }
$state = Get-Content -LiteralPath $Manifest -Raw | ConvertFrom-Json -AsHashtable
$active = @($state.managedObjects | Where-Object { $_.lifecycleStatus -eq 'active' })
function Write-CleanupRefusal {
param([string]$Message)
$refusalActions = @($active | ForEach-Object {
@{ checkpointId = [string]$_.checkpointId; targetId = [string]$_.id; targetType = [string]$_.type; ownership = @{ method = [string]$_.ownership.method; verified = $false }; status = 'failed'; message = $Message }
})
$refusal = @{
schemaVersion = '1.0.0'; labId = 'LAB-20'; runId = $RunId
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
executionMode = $(if ($Execute) { 'execute' } else { 'preview' })
result = $(if ($Execute) { 'fail' } else { 'preview' })
ownershipVerified = $false; actions = $refusalActions
residualChecks = @(@{ id = 'ownership.refusal'; command = 'compare manifest, active context, exact IDs, and ownership proof'; expected = 'all ownership checks pass before mutation'; actual = $Message; status = $(if ($Execute) { 'fail' } else { 'skipped' }) })
activeManagedObjects = @($active | ForEach-Object id); retainedItems = @()
}
$refusal | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $CleanupPath -Encoding utf8
throw $Message
}
if ($state.labId -ne 'LAB-20' -or $state.runId -ne $RunId) {
Write-CleanupRefusal -Message 'Cleanup ownership refusal: manifest lab ID or run ID does not match.'
}
if ($Execute -and $active.Count -eq 0 -and $state.status -eq 'cleaned') {
$priorRetained = @()
if (Test-Path -LiteralPath $CleanupPath) {
try { $priorRetained = @((Get-Content -LiteralPath $CleanupPath -Raw | ConvertFrom-Json).retainedItems) } catch { $priorRetained = @() }
}
$idempotent = @{
schemaVersion = '1.0.0'; labId = 'LAB-20'; runId = $RunId
generatedAt = (Get-Date).ToUniversalTime().ToString('o'); executionMode = 'execute'; result = 'pass'
ownershipVerified = $true; actions = @(); activeManagedObjects = @(); retainedItems = $priorRetained
residualChecks = @(@{ id = 'cleanup.idempotent'; command = 'read run.json active managed-object inventory'; expected = 'zero active manifest-managed objects'; actual = 'active=0; prior cleanup already completed'; status = 'pass' })
}
$idempotent | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $CleanupPath -Encoding utf8
Write-Host 'Cleanup result: pass (already cleaned)'
Write-Host "Artifact: $CleanupPath"
return
}
try {
$account = az account show --output json | ConvertFrom-Json
} catch {
Write-CleanupRefusal -Message 'Cleanup ownership refusal: active Azure context could not be read.'
}
if ([string]$account.tenantId -ne [string]$state.context.tenantId -or [string]$account.id -ne [string]$state.context.subscriptionId) {
Write-CleanupRefusal -Message 'Cleanup ownership refusal: active context does not match the manifest.'
}
$SubscriptionId = [string]$state.context.subscriptionId
$Location = [string]$state.inputs['location']
$SecondaryLocation = [string]$state.inputs['secondary-location']
$ResourceGroupName = $(if ('20' -in @('00', '01', '02')) { $null } else { "rg-az104-l20-$RunId" })
$suffix = (($RunId -replace '[^a-z0-9]', '') + '000000000000').Substring(0, 12)
$delegationId = $state.inputs['external-delegation-record-id']
$delegationName = $state.inputs['external-delegation-record-name']
$parentZoneId = $state.inputs['external-parent-zone-id']
$parentZoneName = $state.inputs['external-parent-zone-name']
$parentZoneResourceGroup = $state.inputs['external-parent-zone-resource-group']
$ownershipVerified = $true
$verifiedOwnershipById = @{}
$absentIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$verifiedResourceGroupIds = [System.Collections.Generic.List[string]]::new()
$resourceGroupObjects = @($active | Where-Object { $_.type -eq 'Microsoft.Resources/resourceGroups' })
$resourceGroupObject = $resourceGroupObjects | Select-Object -First 1
$nativeProbePreference = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
# A manifest entry is necessary but is not, by itself, proof of ownership. Verify
# each manifest-id-and-tags boundary against the live Azure tags before mutation.
foreach ($groupObject in $resourceGroupObjects) {
$groupVerified = $false
try {
$tagJson = az group show --ids $groupObject.id --query tags --output json 2>$null
if ($LASTEXITCODE -eq 0 -and $tagJson) {
$tags = $tagJson | ConvertFrom-Json -AsHashtable
$expectedTags = $groupObject.ownership.expectedTags
$groupVerified =
$groupObject.ownership.method -eq 'manifest-id-and-tags' -and
[string]$tags['purpose'] -eq [string]$expectedTags['purpose'] -and
[string]$tags['labId'] -eq [string]$expectedTags['labId'] -and
[string]$tags['runId'] -eq [string]$expectedTags['runId'] -and
[string]$tags['purpose'] -eq 'az104-lab' -and
[string]$tags['labId'] -eq '20' -and
[string]$tags['runId'] -eq $RunId
} else {
$groupExists = az group exists --subscription $state.context.subscriptionId --name $groupObject.name --output tsv 2>$null
if ($LASTEXITCODE -eq 0 -and $groupExists -eq 'false') {
$null = $absentIds.Add([string]$groupObject.id)
$groupVerified = $true
}
}
} catch {
$groupVerified = $false
}
$verifiedOwnershipById[[string]$groupObject.id] = $groupVerified
if ($groupVerified) { $verifiedResourceGroupIds.Add([string]$groupObject.id) }
if (-not $groupVerified) { $ownershipVerified = $false }
}
foreach ($object in $active) {
$objectId = [string]$object.id
if ($verifiedOwnershipById.ContainsKey($objectId)) { continue }
$method = [string]$object.ownership.method
$objectVerified = $false
$absentGroupBoundary = @($resourceGroupObjects | Where-Object {
$absentIds.Contains([string]$_.id) -and $objectId.StartsWith("$($_.id)/", [System.StringComparison]::OrdinalIgnoreCase)
}).Count -gt 0
if ($absentGroupBoundary) {
$null = $absentIds.Add($objectId)
$verifiedOwnershipById[$objectId] = $true
continue
}
if ($method -eq 'manifest-id-and-tags' -and $object.kind -eq 'azure-resource') {
$expectedTags = $object.ownership.expectedTags
$manifestTagsMatch =
[string]$expectedTags['purpose'] -eq 'az104-lab' -and
[string]$expectedTags['labId'] -eq '20' -and
[string]$expectedTags['runId'] -eq $RunId
$verifiedByGroupBoundary = @($verifiedResourceGroupIds | Where-Object {
$objectId.StartsWith("$_/", [System.StringComparison]::OrdinalIgnoreCase)
}).Count -gt 0
$verifiedByOwnTags = $false
try {
$tagJson = az resource show --ids $objectId --query tags --output json 2>$null
if ($LASTEXITCODE -eq 0 -and $tagJson) {
$tags = $tagJson | ConvertFrom-Json -AsHashtable
$verifiedByOwnTags =
[string]$tags['purpose'] -eq [string]$expectedTags['purpose'] -and
[string]$tags['labId'] -eq [string]$expectedTags['labId'] -and
[string]$tags['runId'] -eq [string]$expectedTags['runId']
} elseif ($verifiedByGroupBoundary) {
$parentGroupObject = $resourceGroupObjects | Where-Object { $objectId.StartsWith("$($_.id)/", [System.StringComparison]::OrdinalIgnoreCase) } | Select-Object -First 1
$exactCount = az resource list --resource-group $parentGroupObject.name --query "[?id=='$objectId'] | length(@)" --output tsv 2>$null
if ($LASTEXITCODE -eq 0 -and [int]$exactCount -eq 0) {
$null = $absentIds.Add($objectId)
$verifiedByOwnTags = $true
}
}
} catch {
$verifiedByOwnTags = $false
}
$objectVerified = $manifestTagsMatch -and ($verifiedByOwnTags -or $verifiedByGroupBoundary)
} elseif ($method -eq 'manifest-id' -and $object.kind -eq 'azure-resource' -and $object.type -eq 'Microsoft.Management/managementGroups') {
try {
$managementGroupJson = az account management-group show --name $object.name --expand --recurse --output json 2>&1
if ($LASTEXITCODE -eq 0) {
$managementGroup = $managementGroupJson | ConvertFrom-Json
$objectVerified = [string]$managementGroup.id -eq $objectId -and @($managementGroup.children).Count -eq 0
} elseif ([string]$managementGroupJson -match '(?i)404|not.?found') {
$null = $absentIds.Add($objectId)
$objectVerified = $true
}
} catch {
$objectVerified = $false
}
} elseif ($method -eq 'manifest-id' -and $object.kind -eq 'entra-object') {
try {
$graphResult = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$objectId" --output none 2>&1
if ($LASTEXITCODE -eq 0) {
$objectVerified = $true
} elseif ([string]$graphResult -match '(?i)404|Request_ResourceNotFound') {
$null = $absentIds.Add($objectId)
$objectVerified = $true
}
} catch {
$objectVerified = $false
}
} elseif ($method -eq 'manifest-id' -and $object.kind -eq 'azure-resource') {
try {
$resourceResult = az resource show --ids $objectId --output none 2>&1
if ($LASTEXITCODE -eq 0) {
$objectVerified = $true
} elseif ([string]$resourceResult -match '(?i)404|ResourceNotFound|could not be found') {
$null = $absentIds.Add($objectId)
$objectVerified = $true
}
} catch {
$objectVerified = $false
}
} elseif ($method -eq 'local-path') {
try {
$candidate = if ([System.IO.Path]::IsPathRooted($objectId)) { $objectId } else { Join-Path $LabRoot $objectId }
$fullPath = [System.IO.Path]::GetFullPath($candidate)
$statePrefix = [System.IO.Path]::GetFullPath($StateDir).TrimEnd([System.IO.Path]::DirectorySeparatorChar) + [System.IO.Path]::DirectorySeparatorChar
$inStateBoundary = $fullPath.StartsWith($statePrefix, [System.StringComparison]::OrdinalIgnoreCase)
$objectVerified = $inStateBoundary
if ($inStateBoundary -and -not (Test-Path -LiteralPath $fullPath)) { $null = $absentIds.Add($objectId) }
} catch {
$objectVerified = $false
}
}
$verifiedOwnershipById[$objectId] = $objectVerified
if (-not $objectVerified) { $ownershipVerified = $false }
}
$PSNativeCommandUseErrorActionPreference = $nativeProbePreference
if (-not $ownershipVerified) {
$failedOwnershipIds = @($active | Where-Object { -not $verifiedOwnershipById[[string]$_.id] } | ForEach-Object id)
Write-CleanupRefusal -Message "Cleanup ownership refusal: live ID and ownership proof failed for $($failedOwnershipIds -join ', ')."
}
$actions = [System.Collections.Generic.List[object]]::new()
$residualChecks = [System.Collections.Generic.List[object]]::new()
if (-not $Execute) {
Write-Host 'Preview only. The exact manifest-owned targets below will not be changed.'
}
# CHECKPOINT LAB20-CP05 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP05' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB20-CP05'; targetId = [string]$target.id; targetType = [string]$target.type; ownership = @{ method = [string]$target.ownership.method; verified = [bool]$verifiedOwnershipById[[string]$target.id] }; status = $(if ($alreadyAbsent) { 'skipped' } else { 'preview' }); message = $(if ($alreadyAbsent) { 'The exact manifest ID was already absent; idempotent cleanup recorded the safe end state.' } elseif ($Execute) { 'Pending removal through the dependency-safe ownership boundary.' } else { 'Would remove this exact manifest-recorded target.' }) })
}
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
# CHECKPOINT LAB20-CP05 END
# CHECKPOINT LAB20-CP04 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP04' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB20-CP04'; targetId = [string]$target.id; targetType = [string]$target.type; ownership = @{ method = [string]$target.ownership.method; verified = [bool]$verifiedOwnershipById[[string]$target.id] }; status = $(if ($alreadyAbsent) { 'skipped' } else { 'preview' }); message = $(if ($alreadyAbsent) { 'The exact manifest ID was already absent; idempotent cleanup recorded the safe end state.' } elseif ($Execute) { 'Pending removal through the dependency-safe ownership boundary.' } else { 'Would remove this exact manifest-recorded target.' }) })
}
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
# CHECKPOINT LAB20-CP04 END
# CHECKPOINT LAB20-CP03 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP03' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB20-CP03'; targetId = [string]$target.id; targetType = [string]$target.type; ownership = @{ method = [string]$target.ownership.method; verified = [bool]$verifiedOwnershipById[[string]$target.id] }; status = $(if ($alreadyAbsent) { 'skipped' } else { 'preview' }); message = $(if ($alreadyAbsent) { 'The exact manifest ID was already absent; idempotent cleanup recorded the safe end state.' } elseif ($Execute) { 'Pending removal through the dependency-safe ownership boundary.' } else { 'Would remove this exact manifest-recorded target.' }) })
}
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
# CHECKPOINT LAB20-CP03 END
# CHECKPOINT LAB20-CP02 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP02' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB20-CP02'; targetId = [string]$target.id; targetType = [string]$target.type; ownership = @{ method = [string]$target.ownership.method; verified = [bool]$verifiedOwnershipById[[string]$target.id] }; status = $(if ($alreadyAbsent) { 'skipped' } else { 'preview' }); message = $(if ($alreadyAbsent) { 'The exact manifest ID was already absent; idempotent cleanup recorded the safe end state.' } elseif ($Execute) { 'Pending removal through the dependency-safe ownership boundary.' } else { 'Would remove this exact manifest-recorded target.' }) })
}
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
# CHECKPOINT LAB20-CP02 END
# CHECKPOINT LAB20-CP01 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP01' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB20-CP01'; targetId = [string]$target.id; targetType = [string]$target.type; ownership = @{ method = [string]$target.ownership.method; verified = [bool]$verifiedOwnershipById[[string]$target.id] }; status = $(if ($alreadyAbsent) { 'skipped' } else { 'preview' }); message = $(if ($alreadyAbsent) { 'The exact manifest ID was already absent; idempotent cleanup recorded the safe end state.' } elseif ($Execute) { 'Pending removal through the dependency-safe ownership boundary.' } else { 'Would remove this exact manifest-recorded target.' }) })
}
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
# CHECKPOINT LAB20-CP01 END
$cleanupFailure = $null
if ($Execute) {
$PSNativeCommandUseErrorActionPreference = $true
$state.status = 'cleanup-in-progress'
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
try {
# Restore shared or tenant-wide settings before removing the disposable
# objects that were used to exercise them.
foreach ($setting in @($state.originalSettings)) {
if ($setting.targetId -eq 'authorizationPolicy' -and $setting.property -eq 'allowedToUseSSPR') {
$restoreBody = @{ allowedToUseSSPR = [bool]$setting.value } | ConvertTo-Json -Compress
az rest --method patch --url 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy' --headers 'Content-Type=application/json' --body $restoreBody --output none
$restored = az rest --method get --url 'https://graph.microsoft.com/v1.0/policies/authorizationPolicy?$select=allowedToUseSSPR' --query allowedToUseSSPR --output tsv
if ([bool]::Parse([string]$restored) -ne [bool]$setting.value) {
throw 'Cleanup stopped because authorizationPolicy.allowedToUseSSPR was not restored.'
}
}
}
foreach ($object in @($active | Where-Object { $_.type -eq 'Microsoft.Management/managementGroups' -and -not $absentIds.Contains([string]$_.id) })) {
az account management-group delete --name $object.name --output none
}
$hasLiveManagedTarget = @($active | Where-Object { -not $absentIds.Contains([string]$_.id) }).Count -gt 0
if ($hasLiveManagedTarget) {
if ($delegationId) {
$probePreference = $PSNativeCommandUseErrorActionPreference
$PSNativeCommandUseErrorActionPreference = $false
try {
$liveParentId = az network dns zone show --resource-group $parentZoneResourceGroup --name $parentZoneName --query id --output tsv 2>$null
$liveDelegationId = az network dns record-set ns show --resource-group $parentZoneResourceGroup --zone-name $parentZoneName --name $delegationName --query id --output tsv 2>$null
if ($LASTEXITCODE -eq 0) {
if (-not [string]::Equals($liveParentId, $parentZoneId, [StringComparison]::OrdinalIgnoreCase) -or -not [string]::Equals($liveDelegationId, $delegationId, [StringComparison]::OrdinalIgnoreCase)) {
throw 'DNS delegation cleanup refused because the exact parent or record ID changed.'
}
$PSNativeCommandUseErrorActionPreference = $true
az network dns record-set ns delete --resource-group $parentZoneResourceGroup --zone-name $parentZoneName --name $delegationName --yes --output none
}
} finally {
$PSNativeCommandUseErrorActionPreference = $probePreference
}
}
}
foreach ($groupToDelete in @($resourceGroupObjects | Where-Object { -not $absentIds.Contains([string]$_.id) })) {
az group delete --ids $groupToDelete.id --yes
}
foreach ($object in @($active | Where-Object { $_.kind -eq 'entra-object' -and -not $absentIds.Contains([string]$_.id) })) {
$collection = switch ([string]$object.type) {
'Microsoft.Graph/user' { 'users' }
'Microsoft.Graph/group' { 'groups' }
default { 'directoryObjects' }
}
az rest --method delete --url "https://graph.microsoft.com/v1.0/$collection/$($object.id)" --output none 2>$null
}
foreach ($object in $state.managedObjects) {
$object.lifecycleStatus = 'deleted'
}
foreach ($action in $actions) {
if ($action.status -eq 'skipped') { continue }
$action.status = 'deleted'
$action.message = 'The exact manifest-recorded target was removed through its ownership boundary.'
}
} catch {
$cleanupFailure = "Cleanup mutation failed: $($_.Exception.GetType().Name)"
foreach ($action in $actions) {
if ($action.status -eq 'preview') {
$action.status = 'failed'
$action.message = $cleanupFailure
}
}
}
}
$PSNativeCommandUseErrorActionPreference = $false
$remaining = [System.Collections.Generic.List[string]]::new()
foreach ($object in $state.managedObjects) {
if (-not $Execute -and $object.lifecycleStatus -eq 'active') { $remaining.Add([string]$object.id); continue }
if ($Execute -and $object.type -eq 'Microsoft.Management/managementGroups') {
$null = az account management-group show --name $object.name --output none 2>$null
if ($LASTEXITCODE -eq 0) { $remaining.Add([string]$object.id) }
} elseif ($Execute -and $object.type -eq 'Microsoft.Resources/resourceGroups') {
$groupExists = az group exists --subscription $SubscriptionId --name $object.name --output tsv 2>$null
if ($LASTEXITCODE -ne 0 -or $groupExists -ne 'false') { $remaining.Add([string]$object.id) }
} elseif ($Execute -and $object.kind -eq 'azure-resource') {
$null = az resource show --ids $object.id --output none 2>$null
if ($LASTEXITCODE -eq 0) {
$remaining.Add([string]$object.id)
} elseif ($object.type -eq 'Microsoft.RecoveryServices/vaults' -and $false) {
$deletedVault = az backup deleted-vault get --location $Location --name $object.name --output json 2>$null
if ($LASTEXITCODE -eq 0 -and $deletedVault) {
$object.lifecycleStatus = 'soft-deleted'
foreach ($action in @($actions | Where-Object { [string]$_.targetId -eq [string]$object.id -and $_.status -ne 'skipped' })) {
$action.status = 'soft-deleted'
$action.message = 'The active ARM vault is absent and the exact recoverable deleted-vault record was confirmed; no purge was performed.'
}
} else {
$remaining.Add([string]$object.id)
}
}
} elseif ($Execute -and $object.kind -eq 'entra-object') {
$null = az rest --method get --url "https://graph.microsoft.com/v1.0/directoryObjects/$($object.id)" --output none 2>$null
if ($LASTEXITCODE -eq 0) { $remaining.Add([string]$object.id) }
}
}
$residualChecks.Clear()
$checkpointRemaining = @($remaining | Where-Object { $_ -in @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP01' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp01.residual'; command = 'query manifest-recorded IDs for LAB20-CP01'; expected = 'zero active objects for LAB20-CP01'; actual = "active=$($checkpointRemaining.Count)"; status = $(if ($Execute -and $checkpointRemaining.Count -eq 0) { 'pass' } elseif (-not $Execute) { 'skipped' } else { 'fail' }) })
$checkpointRemaining = @($remaining | Where-Object { $_ -in @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP02' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp02.residual'; command = 'query manifest-recorded IDs for LAB20-CP02'; expected = 'zero active objects for LAB20-CP02'; actual = "active=$($checkpointRemaining.Count)"; status = $(if ($Execute -and $checkpointRemaining.Count -eq 0) { 'pass' } elseif (-not $Execute) { 'skipped' } else { 'fail' }) })
$checkpointRemaining = @($remaining | Where-Object { $_ -in @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP03' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp03.residual'; command = 'query manifest-recorded IDs for LAB20-CP03'; expected = 'zero active objects for LAB20-CP03'; actual = "active=$($checkpointRemaining.Count)"; status = $(if ($Execute -and $checkpointRemaining.Count -eq 0) { 'pass' } elseif (-not $Execute) { 'skipped' } else { 'fail' }) })
$checkpointRemaining = @($remaining | Where-Object { $_ -in @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP04' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp04.residual'; command = 'query manifest-recorded IDs for LAB20-CP04'; expected = 'zero active objects for LAB20-CP04'; actual = "active=$($checkpointRemaining.Count)"; status = $(if ($Execute -and $checkpointRemaining.Count -eq 0) { 'pass' } elseif (-not $Execute) { 'skipped' } else { 'fail' }) })
$checkpointRemaining = @($remaining | Where-Object { $_ -in @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB20-CP05' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp05.residual'; command = 'query manifest-recorded IDs for LAB20-CP05'; expected = 'zero active objects for LAB20-CP05'; actual = "active=$($checkpointRemaining.Count)"; status = $(if ($Execute -and $checkpointRemaining.Count -eq 0) { 'pass' } elseif (-not $Execute) { 'skipped' } else { 'fail' }) })
if ($Execute -and $remaining.Count -gt 0) {
foreach ($object in $state.managedObjects) {
if ([string]$object.id -in @($remaining)) { $object.lifecycleStatus = 'active' }
}
foreach ($action in $actions) {
if ([string]$action.targetId -in @($remaining) -and $action.status -ne 'skipped') {
$action.status = 'failed'
$action.message = 'Residual query found the target still active after cleanup.'
}
}
}
$result = if (-not $Execute) { 'preview' } elseif ($cleanupFailure -or $remaining.Count -gt 0) { 'fail' } else { 'pass' }
$cleanup = @{
schemaVersion = '1.0.0'
labId = 'LAB-20'
runId = $RunId
generatedAt = (Get-Date).ToUniversalTime().ToString('o')
executionMode = $(if ($Execute) { 'execute' } else { 'preview' })
result = $result
ownershipVerified = $ownershipVerified
actions = @($actions)
residualChecks = @($residualChecks)
activeManagedObjects = @($remaining)
retainedItems = @(
)
}
$cleanup | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $CleanupPath -Encoding utf8
if ($Execute) {
if ($result -eq 'pass') { $state.status = 'cleaned' }
$state.updatedAt = (Get-Date).ToUniversalTime().ToString('o')
$state | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $Manifest -Encoding utf8
}
Write-Host "Cleanup result: $result"
Write-Host "Artifact: $CleanupPath"
Write-Host 'Irreversible purge is never automated; retained or soft-deleted items must be documented explicitly.'
if ($result -eq 'fail') { exit 1 }
# END GENERATED AZ104 V2