Hands-on LabLesson 14 of 16

Lab: Azure Resource Automation

Provision and manage Azure resources with the Az module: create resource groups, deploy and tag virtual machines, configure storage accounts, generate inventory reports, and tear down environments cleanly.

This lab covers the full Azure resource lifecycle using PowerShell and the Az module. You will write scripts that provision an environment, verify it, generate a structured inventory, and clean up—the same pattern used in CI/CD pipelines and Azure Automation Runbooks.

🔑
Prerequisites for This Lab

You need an Azure subscription (Azure free account works). Install the Az module: Install-Module Az.Accounts, Az.Resources, Az.Compute, Az.Storage -Force -Scope CurrentUser. Run Connect-AzAccount to authenticate. All scripts use $ErrorActionPreference = 'Stop' — if any step fails, the error is raised and the script stops rather than continuing in an inconsistent state.

🧪 Lab 1 — Provision a Test Environment

Create a complete test environment: resource group, storage account, and then register the configuration for later cleanup.

powershell
#Requires -Modules Az.Accounts, Az.Resources, Az.Storage
[CmdletBinding(SupportsShouldProcess)]
param(
    [ValidateSet("dev","staging","test")]
    [string]$Environment = "test",

    [string]$Location = "uksouth",

    [string]$Project = "skilly"
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Verify we are authenticated
$ctx = Get-AzContext
if (-not $ctx) {
    Write-Error "Not authenticated. Run Connect-AzAccount first."
    exit 1
}
Write-Host "Using subscription: $($ctx.Subscription.Name) ($($ctx.Subscription.Id))"

# Build consistent names
$suffix    = "$Project-$Environment"
$rgName    = "rg-$suffix"
$stoName   = "st$($Project)$Environment"  # storage names: lowercase, no hyphens
$tags      = @{ project = $Project; environment = $Environment; owner = $ctx.Account.Id; created = (Get-Date -Format "yyyy-MM-dd") }

Write-Host "`n--- Creating Resource Group ---"
if ($PSCmdlet.ShouldProcess($rgName, "Create resource group in $Location")) {
    $rg = New-AzResourceGroup -Name $rgName -Location $Location -Tag $tags -Force
    Write-Host "Resource group: $($rg.ResourceGroupName) [$($rg.Location)]"
}

Write-Host "`n--- Creating Storage Account ---"
if ($PSCmdlet.ShouldProcess($stoName, "Create storage account in $rgName")) {
    $sto = New-AzStorageAccount `
        -ResourceGroupName $rgName `
        -Name $stoName `
        -Location $Location `
        -SkuName "Standard_LRS" `
        -Kind "StorageV2" `
        -Tag $tags
    Write-Host "Storage account: $($sto.StorageAccountName) [$($sto.Sku.Name)]"

    # Create a container for test artifacts
    $ctx2 = $sto.Context
    New-AzStorageContainer -Name "artifacts" -Context $ctx2 -Permission Off | Out-Null
    Write-Host "Created 'artifacts' container (private)."
}

# Save environment config for later labs
$config = [PSCustomObject]@{
    Environment    = $Environment
    Location       = $Location
    ResourceGroup  = $rgName
    StorageAccount = $stoName
    CreatedAt      = (Get-Date -Format "o")
    Subscription   = $ctx.Subscription.Id
}
$config | ConvertTo-Json | Set-Content -Path ".\env-config-$Environment.json" -Encoding UTF8
Write-Host "`nEnvironment config saved to env-config-$Environment.json"
Write-Host "`n✅ Environment provisioned successfully."

🧪 Lab 2 — Upload Artifacts and Generate Inventory

Upload a test blob to the storage account and generate a full inventory report of all resources in the lab resource group.

powershell
#Requires -Modules Az.Accounts, Az.Resources, Az.Storage
[CmdletBinding()]
param(
    [string]$ConfigFile = ".\env-config-test.json",
    [string]$InventoryOutput = ".\inventory.csv"
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

# Load environment config
if (-not (Test-Path $ConfigFile)) { throw "Config not found: $ConfigFile — run Lab 1 first." }
$cfg = Get-Content $ConfigFile | ConvertFrom-Json

Write-Host "Working with: $($cfg.ResourceGroup) in $($cfg.Location)"

# --- UPLOAD TEST ARTIFACT ---
Write-Host "`n--- Uploading Test Blob ---"
$testContent = "Hello from PowerShell lab - $(Get-Date -Format 'o')"
$tempFile = [System.IO.Path]::GetTempFileName() -replace '\.tmp$','.txt'
$testContent | Set-Content -Path $tempFile -Encoding UTF8

$stoCtx = (Get-AzStorageAccount -ResourceGroupName $cfg.ResourceGroup -Name $cfg.StorageAccount).Context
Set-AzStorageBlobContent `
    -Container "artifacts" `
    -File $tempFile `
    -Blob "test/hello.txt" `
    -Context $stoCtx `
    -Force | Out-Null
Remove-Item $tempFile -Force
Write-Host "Uploaded test blob: artifacts/test/hello.txt"

# Verify by listing container
$blobs = Get-AzStorageBlob -Container "artifacts" -Context $stoCtx
Write-Host "Blobs in artifacts container: $($blobs.Count)"

# --- GENERATE RESOURCE INVENTORY ---
Write-Host "`n--- Generating Resource Inventory ---"
$resources = Get-AzResource -ResourceGroupName $cfg.ResourceGroup

$inventory = $resources | Select-Object `
    @{n="Name";     e={$_.Name}},
    @{n="Type";     e={$_.ResourceType}},
    @{n="Location"; e={$_.Location}},
    @{n="Tags";     e={$_.Tags.Keys -join "; "}},
    @{n="ResourceId";e={$_.ResourceId}}

$inventory | Format-Table Name, Type, Location, Tags -AutoSize
$inventory | Export-Csv -Path $InventoryOutput -NoTypeInformation
Write-Host "Inventory exported to $InventoryOutput ($($resources.Count) resources)"

🧪 Lab 3 — Tag Compliance Report

A critical governance task: find all resources missing required tags and report (or remediate) them.

powershell
#Requires -Modules Az.Accounts, Az.Resources
[CmdletBinding(SupportsShouldProcess)]
param(
    [string[]]$RequiredTags = @("project","environment","owner"),
    [string]$SubscriptionId = "",
    [switch]$Remediate   # if specified, applies placeholder tags to non-compliant resources
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

if ($SubscriptionId) { Set-AzContext -SubscriptionId $SubscriptionId }
$ctx = Get-AzContext
Write-Host "Subscription: $($ctx.Subscription.Name)"

$allResources = Get-AzResource
Write-Host "Scanning $($allResources.Count) resources for required tags: $($RequiredTags -join ', ')"

$report = $allResources | ForEach-Object {
    $missing = $RequiredTags | Where-Object { -not $_.ResourceType -and -not $_.Tags.ContainsKey($_) }
    # Fix: check the resource's tags
    $resourceTags = $_.Tags ?? @{}
    $missingTags = $RequiredTags | Where-Object { -not $resourceTags.ContainsKey($_) }

    [PSCustomObject]@{
        Name         = $_.Name
        Type         = $_.ResourceType
        ResourceGroup= $_.ResourceGroupName
        MissingTags  = $missingTags -join "; "
        IsCompliant  = $missingTags.Count -eq 0
        ResourceId   = $_.ResourceId
    }
}

$compliant    = ($report | Where-Object IsCompliant).Count
$nonCompliant = ($report | Where-Object { -not $_.IsCompliant }).Count

Write-Host "`nCompliance Summary:"
Write-Host "  Compliant:     $compliant"
Write-Warning "  Non-compliant: $nonCompliant"

$report | Where-Object { -not $_.IsCompliant } |
    Select-Object Name, Type, ResourceGroup, MissingTags |
    Format-Table -AutoSize

$report | Export-Csv -Path ".\tag-compliance.csv" -NoTypeInformation
Write-Host "Full report saved to tag-compliance.csv"

# Remediate if requested
if ($Remediate) {
    $toFix = $report | Where-Object { -not $_.IsCompliant }
    foreach ($item in $toFix) {
        if ($PSCmdlet.ShouldProcess($item.Name, "Apply placeholder tags: $($item.MissingTags)")) {
            $placeholders = @{}
            $item.MissingTags.Split(";").Trim() | Where-Object { $_ } | ForEach-Object {
                $placeholders[$_] = "NEEDS-REVIEW"
            }
            Update-AzTag -ResourceId $item.ResourceId -Tag $placeholders -Operation Merge | Out-Null
            Write-Verbose "Tagged $($item.Name) with: $($placeholders.Keys -join ', ')"
        }
    }
    Write-Host "Remediation complete."
}

🧪 Lab 4 — Clean Up Environment

Tear down the lab environment cleanly, with confirmation and a summary of what will be removed.

powershell
#Requires -Modules Az.Accounts, Az.Resources
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
    [Parameter(Mandatory)]
    [ValidateSet("dev","staging","test")]
    [string]$Environment
)

Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'

$configFile = ".\env-config-$Environment.json"
if (-not (Test-Path $configFile)) {
    Write-Error "Config file not found: $configFile"
    exit 1
}
$cfg = Get-Content $configFile | ConvertFrom-Json
$rgName = $cfg.ResourceGroup

# Show what will be deleted
$resources = Get-AzResource -ResourceGroupName $rgName
Write-Host "`nThe following resource group and $($resources.Count) resources will be PERMANENTLY DELETED:"
Write-Host "  Resource Group: $rgName"
$resources | ForEach-Object { Write-Host "  - $($_.Name) ($($_.ResourceType))" }

if ($PSCmdlet.ShouldProcess($rgName, "Delete resource group and all contained resources")) {
    Write-Host "`nDeleting $rgName ..."
    Remove-AzResourceGroup -Name $rgName -Force | Out-Null
    Remove-Item $configFile -Force
    Write-Host "✅ Resource group deleted and config file removed."
} else {
    Write-Host "Operation cancelled (use -Confirm:\$false to skip confirmation)."
}
💡
Cost Tip for Lab Cleanup

Always run Lab 4 when you finish this exercise. Azure charges begin as soon as resources are provisioned—even a basic storage account and empty resource group costs fractions of a penny per hour, but 20 forgotten test environments across a team accumulate quickly. Set a calendar reminder if you cannot run the cleanup immediately.

🧪 Exercises

  1. Run Lab 1 with -WhatIf to preview the provisioning steps without creating resources.
  2. Run Lab 1 for real, then run Lab 2 to upload the test blob and see the inventory CSV.
  3. Run Lab 3 against your subscription to see the real tag compliance state.
  4. Write a function that takes a storage account context and lists all blobs with their sizes in MB.
  5. Run Lab 4 to clean up (always required).
🎮
Final Challenge

Combine Labs 1–4 into a single script called Invoke-AzureLabLifecycle.ps1 with a -Phase parameter (Create, Report, Cleanup). Each phase calls the corresponding logic. This is the pattern used by IaC teams for ephemeral environment management — create on PR open, report at PR review, clean up on PR merge.