Three tools, and when each wins
Not everything deserves a pipeline. "Find every disk nobody is using." "Tag these forty resource groups." "Report which storage accounts allow public access." These are questions and chores, not infrastructure — running them through a pull request would be ceremony, and clicking through forty portal blades would be a morning. This is scripting country, and Azure gives you three respectable citizens of it. The choice between them is less important than beginners fear and more shop-dependent than partisans admit — but each has a home ground.1
| Tool | Its natural habitat | Reach for it when |
|---|---|---|
| az CLI + Bash | Pipelines, Cloud Shell, quick one-liners — terse commands emitting JSON | The task is glue: query something, loop over it, act. Most of this class. |
| Azure PowerShell | Windows-heavy shops; scripts with real logic — cmdlets returning structured objects, not text | You need branching, error types, and reporting more than brevity — or the team already lives in PowerShell. |
| Python + Azure SDK | Programs that outgrew scripting — data crunching, APIs, anything past ~100 lines | The "script" wants tests, libraries, and a maintainer. At that point it is software; treat it as such. |
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.
Idempotency — scripts versus hazards
Here is the question that sorts scripts from hazards: what happens when it runs twice? Scripts run twice constantly — a network blip mid-run, a colleague who did not know you already ran it, a scheduler that fired during the retry of the previous firing. A script that behaves differently on the second run is a coin toss wearing a file extension.
- 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.
Error handling that fails loudly
The default behaviour of a shell script is the worst possible one: a command fails, and the script keeps going. Picture the two-line horror — line one, az account set --subscription prod-uction fails on the typo; line two, the cleanup loop runs anyway, in whatever subscription the shell happened to be pointed at. The Class Two dev environment, deleted by a script aimed at prod's leftovers. Loud failure is not a style preference; it is the difference between an error message and an incident.
- Stop at the first error
- Bash: set -euo pipefail at the top of every script — exit on error, on unset variables, on failures inside pipes. PowerShell: $ErrorActionPreference = 'Stop'. One line, non-negotiable, first thing a reviewer looks for.
- 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.
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 --query "[].name" -o tsv # 3 · filter, then project az disk list --query "[?diskState=='Unattached'].name" -o tsv # 4 · filter, then shape a report az disk list \ --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
The script you will actually write
Nobody gets hired for FizzBuzz in Bash. The script cloud engineers actually write, at every employer, forever, is audit-and-report: sweep the estate for a condition somebody cares about, and produce a list a human can act on. It changes nothing — which means it is safe to run anywhere, safe to schedule, and the perfect first script to put your name on. Here is the whole shape:
#!/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 disk list \
--query "[?diskState=='Unattached'].{disk:name, gb:diskSizeGB, rg:resourceGroup}" \
-o table
A script is a decision that repeats.
Every lesson of the class is in those twenty lines: loud failure at the top, an asserted context before anything else, idempotent by nature because it only reads, and two JMESPath queries doing the work that would otherwise be an afternoon of portal tabs. Note what it deliberately does not do: delete anything. An audit script that reports is welcome everywhere; the moment it acts, it needs the full Class Eight conversation about permissions and the full §2 conversation about running twice. Report first. Deletion is a separate decision, made by a human reading your table — and making that table appear every Monday morning is one Class Twenty-Two scheduled workflow away.
The January bill, automated
Class One's origin story was a bill: compute for a November peak, paid through a January trough. Two phases later, the modern version of that waste is quieter — a VM gets deleted, its disk survives (deleting the machine does not delete the disk unless someone says so), and the disk bills every month from then on, attached to nothing.3 Multiply by two years of experiments, three engineers, and the dev subscription nobody audits, and the first run of audit.sh at Campux returns a table with nine unattached disks — several hundred gigabytes of storage whose only function is appearing on an invoice — and four resource groups with no owner tag, which is why nobody ever noticed.
The script gets a home in campux-platform and a Class Twenty-Two schedule: every Monday, a workflow runs the audit through the OIDC login — with a Reader role, because a reporting script should not be able to delete anything — and posts the table where the team reads it. The nine disks get a human review (one, it turns out, was a deliberate backup; Drill 04's cousin in real life), eight get deleted, and the tag gaps get owners. Total code: twenty lines. It is the least impressive artifact in the repository and the only one that directly pays for the rest.
The official pages, and a CAMPUX overview
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
Building the audit script live — a JMESPath query growing from one field to a shaped table, a deliberate failure stopping the script cold, and the Monday schedule wiring it into Actions — will live here. Video to be added.
Make JMESPath answer four questions
Plant one orphaned disk, then find it four ways — each query one step more precise than the last. This is the §4 grammar, typed with your own hands.
Create a resource group and a small unattached disk — the quarry:
az group create --name rg-script-lab --location eastus az disk create --resource-group rg-script-lab \ --name orphan-disk-01 --size-gb 4 --sku Standard_LRS
What to notice: a disk created with no VM is born unattached — exactly the state a deleted VM leaves behind. It starts billing immediately, which is the whole reason this lab exists.Question one and two — everything, then one field:
az disk list -o json | head -30 az disk list --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.Question three and four — filter, then shape:
az disk list --query "[?diskState=='Unattached'].name" -o tsv az disk list \ --query "[?diskState=='Unattached'].{disk:name, gb:diskSizeGB, rg:resourceGroup}" \ -o tableOn screen: orphan-disk-01 alone, then a labelled table with size and location — the exact artifact you would hand a teammate. Note the quoting: single quotes inside the JMESPath string, double around it.Prove the filter earns its keep, then clean up:
az disk list --query "[?diskState=='Attached'].name" -o tsv az group delete --name rg-script-lab --yes --no-wait
The lesson: the Attached query returns nothing — the filter is doing real selection, not decoration. You now hold the four query shapes that cover most scripting work; Lab 2 wraps discipline around them.
Write the audit script, then try to hurt yourself
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.
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.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.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.Optional finale, tying the phase together: schedule it. In a repository with the Class Twenty-Three OIDC login, add a workflow with on: schedule: - cron: '0 8 * * 1' that logs in and runs audit.sh.
The lesson: every Monday at 08:00, a runner logs in with no stored secret, a Reader-only role, and produces the table a human reviews. Git holds the script, Actions runs it, OIDC signs it in, and the script itself cannot delete anything — the whole phase, in one small robot.
The toil you delete
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.
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.
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.
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.
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.
# 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"
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?
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 deliberate backup; an NSG can be momentarily disassociated during maintenance; "unattached right now" is not "unwanted". A blog author's definition of garbage was written for their estate, not yours, and a delete script encodes their assumptions at machine speed. This is why the professional pattern is audit first: run the reporting version this afternoon — read-only, safe against prod by construction — and hand the colleague a table of candidates with sizes and costs. That usually satisfies the deadline pressure, because the cost target needs numbers before it needs deletions.
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.
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, don't 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.
Five things worth carrying out of this class
- 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.
- 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.
- 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.
- 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.
- 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.
- 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. ↩
- One honest limitation: --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. The tool built for estate-wide questions is Azure Resource Graph (az graph query), which filters server-side using KQL — the query language Class Twenty-Nine teaches for logs. Same instinct, bigger engine; JMESPath remains the right tool at script scale. ↩
- 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. ↩