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
| 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 with reusable functions, tests, and Azure SDK clients | The 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.
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 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.
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.
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
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.
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.
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.
The January bill, automated
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.
Official references for the exercises
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.
Make JMESPath answer four questions
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.
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_LRSWhat 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.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.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 tableOn 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.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.
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: 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 UTCThe 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.
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.
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?
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 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.
A script that hard-codes a credential is the leak from Class Nine wearing a shell; convenience buys exposure that outlives the task.
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.
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 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.
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.
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. ↩
- 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. ↩
- 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. ↩
Automate a repeatable Azure task in Bash, PowerShell or the CLI, and know which to reach for.
Play the lesson above to stamp this class.