Lab 04: Manage hierarchy, resource groups, tags, and locks¶
Previous: Lab 03 · Catalog · Next: Lab 05
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: A governance administrator organizing disposable workloads is responding to an operational request: Build a tagged workload hierarchy, prove tag non-inheritance, enforce a deletion lock, and optionally exercise a tenant-authorized management group.
Learner role: A governance administrator organizing disposable workloads.
Outcome: Build a tagged workload hierarchy, prove tag non-inheritance, enforce a deletion lock, and optionally exercise a tenant-authorized management group.
| Item | Value |
|---|---|
| Duration | 90 minutes |
| Difficulty | foundational |
| Cost class | none |
| 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 |
|---|---|---|
IG-GOVERN-02 |
Configure resource locks | LAB04-CP01, LAB04-CP05 |
IG-GOVERN-03 |
Apply and manage tags on resources | LAB04-CP02, LAB04-CP05 |
IG-GOVERN-04 |
Manage resource groups | LAB04-CP03, LAB04-CP05 |
IG-GOVERN-05 |
Manage subscriptions | LAB04-CP04, LAB04-CP05 |
IG-GOVERN-07 |
Configure management groups | LAB04-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. The subscription contains an optional lab management group and a required tagged resource group. A child virtual network demonstrates that tags do not inherit automatically. A CanNotDelete lock protects the resource-group control plane until dependency-aware cleanup removes the exact lock.
Concept primer and design decisions¶
Management groups and subscriptions establish hierarchy; resource groups establish lifecycle scope. Tags describe resources but do not automatically inherit, and locks protect against control-plane mutation rather than data-plane deletion.
Design decisions:
- Use merge semantics when updating tags.
- Test CanNotDelete at the smallest scope.
- Keep management-group work behind an explicit tenant-change gate.
Required and optional inputs¶
| Input | Required | Source | Safe example | Gate behavior |
|---|---|---|---|---|
run-id — Unique lowercase run ownership identifier |
Yes | parameter | az104l04-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 |
allow-management-group-change — Explicit YES gate for optional hierarchy mutation |
No | environment (AZ104_ALLOW_MANAGEMENT_GROUP_CHANGE) |
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 'az104l04-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 — Map management-group, subscription, and resource-group ancestry¶
Checkpoint: LAB04-CP01
Purpose and operational relevance: Read the hierarchy and establish the exact resource-group ID before applying governance controls. Governance scope mistakes happen when operators reason from names instead of full ARM scope IDs.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
$rgId = az group show --name $ResourceGroupName --query id --output tsv
az account management-group list --query '[].{name:name,displayName:displayName}' --output table
az group show --name $ResourceGroupName --query '{id:id,location:location,tags:tags}' --output json
Expected state: The resource group has the expected subscription-qualified ID and initial run-ownership tags.
Representative redacted output:
Positive validation:
$group = az group show --name $ResourceGroupName --output json | ConvertFrom-Json
if ($group.id -ne $rgId -or $group.tags.runId -ne $RunId) { throw 'Resource-group ID or run ownership tag mismatch.' }
$group | Select-Object id,location,tags
Expected positive result: The exact resource-group ID and runId tag match the manifest boundary.
Negative validation:
$foreign = @(az group list --tag "runId=$RunId" --output json | ConvertFrom-Json | Where-Object id -ne $rgId)
if ($foreign.Count -gt 0) { throw 'The run ID is already attached to another resource group.' }
'Conflicting resource groups: 0'
Expected negative result: No second resource group claims the run ID.
Evidence to retain:
LAB04-CP01UTC result for map management-group, subscription, and resource-group ancestry.- Exact run-owned ID and asserted service properties from: The exact resource-group ID and runId tag match the manifest boundary.
- Negative-boundary outcome from: No second resource group claims the run ID.
- Cleanup dependency recorded for the objects created or configured by LAB04-CP01.
Common failure and safe retry: The active subscription differs from the manifest or an earlier run reused the same ownership tag. Correct context outside the lab or choose a new run ID; never retag an unrelated group as a shortcut.
Cleanup dependency: The resource group is the final Azure deletion boundary after locks and optional hierarchy objects are removed.
Task 2 — Merge governance tags without replacing ownership tags¶
Checkpoint: LAB04-CP02
Purpose and operational relevance: Add environment, cost center, and owner metadata through tag merge semantics. Replacing the entire tag set can erase cleanup ownership and chargeback metadata.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
az tag update --resource-id $rgId --operation Merge --tags environment=training costCenter=az104 owner=learner --output none
Expected state: The three governance tags and the original purpose, labId, runId, and expiresOn ownership tags coexist.
Representative redacted output:
Positive validation:
$tags = az group show --name $ResourceGroupName --query tags --output json | ConvertFrom-Json -AsHashtable
foreach ($key in @('environment','costCenter','owner','purpose','labId','runId','expiresOn')) { if (-not $tags.ContainsKey($key)) { throw "Missing tag: $key" } }
$tags
Expected positive result: All governance and ownership keys are present after the merge.
Negative validation:
$tags = az group show --name $ResourceGroupName --query tags --output json | ConvertFrom-Json -AsHashtable
if ($tags['purpose'] -ne 'az104-lab' -or $tags['runId'] -ne $RunId) { throw 'Tag update replaced the cleanup ownership boundary.' }
'Ownership tags preserved.'
Expected negative result: The merge did not replace purpose or runId.
Evidence to retain:
LAB04-CP02UTC result for merge governance tags without replacing ownership tags.- Exact run-owned ID and asserted service properties from: All governance and ownership keys are present after the merge.
- Negative-boundary outcome from: The merge did not replace purpose or runId.
- Cleanup dependency recorded for the objects created or configured by LAB04-CP02.
Common failure and safe retry: An operator uses az tag create, which replaces rather than merges the existing tag set. Read current tags, restore manifest ownership values, then repeat az tag update with Merge.
Cleanup dependency: Tags disappear with the resource group; ownership values must remain until cleanup verifies them.
Task 3 — Prove that resource-group tags do not inherit automatically¶
Checkpoint: LAB04-CP03
Purpose and operational relevance: Create an untagged child virtual network, observe the empty tag set, then apply only the intended child tags. Cost and ownership reporting is incomplete unless inheritance is implemented through deployment or Policy.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
$vnet = "vnet04-$suffix"
$createdVnet = az network vnet create --resource-group $ResourceGroupName --name $vnet --location $Location --address-prefixes 10.4.0.0/16 --subnet-name workload --subnet-prefixes 10.4.1.0/24 --output json | ConvertFrom-Json
$createdVnetId = [string]$createdVnet.newVNet.id
if (-not $createdVnetId) { $createdVnetId = az network vnet show --resource-group $ResourceGroupName --name $vnet --query id --output tsv }
Add-ManagedObject -CheckpointId 'LAB04-CP03' -Kind 'azure-resource' -Id $createdVnetId -Name $vnet -Type 'Microsoft.Network/virtualNetworks' -Scope $rgId -OwnershipMethod 'manifest-id'
$before = az network vnet show --resource-group $ResourceGroupName --name $vnet --query tags --output json
if ($before -notin @('{}','null')) { throw 'New child unexpectedly inherited tags; investigate policy before continuing.' }
$vnetId = az network vnet show --resource-group $ResourceGroupName --name $vnet --query id --output tsv
az tag update --resource-id $vnetId --operation Merge --tags purpose=az104-lab labId=04 runId=$RunId environment=training --output none
Expected state: The before query proves no automatic inheritance; the final virtual network has explicit ownership and environment tags.
Representative redacted output:
Positive validation:
$child = az network vnet show --resource-group $ResourceGroupName --name $vnet --output json | ConvertFrom-Json
if ($child.tags.runId -ne $RunId -or $child.tags.environment -ne 'training') { throw 'Explicit child tags are incomplete.' }
$child | Select-Object id,tags
Expected positive result: The virtual network contains the explicit run ownership and environment tags.
Negative validation:
$rgTags = az group show --name $ResourceGroupName --query tags --output json | ConvertFrom-Json -AsHashtable
$childTags = az network vnet show --resource-group $ResourceGroupName --name $vnet --query tags --output json | ConvertFrom-Json -AsHashtable
if ($childTags.ContainsKey('costCenter')) { throw 'The exercise requires costCenter to remain absent from the child to prove non-inheritance.' }
[pscustomobject]@{ resourceGroupCostCenter = $rgTags['costCenter']; childCostCenter = $null }
Expected negative result: The child still lacks costCenter, demonstrating that resource-group tags did not propagate automatically.
Evidence to retain:
LAB04-CP03UTC result for prove that resource-group tags do not inherit automatically.- Exact run-owned ID and asserted service properties from: The virtual network contains the explicit run ownership and environment tags.
- Negative-boundary outcome from: The child still lacks costCenter, demonstrating that resource-group tags did not propagate automatically.
- Cleanup dependency recorded for the objects created or configured by LAB04-CP03.
Common failure and safe retry: A tenant Policy modifies tags, or the operator assumes resource-group metadata is inherited by children. Record any Policy effect, then use explicit tags that preserve the run boundary; never remove organization Policy.
Cleanup dependency: The virtual network is deleted with the unlocked resource group.
Task 4 — Apply and interpret a CanNotDelete lock¶
Checkpoint: LAB04-CP04
Purpose and operational relevance: Protect the workload lifecycle boundary and inspect the effective lock by its exact ARM ID. Locks protect control-plane mutations and can block cleanup even when RBAC permits deletion.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
$lock = az lock create --name $lockName --lock-type CanNotDelete --resource-group $ResourceGroupName --notes "AZ-104 Lab 04 run $RunId" --output json | ConvertFrom-Json
Add-ManagedObject -CheckpointId 'LAB04-CP04' -Kind 'azure-resource' -Id ([string]$lock.id) -Name $lockName -Type 'Microsoft.Authorization/locks' -Scope $rgId -OwnershipMethod 'manifest-id'
$PSNativeCommandUseErrorActionPreference = $false
$denied = az network vnet delete --resource-group $ResourceGroupName --name $vnet 2>&1
$deniedExit = $LASTEXITCODE
$PSNativeCommandUseErrorActionPreference = $true
if ($deniedExit -eq 0) {
$recreated = az network vnet create --resource-group $ResourceGroupName --name $vnet --location $Location --address-prefixes 10.4.0.0/16 --subnet-name workload --subnet-prefixes 10.4.1.0/24 --tags purpose=az104-lab labId=04 runId=$RunId environment=training --output json | ConvertFrom-Json
throw 'The child delete unexpectedly bypassed the resource-group lock; the virtual network was recreated for cleanup.'
}
$state.inputs['lock-denial-observed'] = $true
Save-RunState
Expected state: Exactly one CanNotDelete lock exists, its ID is recorded, and a safe child virtual-network delete is rejected.
Representative redacted output:
Positive validation:
$locks = @(az lock list --resource-group $ResourceGroupName --output json | ConvertFrom-Json | Where-Object name -eq $lockName)
if ($locks.Count -ne 1 -or $locks[0].level -ne 'CanNotDelete') { throw 'Expected one CanNotDelete lab lock.' }
$locks[0] | Select-Object id,name,level,notes
Expected positive result: The exact run lock is present with level CanNotDelete.
Negative validation:
$foreignLocks = @(az lock list --resource-group $ResourceGroupName --output json | ConvertFrom-Json | Where-Object name -ne $lockName)
if ($foreignLocks.Count -gt 0) { throw 'An unowned lock exists; cleanup must refuse to remove it.' }
if (-not $state.inputs['lock-denial-observed']) { throw 'The safe child-delete denial was not recorded.' }
'Unowned locks at scope=0; child delete denied=True'
Expected negative result: No foreign lock exists and the controlled child deletion was denied by the run-owned lock.
Evidence to retain:
LAB04-CP04UTC result for apply and interpret a CanNotDelete lock.- Exact run-owned ID and asserted service properties from: The exact run lock is present with level CanNotDelete.
- Negative-boundary outcome from: No foreign lock exists and the controlled child deletion was denied by the run-owned lock.
- Cleanup dependency recorded for the objects created or configured by LAB04-CP04.
Common failure and safe retry: Lock write permission is missing or an organization-owned inherited lock already governs the scope. List locks first and create only the deterministic lab lock when no name collision exists.
Cleanup dependency: Cleanup removes this exact lock ID before attempting resource-group deletion and refuses foreign locks.
Task 5 — Exercise an optional tenant-authorized management-group node¶
Checkpoint: LAB04-CP05
Purpose and operational relevance: Create one empty child management group only when the explicit tenant-change gate is present, then record its ARM ID. Management groups are tenant-scoped governance boundaries and must not be created as an implicit lab side effect.
Run these commands from PowerShell. If you already used the automated lane, choose a new run ID before trying this guided lane.
if ($env:AZ104_ALLOW_MANAGEMENT_GROUP_CHANGE -ne 'YES') { throw 'Set the management-group gate to the exact value YES.' }
$managementGroup = "mg-az104-$suffix"
$mg = az account management-group create --name $managementGroup --display-name "AZ104 $RunId" --output json | ConvertFrom-Json
Add-ManagedObject -CheckpointId 'LAB04-CP05' -Kind 'azure-resource' -Id ([string]$mg.id) -Name $managementGroup -Type 'Microsoft.Management/managementGroups' -Scope '/providers/Microsoft.Management/managementGroups' -OwnershipMethod 'manifest-id'
Expected state: One empty run-specific management group exists only when tenant change is acknowledged and its ID is manifest-recorded.
Representative redacted output:
Positive validation:
$managementGroup = "mg-az104-$suffix"
$mg = az account management-group show --name $managementGroup --expand --recurse --output json | ConvertFrom-Json
if ($mg.name -ne $managementGroup) { throw 'Run-specific management group was not found.' }
$mg | Select-Object id,name,displayName
Expected positive result: The exact management group exists and matches the deterministic name.
Negative validation:
$managementGroup = "mg-az104-$suffix"
$children = @(az account management-group show --name $managementGroup --expand --recurse --query children --output json | ConvertFrom-Json)
if ($children.Count -gt 0) { throw 'The lab management group must remain empty for deterministic cleanup.' }
'Management-group children: 0'
Expected negative result: The lab does not move a subscription or attach child management groups.
Evidence to retain:
LAB04-CP05UTC result for exercise an optional tenant-authorized management-group node.- Exact run-owned ID and asserted service properties from: The exact management group exists and matches the deterministic name.
- Negative-boundary outcome from: The lab does not move a subscription or attach child management groups.
- Cleanup dependency recorded for the objects created or configured by LAB04-CP05.
Common failure and safe retry: The tenant does not permit management-group creation or the deterministic name exists from a prior run. Query the exact name and reconcile ownership; never adopt or delete an organization-owned management group.
Cleanup dependency: Delete the empty manifest-recorded management group by name before final residual validation.
Final validation and result interpretation¶
Run independent deployment validation after all required checkpoints:
./scripts/cli/Validate.ps1 -RunId 'az104l04-01' -Mode Deployment
az resource list --resource-group 'rg-az104-l04-az104l04-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: Try to delete the run-owned child virtual network while the resource-group lock exists.
Expected symptom: Azure rejects the safe child deletion with a scope-lock error.
Diagnose with a read-only service query:
$foreign = @(az group list --tag "runId=$RunId" --output json | ConvertFrom-Json | Where-Object id -ne $rgId)
if ($foreign.Count -gt 0) { throw 'The run ID is already attached to another resource group.' }
'Conflicting resource groups: 0'
Diagnosis: List inherited and direct locks at the target scope.
Repair: Preserve the protected child, then remove only the recorded lab lock during dependency-aware cleanup.
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¶
Define an ownership and expiry tagging standard and explain which tags need Policy-based inheritance.
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 'az104l04-01'
./scripts/cli/Cleanup.ps1 -RunId 'az104l04-01' -Execute
./scripts/cli/Validate.ps1 -RunId 'az104l04-01' -Mode PostCleanup
$managementGroup = "mg-az104-$suffix"
$children = @(az account management-group show --name $managementGroup --expand --recurse --query children --output json | ConvertFrom-Json)
if ($children.Count -gt 0) { throw 'The lab management group must remain empty for deterministic cleanup.' }
'Management-group children: 0'
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
- Manage Azure resource groups using Azure CLI
- Manage Azure resource tags using Azure CLI
- Lock Azure resources to protect infrastructure
- Manage Azure management groups using 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 ('04' -in @('00', '01', '02')) { $null } else { "rg-az104-l04-$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.Authorization'
'Microsoft.Management'
'Microsoft.Resources'
)
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' })
$gateMatches = [string]::Equals([Environment]::GetEnvironmentVariable('AZ104_ALLOW_MANAGEMENT_GROUP_CHANGE'), 'YES', [StringComparison]::Ordinal)
Add-PreflightResult -Id 'optional-gate:AZ104_ALLOW_MANAGEMENT_GROUP_CHANGE' -Required $false -Passed $gateMatches -Actual $(if ($gateMatches) { 'exact affirmative value supplied (value redacted)' } else { 'absent or not exact; checkpoint will be skipped' })
$managementGroupAuthorization = [Environment]::GetEnvironmentVariable('AZ104_ALLOW_MANAGEMENT_GROUP_CHANGE')
$probePassed = $true
$probeActual = 'assertion passed (output not persisted)'
try {
$LASTEXITCODE = 0
& {
$subscription = az account show --query '{id:id,tenantId:tenantId}' --output json | ConvertFrom-Json
if (-not $subscription.id -or -not $subscription.tenantId) { throw 'Subscription hierarchy context is incomplete.' }
} | 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.lab04.hierarchy-read' -Required $true -Passed $probePassed -Actual $probeActual
$probePassed = $true
$probeActual = 'assertion passed (output not persisted)'
try {
$LASTEXITCODE = 0
& {
if ($managementGroupAuthorization -eq 'YES') {
$groups = az account management-group list --output json | ConvertFrom-Json
if ($null -eq $groups) { throw 'Management-group inventory 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.lab04.management-group-gate' -Required $false -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 ('04' -in @('00', '01', '02')) { $null } else { "rg-az104-l04-$RunId" })
$suffix = (($RunId -replace '[^a-z0-9]', '') + '000000000000').Substring(0, 12)
Write-Host 'LAB-04 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 ($false -and -not $AcknowledgeCost) { throw 'This lab requires -AcknowledgeCost before execution.' }
if ($false -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-04'
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 = 'LAB04-CP01'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB04-CP02'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB04-CP03'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB04-CP04'; required = $true; status = 'pending'; updatedAt = $now; message = 'Not started.' }
@{ checkpointId = 'LAB04-CP05'; required = $false; 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 = '04'; 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) { 'LAB04-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=04 runId=$RunId expiresOn=$expiresOn --output none
} catch {
Write-CheckpointState -CheckpointId 'LAB04-CP01' -Status 'fail' -Message "Resource-group boundary creation failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
} finally {
Sync-ManagedResource -CheckpointId 'LAB04-CP01'
}
# CHECKPOINT LAB04-CP01 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB04-CP01' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB04-CP01'
Save-RunState
$rgId = az group show --name $ResourceGroupName --query id --output tsv
az account management-group list --query '[].{name:name,displayName:displayName}' --output table
az group show --name $ResourceGroupName --query '{id:id,location:location,tags:tags}' --output json
Sync-ManagedResource -CheckpointId 'LAB04-CP01'
Write-CheckpointState -CheckpointId 'LAB04-CP01' -Status 'pass' -Message 'The resource group has the expected subscription-qualified ID and initial run-ownership tags.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB04-CP01'
Write-CheckpointState -CheckpointId 'LAB04-CP01' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB04-CP01 END
# CHECKPOINT LAB04-CP02 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB04-CP02' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB04-CP02'
Save-RunState
az tag update --resource-id $rgId --operation Merge --tags environment=training costCenter=az104 owner=learner --output none
Sync-ManagedResource -CheckpointId 'LAB04-CP02'
Write-CheckpointState -CheckpointId 'LAB04-CP02' -Status 'pass' -Message 'The three governance tags and the original purpose, labId, runId, and expiresOn ownership tags coexist.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB04-CP02'
Write-CheckpointState -CheckpointId 'LAB04-CP02' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB04-CP02 END
# CHECKPOINT LAB04-CP03 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB04-CP03' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB04-CP03'
Save-RunState
$vnet = "vnet04-$suffix"
try {
$createdVnet = az network vnet create --resource-group $ResourceGroupName --name $vnet --location $Location --address-prefixes 10.4.0.0/16 --subnet-name workload --subnet-prefixes 10.4.1.0/24 --output json | ConvertFrom-Json
} finally {
Sync-ManagedResource -CheckpointId 'LAB04-CP03'
}
$createdVnetId = [string]$createdVnet.newVNet.id
if (-not $createdVnetId) { $createdVnetId = az network vnet show --resource-group $ResourceGroupName --name $vnet --query id --output tsv }
Add-ManagedObject -CheckpointId 'LAB04-CP03' -Kind 'azure-resource' -Id $createdVnetId -Name $vnet -Type 'Microsoft.Network/virtualNetworks' -Scope $rgId -OwnershipMethod 'manifest-id'
$before = az network vnet show --resource-group $ResourceGroupName --name $vnet --query tags --output json
if ($before -notin @('{}','null')) { throw 'New child unexpectedly inherited tags; investigate policy before continuing.' }
$vnetId = az network vnet show --resource-group $ResourceGroupName --name $vnet --query id --output tsv
az tag update --resource-id $vnetId --operation Merge --tags purpose=az104-lab labId=04 runId=$RunId environment=training --output none
Sync-ManagedResource -CheckpointId 'LAB04-CP03'
Write-CheckpointState -CheckpointId 'LAB04-CP03' -Status 'pass' -Message 'The before query proves no automatic inheritance; the final virtual network has explicit ownership and environment tags.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB04-CP03'
Write-CheckpointState -CheckpointId 'LAB04-CP03' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB04-CP03 END
# CHECKPOINT LAB04-CP04 BEGIN
try {
Write-CheckpointState -CheckpointId 'LAB04-CP04' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB04-CP04'
Save-RunState
try {
$lock = az lock create --name $lockName --lock-type CanNotDelete --resource-group $ResourceGroupName --notes "AZ-104 Lab 04 run $RunId" --output json | ConvertFrom-Json
} finally {
Sync-ManagedResource -CheckpointId 'LAB04-CP04'
}
Add-ManagedObject -CheckpointId 'LAB04-CP04' -Kind 'azure-resource' -Id ([string]$lock.id) -Name $lockName -Type 'Microsoft.Authorization/locks' -Scope $rgId -OwnershipMethod 'manifest-id'
$PSNativeCommandUseErrorActionPreference = $false
$denied = az network vnet delete --resource-group $ResourceGroupName --name $vnet 2>&1
$deniedExit = $LASTEXITCODE
$PSNativeCommandUseErrorActionPreference = $true
if ($deniedExit -eq 0) {
try {
$recreated = az network vnet create --resource-group $ResourceGroupName --name $vnet --location $Location --address-prefixes 10.4.0.0/16 --subnet-name workload --subnet-prefixes 10.4.1.0/24 --tags purpose=az104-lab labId=04 runId=$RunId environment=training --output json | ConvertFrom-Json
} finally {
Sync-ManagedResource -CheckpointId 'LAB04-CP04'
}
throw 'The child delete unexpectedly bypassed the resource-group lock; the virtual network was recreated for cleanup.'
}
$state.inputs['lock-denial-observed'] = $true
Save-RunState
Sync-ManagedResource -CheckpointId 'LAB04-CP04'
Write-CheckpointState -CheckpointId 'LAB04-CP04' -Status 'pass' -Message 'Exactly one CanNotDelete lock exists, its ID is recorded, and a safe child virtual-network delete is rejected.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB04-CP04'
Write-CheckpointState -CheckpointId 'LAB04-CP04' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
# CHECKPOINT LAB04-CP04 END
# CHECKPOINT LAB04-CP05 BEGIN
if (-not ([string]::Equals([Environment]::GetEnvironmentVariable('AZ104_ALLOW_MANAGEMENT_GROUP_CHANGE'), 'YES', [StringComparison]::Ordinal))) {
Write-CheckpointState -CheckpointId 'LAB04-CP05' -Status 'skipped' -Message 'Optional input or exact authorization gate was not satisfied.'
Save-RunState
} else {
try {
Write-CheckpointState -CheckpointId 'LAB04-CP05' -Status 'in-progress' -Message 'Checkpoint execution started.'
$activeCheckpointId = 'LAB04-CP05'
Save-RunState
if ($env:AZ104_ALLOW_MANAGEMENT_GROUP_CHANGE -ne 'YES') { throw 'Set the management-group gate to the exact value YES.' }
$managementGroup = "mg-az104-$suffix"
try {
$mg = az account management-group create --name $managementGroup --display-name "AZ104 $RunId" --output json | ConvertFrom-Json
} finally {
Sync-ManagedResource -CheckpointId 'LAB04-CP05'
}
Add-ManagedObject -CheckpointId 'LAB04-CP05' -Kind 'azure-resource' -Id ([string]$mg.id) -Name $managementGroup -Type 'Microsoft.Management/managementGroups' -Scope '/providers/Microsoft.Management/managementGroups' -OwnershipMethod 'manifest-id'
Sync-ManagedResource -CheckpointId 'LAB04-CP05'
Write-CheckpointState -CheckpointId 'LAB04-CP05' -Status 'pass' -Message 'One empty run-specific management group exists only when tenant change is acknowledged and its ID is manifest-recorded.'
Save-RunState
} catch {
Sync-ManagedResource -CheckpointId 'LAB04-CP05'
Write-CheckpointState -CheckpointId 'LAB04-CP05' -Status 'fail' -Message "Checkpoint failed: $($_.Exception.GetType().Name)"
$state.status = 'failed'
Save-RunState
throw
}
}
# CHECKPOINT LAB04-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-04' -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-04'; runId = $RunId; mode = $Mode
generatedAt = $capturedAt; result = 'fail'
summary = @{ required = 1; passed = 0; failed = 1; skipped = 0 }
checks = @(@{ id = 'context.active'; checkpointId = 'LAB04-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 ('04' -in @('00', '01', '02')) { $null } else { "rg-az104-l04-$RunId" })
$suffix = (($RunId -replace '[^a-z0-9]', '') + '000000000000').Substring(0, 12)
$rgId = az group show --name $ResourceGroupName --query id --output tsv
$vnet = "vnet04-$suffix"
$lockName = "lock-$RunId"
$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 LAB04-CP01 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB04-CP01' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP01' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab04-cp01.positive' -CheckpointId 'LAB04-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 'lab04-cp01.negative' -CheckpointId 'LAB04-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
& {
$group = az group show --name $ResourceGroupName --output json | ConvertFrom-Json
if ($group.id -ne $rgId -or $group.tags.runId -ne $RunId) { throw 'Resource-group ID or run ownership tag mismatch.' }
$group | Select-Object id,location,tags
} | 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 'lab04-cp01.positive' -CheckpointId 'LAB04-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
& {
$foreign = @(az group list --tag "runId=$RunId" --output json | ConvertFrom-Json | Where-Object id -ne $rgId)
if ($foreign.Count -gt 0) { throw 'The run ID is already attached to another resource group.' }
'Conflicting resource groups: 0'
} | 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 'lab04-cp01.negative' -CheckpointId 'LAB04-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 'lab04-cp01.residual' -CheckpointId 'LAB04-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 LAB04-CP01 END
# CHECKPOINT LAB04-CP02 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB04-CP02' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP02' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab04-cp02.positive' -CheckpointId 'LAB04-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 'lab04-cp02.negative' -CheckpointId 'LAB04-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
& {
$tags = az group show --name $ResourceGroupName --query tags --output json | ConvertFrom-Json -AsHashtable
foreach ($key in @('environment','costCenter','owner','purpose','labId','runId','expiresOn')) { if (-not $tags.ContainsKey($key)) { throw "Missing tag: $key" } }
$tags
} | 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 'lab04-cp02.positive' -CheckpointId 'LAB04-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
& {
$tags = az group show --name $ResourceGroupName --query tags --output json | ConvertFrom-Json -AsHashtable
if ($tags['purpose'] -ne 'az104-lab' -or $tags['runId'] -ne $RunId) { throw 'Tag update replaced the cleanup ownership boundary.' }
'Ownership tags preserved.'
} | 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 'lab04-cp02.negative' -CheckpointId 'LAB04-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 'lab04-cp02.residual' -CheckpointId 'LAB04-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 LAB04-CP02 END
# CHECKPOINT LAB04-CP03 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB04-CP03' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP03' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab04-cp03.positive' -CheckpointId 'LAB04-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 'lab04-cp03.negative' -CheckpointId 'LAB04-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
& {
$child = az network vnet show --resource-group $ResourceGroupName --name $vnet --output json | ConvertFrom-Json
if ($child.tags.runId -ne $RunId -or $child.tags.environment -ne 'training') { throw 'Explicit child tags are incomplete.' }
$child | Select-Object id,tags
} | 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 'lab04-cp03.positive' -CheckpointId 'LAB04-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
& {
$rgTags = az group show --name $ResourceGroupName --query tags --output json | ConvertFrom-Json -AsHashtable
$childTags = az network vnet show --resource-group $ResourceGroupName --name $vnet --query tags --output json | ConvertFrom-Json -AsHashtable
if ($childTags.ContainsKey('costCenter')) { throw 'The exercise requires costCenter to remain absent from the child to prove non-inheritance.' }
[pscustomobject]@{ resourceGroupCostCenter = $rgTags['costCenter']; childCostCenter = $null }
} | 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 'lab04-cp03.negative' -CheckpointId 'LAB04-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 'lab04-cp03.residual' -CheckpointId 'LAB04-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 LAB04-CP03 END
# CHECKPOINT LAB04-CP04 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB04-CP04' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP04' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab04-cp04.positive' -CheckpointId 'LAB04-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 'lab04-cp04.negative' -CheckpointId 'LAB04-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
& {
$locks = @(az lock list --resource-group $ResourceGroupName --output json | ConvertFrom-Json | Where-Object name -eq $lockName)
if ($locks.Count -ne 1 -or $locks[0].level -ne 'CanNotDelete') { throw 'Expected one CanNotDelete lab lock.' }
$locks[0] | Select-Object id,name,level,notes
} | 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 'lab04-cp04.positive' -CheckpointId 'LAB04-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
& {
$foreignLocks = @(az lock list --resource-group $ResourceGroupName --output json | ConvertFrom-Json | Where-Object name -ne $lockName)
if ($foreignLocks.Count -gt 0) { throw 'An unowned lock exists; cleanup must refuse to remove it.' }
if (-not $state.inputs['lock-denial-observed']) { throw 'The safe child-delete denial was not recorded.' }
'Unowned locks at scope=0; child delete denied=True'
} | 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 'lab04-cp04.negative' -CheckpointId 'LAB04-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 'lab04-cp04.residual' -CheckpointId 'LAB04-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 LAB04-CP04 END
# CHECKPOINT LAB04-CP05 BEGIN
$checkpointState = @($state.checkpointStates | Where-Object { $_.checkpointId -eq 'LAB04-CP05' })[0]
$checkpointObjects = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP05' })
if ($Mode -eq 'Deployment') {
if ($checkpointState.status -eq 'skipped') {
Add-ValidationCheck -Id 'lab04-cp05.positive' -CheckpointId 'LAB04-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 'lab04-cp05.negative' -CheckpointId 'LAB04-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
& {
$managementGroup = "mg-az104-$suffix"
$mg = az account management-group show --name $managementGroup --expand --recurse --output json | ConvertFrom-Json
if ($mg.name -ne $managementGroup) { throw 'Run-specific management group was not found.' }
$mg | Select-Object id,name,displayName
} | 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 'lab04-cp05.positive' -CheckpointId 'LAB04-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
& {
$managementGroup = "mg-az104-$suffix"
$children = @(az account management-group show --name $managementGroup --expand --recurse --query children --output json | ConvertFrom-Json)
if ($children.Count -gt 0) { throw 'The lab management group must remain empty for deterministic cleanup.' }
'Management-group children: 0'
} | 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 'lab04-cp05.negative' -CheckpointId 'LAB04-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 'lab04-cp05.residual' -CheckpointId 'LAB04-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 LAB04-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-04'
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-04'; 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-04' -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-04'; 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 ('04' -in @('00', '01', '02')) { $null } else { "rg-az104-l04-$RunId" })
$suffix = (($RunId -replace '[^a-z0-9]', '') + '000000000000').Substring(0, 12)
$lockName = "lock-$RunId"
$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 '04' -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 '04' -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 LAB04-CP05 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP05' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB04-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 LAB04-CP05 END
# CHECKPOINT LAB04-CP04 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP04' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB04-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 LAB04-CP04 END
# CHECKPOINT LAB04-CP03 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP03' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB04-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 LAB04-CP03 END
# CHECKPOINT LAB04-CP02 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP02' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB04-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 LAB04-CP02 END
# CHECKPOINT LAB04-CP01 BEGIN
$targets = @($state.managedObjects | Where-Object { $_.checkpointId -eq 'LAB04-CP01' -and $_.lifecycleStatus -eq 'active' })
foreach ($target in $targets) {
$alreadyAbsent = $absentIds.Contains([string]$target.id)
$actions.Add(@{ checkpointId = 'LAB04-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 LAB04-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) {
$lockRecord = @($state.managedObjects | Where-Object { $_.type -eq 'Microsoft.Authorization/locks' -and $_.name -eq $lockName -and $_.lifecycleStatus -eq 'active' })
if ($lockRecord.Count -eq 1) {
$liveLockId = az lock show --ids $lockRecord[0].id --query id --output tsv 2>$null
if ($LASTEXITCODE -eq 0 -and $liveLockId -eq $lockRecord[0].id) {
az lock delete --ids $lockRecord[0].id
}
}
}
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 'LAB04-CP01' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp01.residual'; command = 'query manifest-recorded IDs for LAB04-CP01'; expected = 'zero active objects for LAB04-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 'LAB04-CP02' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp02.residual'; command = 'query manifest-recorded IDs for LAB04-CP02'; expected = 'zero active objects for LAB04-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 'LAB04-CP03' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp03.residual'; command = 'query manifest-recorded IDs for LAB04-CP03'; expected = 'zero active objects for LAB04-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 'LAB04-CP04' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp04.residual'; command = 'query manifest-recorded IDs for LAB04-CP04'; expected = 'zero active objects for LAB04-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 'LAB04-CP05' } | ForEach-Object id) }); $residualChecks.Add(@{ id = 'cp05.residual'; command = 'query manifest-recorded IDs for LAB04-CP05'; expected = 'zero active objects for LAB04-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-04'
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