Skip to content
CAMPUX Cloud Bootcamp Phase Three · Class Twenty-Five
Phase Three — DevOps Core
4 drills · 2 situations · 2 Labs
Aligned to AZ-400
Class Twenty-Five

Scripting for cloud engineers

Between the portal you have outgrown and the pipelines you have built sits the humble script — the tool for every task too small for Bicep and too frequent for clicking, and the easiest place in this whole bootcamp to hurt yourself.

Phase Three · DevOps Core · Class 25 of 42 · 29 min · Assumes Class 16

After this you can automate a repeatable Azure task in Bash, PowerShell or the CLI, and know which to reach for.

New to this? Read Class 16 first. Already done it, or know the material? Carry on.

§1

Three tools, and when each wins

A cloud engineer often needs to answer a repeatable question: "Which disks are not attached to a VM?" or "Which resource groups lack an owner tag?" The Azure portal can answer one item at a time; a short script can collect the answer across a subscription. A script can also change resources, so begin with a report that only reads. This class introduces the Azure CLI in Bash, then shows the same idea in PowerShell and Python.1

Table 1 — Three tools, three home grounds
ToolIts natural habitatReach for it when
az CLI + BashPipelines, Cloud Shell, quick one-liners — terse commands emitting JSONThe task is glue: query something, loop over it, act. Most of this class.
Azure PowerShellWindows-heavy shops; scripts with real logic — cmdlets returning structured objects, not textYou need branching, error types, and reporting more than brevity — or the team already lives in PowerShell.
Python + Azure SDKPrograms with reusable functions, tests, and Azure SDK clientsThe task needs application logic or libraries that a shell script would make hard to maintain.

The honest guidance: learn the az CLI cold, because it is the lingua franca of every pipeline you built this phase; be conversant in PowerShell, because half the shops that interview you run on it; and know when a task has outgrown both and deserves Python. What follows — idempotency, loud failure, querying — applies identically to all three, because the discipline is the skill. The syntax is a costume.

Bash, enough to be dangerous

You have been running Bash since Class 7. The terminal warm-up covered storing and printing a value, quiet commands, file blocks and a small function; if any of that still feels shaky, spend ten minutes there first. This class picks up where it stopped.

The az CLI lives inside a shell, and the shell is where beginners cut themselves. Four fundamentals carry most scripts. Variables take no spaces around the = — SUB=$(az account show --query id -o tsv) assigns, while SUB = ... tries to run a program named SUB — and you quote every expansion, "$SUB" and never bare $SUB, so a value carrying a space, or an empty value, cannot silently reshape the command around it. Command substitution, $(...), captures one command's output as text for another; it is the joint that turns a query into an input.

# act on every unattached disk in one resource group — one line at a time, safely
az disk list --resource-group <resource-group> --query "[?diskState=='Unattached'].id" -o tsv |
tr -d '\r' |   # on Windows the CLI ends each line with a carriage return; strip it
while IFS= read -r id; do
  echo "would remove: $id"
done

Loops walk a list, and the shape above is the sturdy one: -o tsv emits one id per line, and while IFS= read -r consumes them a line at a time — safer than for id in $(...), which splits on any whitespace and mangles a value that happens to contain some. (az disk list scopes to one resource group; sweeping a whole subscription means looping az group list first, which §5 puts to work.) Exit codes are the fourth fundamental: every command leaves one in $? — zero for success, non-zero for failure — and that number, not the text on screen, is what set -e, the next script, and every pipeline you built this phase actually read. Master those four and the rest of Bash is lookup; skip them and you will write the two-line horror story in §3 yourself.

§2

Idempotency — scripts versus hazards

Before automating a task, ask: what happens if it runs twice? A scheduler may retry, or someone may rerun a command after a timeout. A read-only report produces another report. A create or delete script needs checks so the second run does not create duplicates or act on an unexpected target.

Idempotency
The property that running a script once or five times produces the same end state. Achieved by describing outcomes rather than actions: "ensure the group exists", never "create the group"; "set the tag", never "append to the list".

You have met this property twice already — it is why the same Bicep file deploys safely a hundred times, and why Terraform plans converge. Scripts have no engine doing that work for them, so you supply it, two ways. First, prefer verbs that are already idempotent: az group create succeeds harmlessly if the group exists with the same settings, and az tag update --operation merge sets a tag to a value rather than piling values up. Second, when the verb is not idempotent, check before acting: query whether the thing exists, and only create it if not. The anti-patterns to hunt in review are the ones that accumulate — names built from $RANDOM or timestamps (each run makes a new resource), appends to files or lists (each run makes them longer), counters incremented (each run drifts further). Drill 04 will hand you one of these and ask you to spot it; production will do the same, less politely.

Play

Play it through

Three minutes, two drags and a choice. Azure accepts a role assignment, but the script loses its response. On rerun, a create-only step encounters the existing assignment. Check the exact role assignment before creating it again, then see the run finish. The game pauses when it needs your choice.

§3

Error handling that fails loudly

By default, Bash can continue after a failed command. Imagine a typo in az account set --subscription prod-uction: the selection fails, but a later cleanup command could run against the previously selected subscription. A script that changes resources should stop on failure and verify its subscription before acting.

Stop on unexpected errors
In Bash, set -euo pipefail catches many failed commands, unset variables, and failed pipeline stages. It has exceptions inside conditions and loops, so check expected failures explicitly. In PowerShell, $ErrorActionPreference = 'Stop' turns non-terminating errors into terminating ones; still handle expected outcomes deliberately.
Assert your context
Before anything destructive, prove you are where you think you are: query az account show and compare the subscription id against the one you intend. Wrong context is the classic scripting disaster, and it is one if statement away from impossible.
Exit with meaning
Exit code zero means success — to schedulers, pipelines, and the next script. A script that prints an error but exits zero tells every machine downstream that all is well. Fail with a non-zero code, and let Class Twenty-Two's workflows turn that into a red check somebody sees.

Notice the common thread with the whole phase: machines act on what your code signals, not what it prints. A pipeline cannot read a sad message in a log; it reads exit codes. The three habits above cost five lines total and convert the silent-continuation failure mode — the one that compounds for twenty minutes before anyone notices — into an immediate, visible, diagnosable stop.

Loud is not the same as fragile: retrying the transient

Some service failures are temporary. HTTP 429 means a request was rate-limited; 503 can mean the service is unavailable. If an API identifies a retryable failure, wait as directed by its retry header when one is supplied, then try again with a bounded number of attempts. Do not retry a permission error or malformed command as if time could fix it. The small example below demonstrates bounded attempts for a read, but the Azure CLI command's exit code alone cannot identify which error occurred; production retry logic must inspect the failure and respect service guidance.

# Bash script fragment: retry a read and publish only a complete result
tmp=$(mktemp)
trap 'rm -f "$tmp"' EXIT
for attempt in 1 2 3 4 5; do
  if az disk list --resource-group <resource-group> -o json > "$tmp"; then
    break
  fi
  if [ "$attempt" -eq 5 ]; then
    echo "disk list failed after 5 attempts" >&2
    exit 1
  fi
  echo "attempt $attempt failed; waiting $((attempt * 2))s" >&2
  sleep $((attempt * 2))
done
mv "$tmp" disks.json

The read is safe to repeat, and the last failure exits non-zero instead of masquerading as success. The temporary file prevents a failed attempt from replacing a previously complete disks.json. The example does not distinguish throttling from a bad resource-group name, so use it to learn the loop, not as a production retry policy.

§4

Querying with --query

Every az command returns JSON, and buried in that JSON is the one fact you wanted. The --query parameter — JMESPath, a query language for JSON — extracts it without a single line of parsing code. Four shapes cover nearly everything you will do:

# 1 · reach into one object
az account show --query id -o tsv

# 2 · project one field from a list
az disk list --resource-group <resource-group> --query "[].name" -o tsv

# 3 · filter, then project
az disk list --resource-group <resource-group> --query "[?diskState=='Unattached'].name" -o tsv

# 4 · filter, then shape a report
az disk list --resource-group <resource-group> \
  --query "[?diskState=='Unattached'].{disk:name, gb:diskSizeGB, rg:resourceGroup}" \
  -o table

Read them as a grammar: [] walks a list, [?…] filters it with a condition, .field reaches in, and .{new:old} reshapes the survivors into exactly the columns you want. Pair the query with the right output: -o tsv for values a script consumes, -o table for reports a human reads, and JSON — the default — when the next tool wants structure. The fourth example already is half of this class's deliverable: unattached disks, sized and located, in one command. What separates a one-liner from an audit script is only the discipline of §2 and §3 wrapped around it.2

Figure — The query funnel
az disk list — the raw JSON every disk · every property · a wall [].name — project one field names only every disk · one field [?diskState=='Unattached'] — filter unattached only nine disks survive .{disk, gb, rg} — reshape · -o table the report nine rows · three columns each step drops what the question did not ask
This figure uses an illustrative list with nine matching disks; your subscription may show any number, including zero. Projection keeps a field, filtering keeps matching disks, and reshaping names the columns a human needs.

jq, for the JSON that is not Azure's

--query is a passenger on the az command; the moment the JSON comes from somewhere else — a raw ARM REST call, a webhook payload, a saved file, kubectl — it is not there to help. jq is the same idea packaged as a standalone tool: a filter language for JSON arriving on a pipe. The audit filter, written both ways:

# the same question, two tools
az disk list --resource-group <resource-group> --query "[?diskState=='Unattached'].name" -o tsv

az disk list --resource-group <resource-group> -o json |
  jq -r '.[] | select(.diskState=="Unattached") | .name'

Read the jq: .[] streams the array, select(...) keeps the matches, .name reaches in, and -r ("raw") strips the surrounding quotes so the output feeds a script — the -o tsv of the jq world. The grammar rhymes with JMESPath because both answer the same question; the difference is reach. Prefer --query when the data already comes from az: it filters in-process, needs nothing installed, and keeps one tool in the pipe. Reach for jq the moment JSON arrives from anything that is not the CLI — which, once you start calling ARM directly in a page or two, is often.

§5

The script you will actually write

A useful first automation is an audit-and-report script: inspect resources and print a list for a human to review. This example makes read-only Azure CLI calls; it does not delete resources. Use an account with only the read permissions it needs, because the report may still reveal names and configuration details.

#!/bin/bash
# audit.sh — untagged resource groups + unattached disks
set -euo pipefail

EXPECTED_SUB="<campux-subscription-id>"
ACTUAL_SUB=$(az account show --query id -o tsv)
if [ "$ACTUAL_SUB" != "$EXPECTED_SUB" ]; then
  echo "Wrong subscription: $ACTUAL_SUB" >&2
  exit 1
fi

echo "== Resource groups with no owner tag =="
az group list \
  --query "[?tags.owner == null].name" -o tsv

echo "== Unattached disks (paid for, used by nobody) =="
az group list --query "[].name" -o tsv | tr -d '\r' |
while IFS= read -r rg; do
  az disk list --resource-group "$rg" \
    --query "[?diskState=='Unattached'].{disk:name, gb:diskSizeGB, rg:resourceGroup}" \
    -o table
done

A script is a decision that repeats.

The script checks its selected subscription before querying. The first query finds resource groups without an owner tag; the loop asks for unattached disks in each group and prints a table. Empty output means no matches for that query, not that the subscription has no resources. A disk shown as unattached is a candidate for review, not automatic deletion: backups, replicas, or a temporary detach may explain it. A human should check the disk's purpose and retention needs before any removal.

Figure — audit.sh, guardrails first
Lab 2 trips both, on purpose fail loud set -euo pipefail first error stops the run assert context az account show wrong subscription → exit 1 read-only queries untagged groups unattached disks nothing changed the report -o table a human decides what happens next both guardrails run before the first query — the script stops early, never twenty minutes in
The order is the argument: both guardrails fire before the first query, so the script stops in the wrong subscription or on the first typo — never twenty minutes later. Lab 2 makes you trip each one deliberately, because a safety you have never seen fire is a safety you are guessing about.

And because half the shops that will interview you run this same audit in PowerShell, here is the untagged-groups half in that costume — $ErrorActionPreference = 'Stop' standing where set -euo pipefail stood, and a pipeline of objects doing the work of the JMESPath string. The discipline is identical; only the syntax changed its clothes:

# audit.ps1 — the same guardrails, PowerShell costume
$ErrorActionPreference = 'Stop'

$expected = '<campux-subscription-id>'
$actual   = (Get-AzContext).Subscription.Id
if ($actual -ne $expected) {
  throw "Wrong subscription: $actual"
}

"== Resource groups with no owner tag =="
Get-AzResourceGroup |
  Where-Object { -not $_.Tags -or -not $_.Tags.ContainsKey('owner') } |
  ForEach-Object { $_.ResourceGroupName }

Why the PowerShell version needed no query language

Look again at what replaced the JMESPath string: Where-Object and a comparison against a real property. That is the one idea worth taking from PowerShell even if you never write it in anger — its cmdlets return objects, not text, so a resource group arrives with a .Tags hashtable and a .ResourceGroupName you address by name, never a slab of output you slice with awk and pray the columns did not shift. The pipeline is the same three verbs JMESPath gave you, spelled longer: Where-Object filters, Select-Object projects, ForEach-Object acts. When a task has real branching — this tag present, that one absent, a different action for each — objects with named fields age better than a string you re-parse on every run. That is the §1 table's "object-shaped logic" made concrete, and the reason PowerShell shops do not consider themselves second-class.

And when it becomes Python

Past a certain size the script stops being glue and becomes software — it wants functions, tests, a package other scripts import, a maintainer who is not you. That is Python's cue, and it reaches Azure two ways. The high road is the SDK: azure-identity to authenticate, and a management library such as azure-mgmt-compute to work in objects — pagination and retries handled for you.

# audit, the SDK way — the same unattached-disk question
from azure.identity import DefaultAzureCredential
from azure.mgmt.compute import ComputeManagementClient

sub = "<campux-subscription-id>"
client = ComputeManagementClient(DefaultAzureCredential(), sub)

for disk in client.disks.list():
    if disk.disk_state == "Unattached":
        print(disk.name, disk.disk_size_gb, disk.id)

The low road, when no SDK covers what you need, is to call the Azure Resource Manager REST API yourself with requests — the same endpoints Class Sixteen took apart. You still authenticate through the credential; you just carry the token by hand:

# the same call, one level down — raw ARM over HTTPS
import requests
from azure.identity import DefaultAzureCredential

token = DefaultAzureCredential().get_token("https://management.azure.com/.default")
sub = "<campux-subscription-id>"
url = (f"https://management.azure.com/subscriptions/{sub}"
       "/providers/Microsoft.Compute/disks?api-version=2023-04-02")
r = requests.Session()
while url:
    response = r.get(url, headers={"Authorization": f"Bearer {token.token}"}, timeout=30)
    response.raise_for_status()
    page = response.json()
    for d in page.get("value", []):
        if d["properties"]["diskState"] == "Unattached":
            print(d["name"])
    url = page.get("nextLink")

raise_for_status() turns an HTTP error into an exception instead of treating an error response as disk data. The loop follows each nextLink, so it does not quietly stop after the first page. The SDK's .list() handles paging for you. This REST sketch still needs token refresh and a bounded retry policy for a long-running production job; use the SDK when it covers your task.

Secrets: the line that ends up in a log

Notice what none of these scripts contains: a password, a key, a connection string. That is deliberate, and it is the §5 habit most likely to keep your name out of an incident report. DefaultAzureCredential is why — it tries a fixed chain of identity sources and uses the first that answers: environment variables, then a workload or managed identity (the Class Twenty-Three OIDC token in a pipeline), and, near the end of the chain, your az login session when you run it locally. The same line of code authenticates on your laptop and on a runner without a secret written down anywhere, which is the whole point — the environment supplies the identity, so the script never has to. When a script genuinely needs a secret of its own — a third-party API key — it reads it from Key Vault or an environment variable at runtime, never a literal in the source, because source goes to Git and Git remembers forever. That is Class Nine's lesson wearing a shell.

Two quieter traps close the topic. A secret passed as a command-line argument is visible to anyone who can list processes and lands in shell history; pass it by environment variable or on stdin instead. And a secret echoed while debugging, or returned inside an error, lands in the pipeline log where it lives as long as the run does — set sensitive commands to --output none and refuse to print the thing you were told to protect. The credential you never wrote down cannot leak; the one you printed for five minutes already has.

Case File · Campux Retail

The January bill, automated

audit.sh finds the money leaking out of the subscription

In this fictional Campux Retail example, VM cleanup retained several data disks. A retained managed disk can continue to incur storage charges after its VM is gone.3 The first audit lists nine unattached disks and four resource groups without an owner tag. Those numbers describe the teaching scenario, not a measured CAMPUX subscription. Each disk is a review candidate; an unattached state alone cannot prove it is waste.

The fictional team schedules audit.sh with an OIDC identity granted Reader access at the intended scope. A human reviews all nine disks; one is a retained backup, and eight are approved for removal through a separate process. The example shows why a report should stop at a candidate list. It cannot decide that a disk is safe to delete.

Watch · Microsoft Learn

Official references for the exercises

The query page is the one to keep open during both labs
Microsoft Learn · Docs

Query Azure CLI command results (JMESPath)
learn.microsoft.com/cli/azure/use-azure-cli-successfully-query

Choose the right command-line tool
learn.microsoft.com/cli/azure/choose-the-right-azure-command-line-tool

Use the query guide while writing the lab. It explains each JMESPath operator used below, so you can check the expression rather than memorise it.

Lab 1 · Query practice

Make JMESPath answer four questions

~15 minutes · Bash in Cloud Shell or Azure CLI · creates a billable disk

This optional lab creates a small managed disk in a new, uniquely named resource group, then finds it with four queries. It incurs Azure storage charges until deletion completes. You need permission to create and delete a resource group and disk. To avoid creating a new billable resource, run the queries against an existing group you are allowed to inspect and skip creation and deletion.

  1. In one Bash session, confirm the selected subscription is the one you intend to use. Then create a unique resource group and one unattached disk. Check the printed group name before continuing:

    az account show --query '{name:name,subscriptionId:id}' -o table
    LAB_RG="rg-script-lab-$RANDOM-$RANDOM"
    echo "$LAB_RG"
    az group create --name "$LAB_RG" --location eastus
    az disk create --resource-group "$LAB_RG" \
      --name orphan-disk-01 --size-gb 4 --sku Standard_LRS
    What to notice: the new disk has no VM attached. Keep the same shell session so $LAB_RG remains set. If creation fails, do not continue to the delete step until you know exactly which resources were created.
  2. Question one and two — everything, then one field:

    az disk list --resource-group "$LAB_RG" -o json
    az disk list --resource-group "$LAB_RG" --query "[].name" -o tsv
    On screen: first the raw JSON wall — every property of every disk — then a clean list of names. The query did the parsing a script would otherwise regex its way through.
  3. Question three and four — filter, then shape:

    az disk list --resource-group "$LAB_RG" --query "[?diskState=='Unattached'].name" -o tsv
    az disk list --resource-group "$LAB_RG" \
      --query "[?diskState=='Unattached'].{disk:name, gb:diskSizeGB, rg:resourceGroup}" \
      -o table
    On screen: orphan-disk-01 alone, then a labelled table with disk name, size in GB, and resource group. The inner single quotes are part of the JMESPath expression; the outer double quotes keep the shell argument together.
  4. Run the opposite filter. Then confirm that $LAB_RG is the new lab group you created and delete only that group:

    az disk list --resource-group "$LAB_RG" --query "[?diskState=='Attached'].name" -o tsv
    printf 'Delete lab group: %s\n' "$LAB_RG"
    az group show --name "$LAB_RG" --query '{name:name,resourceId:id}' -o table
    az group delete --name "$LAB_RG" --yes
    az group exists --name "$LAB_RG"
    The lesson: the Attached query should return no rows for the disk you just created. After deletion, az group exists should print false. If it prints true, check whether deletion is still finishing or failed; do not leave a billable disk behind.
Lab 2 · audit.sh

Write the audit script, then try to hurt yourself

~15 minutes · continues Lab 1's tools · read-only against your subscription

Assemble the §5 script, watch its guardrails work, and then deliberately trip both of them — because trusting a safety you have never seen fire is how the two-line horror story starts.

  1. Create audit.sh with the full §5 contents, substituting your real subscription id (from az account show --query id -o tsv). Make it executable and run it:

    chmod +x audit.sh
    ./audit.sh
    On screen: your own estate, audited — resource groups missing an owner tag, disks attached to nothing. If both lists are empty, congratulations; most subscriptions are not so lucky.
  2. Trip guardrail one — wrong context. Edit EXPECTED_SUB to a made-up id and run again:

    ./audit.sh
    echo "exit code: $?"
    On screen: Wrong subscription: and an exit code of 1 — no queries ran. This is the assert-your-context habit firing: the script refused to work in a place it did not expect. Restore the real id.
  3. Trip guardrail two — loud failure. Add a deliberately broken line right after the context check (az groop list, misspelled), run, and read what happens:

    ./audit.sh
    echo "exit code: $?"
    On screen: the typo errors and the script stops dead — the audit sections below it never print, and the exit code is non-zero. Delete set -euo pipefail, run once more, and watch the script sail past the error and "succeed". That difference is the entire §3 argument. Put the line back and remove the typo.
  4. Optional finale: start from the reviewed Class Twenty-Three OIDC workflow, give its Azure identity only the Reader role at the intended scope, check out the repository, and run bash audit.sh. Add this trigger in that workflow:

    on:
      workflow_dispatch:
      schedule:
        - cron: '17 8 * * 1' # Monday 08:17 UTC
    The lesson: the schedule uses UTC, runs from the default branch, and can run later than its scheduled minute. The workflow needs id-token: write for OIDC, a checkout step so audit.sh exists on the runner, Azure login, and a reviewed expected subscription ID. A schedule alone supplies none of those. Test manually first, then inspect a scheduled run before relying on the report.
Note · audit.sh calls read-only Azure CLI commands, but its output can reveal resource names and configuration. Use a least-privilege identity, store reports appropriately, and review any future change that adds a write or delete command.
Think in systems

Zoom out: a script is a decision you made once and will run a thousand times

You can automate a task. Now reason about what a script does as a system, because it will run again — on more resources, later, when the assumptions it baked in have quietly rotted.

Feedback loops

A one-off script that is not idempotent works the first run and corrupts the second, so the convenience of "just automate it" becomes the incident when it re-runs. What makes running it twice harmless?

Dependencies & coupling

Automation couples an outcome to assumptions baked in at write time — a region, a name, an api-version — that rot silently until a run fails in a way nobody can immediately explain.

The constraint

The blast radius of a script is its scope times its speed; a fast loop over a whole subscription is a fast way to break a whole subscription.

Second-order effects

A script that hard-codes a credential is the leak from Class Nine wearing a shell; convenience buys exposure that outlives the task.

What breaks when it scales

A script that assumes a handful of resources times out or half-completes over thousands, leaving the estate in a state nobody planned and nobody can cleanly resume.

The engineer who ships is asked "does it run?" The engineer who gets promoted is asked "and what happens the second time, and the thousandth?" — and made it idempotent.

On the job

The toil you delete

You · Cloud Engineer · a repetitive chore appears

The same ten-minute manual chore lands on you for the third time this week. You script it once — the CLI, a loop, a couple of parameters — and the third time becomes the last time you did it by hand. The habit compounds: a cloud engineer's real output is not the clicks, it is the toil they delete for everyone who comes after.

Class Twenty-Five

Examination

Four drills, then two situations. The situations have no marking scheme — write your answer before you reveal the reasoning, or the exercise is worthless. Nothing is stored; this is between you and the page.

Drill 01Recall · idempotency
What does it mean for a script to be idempotent?
Marked

B. Idempotency is about the destination, not the trip: five runs, one end state. It says nothing about permissions (A), portability (D), or being error-free (C) — in fact a good idempotent script errors loudly when its context is wrong, which is §3's separate virtue. The property matters because reruns are not an edge case: schedulers double-fire, networks drop mid-run, and colleagues rerun things "to be sure". A script without this property does not have a bug that might trigger; it has a behaviour that waits for the second run. You have trusted this property all phase — it is why Bicep redeploys and Terraform re-applies are safe — and scripts are where you must build it by hand.

Drill 02Recall · idempotency
Which of these commands is safe to run twice, as written?
Marked

C. az group create describes an outcome — this group, this region, existing — so a second run finds the outcome already true and succeeds without changing anything. The other three are the §2 anti-pattern family in the flesh: A mints a new name every run, so twice means two storage accounts and one mystery bill; B appends, so the delete list grows a duplicate — and whatever consumes that list may delete twice or choke; D counts, so each run drifts one VM further from anyone's intent. The tell is grammatical: idempotent commands read like state ("this exists"), hazards read like actions ("make another, add one more"). Review for the verbs.

Drill 03Select three
Which three habits make a script fail loudly instead of failing dangerously?
Marked

Halt on error, assert context, exit with meaning. The two rejects are the seductive ones. Catch-log-and-continue feels diligent but is §3's horror story with extra steps — the failed az account set gets politely logged, and the delete loop still runs in the wrong subscription; a catch block earns its place only when it handles the error or re-throws, never when it swallows. And "Done!" is worse than nothing: it prints even after swallowed failures, teaching operators to trust a lie. Machines read exit codes and humans read what machines surface — a red workflow check from a non-zero exit reaches someone; a cheerful string in a log nobody opens reaches no one.

Drill 04Spot the error
This nightly script passed review. After a week, the subscription contains seven storage accounts nobody recognises. Which line is the mistake?
# nightly-export.sh — copies a report to storage each night
1.  #!/bin/bash
2.  set -euo pipefail
3.  az group create --name rg-campux-export --location eastus
4.  STORAGE="export$(date +%s)"
5.  az storage account create --name "$STORAGE" \
      --resource-group rg-campux-export --sku Standard_LRS
6.  az storage container create --name reports --account-name "$STORAGE"
Marked

Line four. $(date +%s) bakes the current second into the name, so the script never sees the account it made yesterday — each night it "succeeds" by creating a fresh one. Seven nights, seven accounts, seven line items on the invoice, and a review that waved it through because every individual line looks reasonable. That is the signature of idempotency bugs: they are invisible in a single run and inevitable across many.

The fix is a deterministic name — derive it from something stable (the resource group, per Bicep's uniqueString trick in Class Twenty) so night two finds night one's account and moves on. The distractors: line two is the good line (A); line three is idempotent by design and succeeds harmlessly every night (B) — which is exactly why it makes a useful contrast with line four; and containers script fine (D). When a reviewer sees $RANDOM, a timestamp, or a UUID feeding a resource name in anything that runs more than once, the question to ask is Drill 01's: what happens on the second run?

Situation 01Write before you reveal
A senior colleague drops a link in chat: "Found a great cleanup script on a blog — deletes all unattached NICs, NSGs, and disks. Can you run it against prod this afternoon? We're chasing the cost target." What do you do?
The goal is legitimate. The artifact and the target are the problems — separate all three.
Reasoning

Separate the goal from the artifact, out loud. Chasing waste is exactly right — it is this class's case file. What is not right is executing unreviewed code from the internet, with delete permissions, against production, on a deadline. Say yes to the mission and no to the method in the same sentence, because a flat "no" reads as obstruction while "yes — and here is how we do it without an incident" reads as competence. Then read the script, which is the step the request skipped: does it fail loudly, does it assert its subscription, is it idempotent, and — the question that matters most — how does it decide something is safe to delete?

Name the specific danger of "unattached". The case file's ninth disk was a retained backup; an NSG can be momentarily disassociated during maintenance. "Unattached right now" does not mean "unwanted". A blog author's cleanup assumptions were written for another estate. Start with a reviewed, read-only report using an identity scoped to the intended subscription. Hand the colleague a table of candidates for owner review before changing anything.

Then do the deletion properly, if the list survives review. Owners eyeball the table; anything claimed gets a tag that exempts it; the delete step becomes a short script of your own — context assert, loud failure, the reviewed list as its only input — run first in non-prod, then in prod through the pipeline with its approval gate, like every other production change this phase built. Total delay versus the blog script: perhaps a day. Total difference in risk: the difference between a cost win and a postmortem titled "cleanup script". If the colleague pushes back on a day, the §3 horror story is worth telling — it also started with someone in a hurry.

Situation 02Write before you reveal
The Monday audit works so well that your manager asks: "Why does a human still review the table? Have the script delete the orphaned disks automatically — it's been right every week for two months." How do you respond?
Two months of correct reports proves the finding. What has it proved about the deleting?
Reasoning

Concede the record, then name what it actually proves. Two months of accurate tables is real evidence — the detection is trustworthy. But the human in the loop has not been checking whether the script can find unattached disks; they have been checking whether each disk is unwanted, and those are different questions. The first run's ninth disk was a deliberate backup that only a human recognised. Detection is a query; judgement is context the subscription does not contain. The manager is proposing to automate the part that was never the bottleneck and delete the part that caught the backup.

Price the asymmetry, because that is the actual argument. The review costs five minutes a week. A wrongly deleted disk is unrecoverable — not slow to fix, gone — and the blast radius is whatever was on it. Automation earns the right to act unattended when the action is reversible or the cost of error is small; a permanent, irreversible delete on a heuristic ("unattached") clears neither bar. There is also a quiet permission escalation hiding in the request: the Monday robot currently holds Reader by design, and cannot delete anything even if compromised or wrong. Granting it delete rights widens what the Class Twenty-Three pipeline can do the day anything about it misbehaves.

Offer the automation that is actually safe, so the answer is not just "no". Shrink the judgement, do not skip it: the script tags candidates with delete-after dates, owners get an automated mention, and anything still unclaimed after thirty days goes to a deletion step that runs through the pipeline's approval gate — one human click on a pre-reviewed list, exactly the Class Twenty-Three pattern. The five-minute review becomes a thirty-second approval, the backup-shaped disks get a survival mechanism, and the robot never holds power it does not need. That is the honest version of "fully automated": the machine does everything except the one thing only a human can know.

Examination record · first attempt
0/4
Class Twenty-Five · Complete
Retain this much

Five things worth carrying out of this class

  1. Three tools, one discipline: az CLI for glue and pipelines, PowerShell for object-shaped logic and PowerShell shops, Python when the script becomes software. The habits transfer; the syntax is a costume.
  2. Idempotent means one run or five, same end state — describe outcomes ("ensure it exists"), and hunt the accumulators: $RANDOM names, timestamps in names, appends, counters. The second run is not an edge case.
  3. Fail loudly: set -euo pipefail, assert the subscription before acting, exit non-zero on failure. Machines act on exit codes, not on sad messages in logs.
  4. JMESPath's four shapes — reach in, project [], filter [?…], reshape .{} — plus -o tsv for scripts and -o table for humans, replace an afternoon of portal tabs with one line.
  5. The career script is audit-and-report: read-only, Reader-role, scheduled, human-reviewed. Reporting is welcome everywhere; deleting is a separate decision with the full checklist attached.
Notes
  1. Microsoft's own guidance declines to pick a winner between the az CLI and Azure PowerShell — they are functionally equivalent, and the honest determinant is what your team already reads fluently. Distrust anyone who tells you one of them is unprofessional; both run half the world's Azure estates. The Python cutoff (~100 lines) is likewise a heuristic, not a law — the real threshold is the moment you wish the script had tests. ↩
  2. Two honest limitations. First: --query filters on your machine, after Azure has returned the full result — fine at Campux's scale, sluggish across an enterprise's tens of thousands of resources. Second: az disk list itself only ever answers for one resource group, which is why §5's audit loops az group list to cover a subscription at all. The tool built for estate-wide questions without either limit is Azure Resource Graph (az graph query), which filters server-side using KQL, across every resource group you can see, in one call — the query language Class Twenty-Nine teaches for logs. Same instinct, bigger engine; JMESPath remains the right tool at script scale. ↩
  3. Deleting a VM can take its disk with it — the OS disk has a delete-with-VM option, and careful shops set it — but the default behaviour across years of portal clicking has left orphaned disks in nearly every subscription old enough to have history. Treat "every estate has some" as reliable and the specific quantities in this class's case file as illustrative. Audit yours before assuming either way. ↩
Practice Now write some. 65 Python & PowerShell challenges on the Automation Practice bench — starter code, hints, worked solutions. →