Principal Azure Cloud Engineer & Architect — Curriculum
Target Role: Azure Cloud Engineer → Principal Cloud Architect — secure, event-driven, serverless platforms on Microsoft Azure (the reference JD — jd.md)
Also prepares you for:
- Microsoft / consulting partners — Cloud Solution Architect, Azure Platform Engineer
- Any enterprise running a regulated, multi-subscription Azure estate: landing zones, Entra ID, policy-as-code, private networking, event-driven serverless, and 24×7 ops
- The Azure certification ladder used as a floor, not a ceiling: AZ-104 (Administrator), AZ-204 (Developer), AZ-305 (Solutions Architect Expert), AZ-500 (Security), AZ-700 (Networking) — this track goes far deeper than any of them, because it makes you build the mechanisms the exams only name.
Duration: ~32 weeks core (≈8 months) — extendable with the capstones Seniority Target: Principal / Staff IC — the escalation point for the hardest cloud failures and the person who defines the platform standards the org builds on Goal: Make you the engineer who can architect an Azure platform at the system level and debug it at the token, route-table, manifest-digest, message-lock, checkpoint, and KQL-query level — across IaC, identity, governance, networking, containers, CI/CD, APIs, events, serverless, secrets, observability, and reliability.
Why This Curriculum Exists
Most "learn Azure" material teaches you to operate the portal: which blade to click,
which az command to copy. That gets you to senior. It does not get you to principal,
because the principal's job is to make correct tradeoffs under conflicting requirements
and to debug the layer everyone else stands on — and you cannot do either if the
service is a black box.
So this track is built on two non-negotiables:
- Every Azure mechanism in the JD gets implemented, not just described. The ARM deployment dependency graph, Terraform's plan/diff/replace engine, the OAuth2 + OIDC + JWT validation flows, the RBAC + Azure Policy evaluator, the NSG + effective- routes engine, an OCI/ACR registry with garbage collection, a CI/CD DAG with approval gates and OIDC federation, an API gateway with scope authorization and rate limiting, a Service Bus broker with peek-lock/sessions/dead-letter, the Functions scale controller and Durable replay engine, Key Vault envelope encryption and Managed- Identity token acquisition, a KQL query engine, and the reliability primitives (SLA composition, retry+jitter, circuit breaker) — you build a working, tested miniature of each.
- Everything is taught to the depth interviewers and incidents actually demand — the token claims, the priority ordering, the longest-prefix match, the lock-expiry off-by-one, the SLA arithmetic, the failure signatures. We assume you are building the platform that serves millions of requests with security and availability guarantees, not a demo resource group.
How Each Phase Teaches (the "many ways of explaining")
Every phase deliberately explains the same material through several lenses, because principal-level understanding is the intersection of all of them:
| Document | Voice | What it gives you |
|---|---|---|
README.md | the syllabus | why the phase exists, concept map, lab specs, integrated-scenario ideas, deliverables checklist, key takeaways |
WARMUP.md | the professor | zero-to-principal primer — every term from first principles, the mechanism under the hood, the math, lab guidance, success criteria, interview Q&A, references |
HITCHHIKERS-GUIDE.md | the senior who's been there | the compressed practitioner tour — 30-second mental model, the numbers to tattoo on your arm, az/Terraform one-liners, war stories, beginner mistakes |
BROTHER-TALK.md | your brother, off the record | candid real-talk — what nobody tells you, the 2 a.m. pager truth, the career framing |
lab-*/ | the lab bench | a runnable, test-verified implementation: lab.py (TODOs) → your code → solution.py (reference) → test_lab.py (the proof). Validation is pytest. |
The lab engineering standard is documented in LAB-STANDARD.md. Read it before you start the first lab.
The Phases
| # | Phase | You build (flagship lab) |
|---|---|---|
| 00 | The Azure Principal: Control Plane, Well-Architected & Tradeoffs | ARM resource-id parser + Well-Architected five-pillar tradeoff scorer + SLA/cost/region modeler |
| 01 | Azure Resource Manager & the Deployment Engine | ARM/Bicep template dependency-graph deployment engine + idempotent PUT + what-if diff |
| 02 | Terraform, CDKTF & IaC State Internals | a Terraform-style state + plan engine: diff, ForceNew replace, dependency ordering, drift |
| 03 | Microsoft Entra ID: OAuth2 / OIDC / JWT | authorization-code+PKCE & client-credentials flow engine + a JWT validator |
| 04 | RBAC & Azure Policy Evaluation | role Actions/NotActions wildcard matcher + scope inheritance + deny assignments + Policy effects |
| 05 | Azure Landing Zones & Management-Group Scale | management-group tree + effective policy/RBAC inheritance resolver + ALZ compliance scorer |
| 06 | Azure Networking & the Software-Defined Network | NSG priority evaluator + effective-routes longest-prefix engine + Private DNS / subnet calculator |
| 07 | Containers, Docker, ACR & AKS Internals | content-addressable OCI registry (digests, manifests, GC, geo-replication) + AKS bin-packing scheduler |
| 08 | CI/CD Pipelines & Secure Supply Chain | pipeline DAG executor (stages, gates, approvals) + OIDC workload-federation + blue-green/canary controller |
| 09 | RESTful APIs, JSON & Secure AuthN/AuthZ | an API gateway: route matching + JWT scope authorization + token-bucket rate limiting + policy pipeline |
| 10 | Event-Driven: Event Grid, Service Bus & Logic Apps | a Service Bus broker (peek-lock, sessions, dedup, dead-letter) + Event Grid retry delivery |
| 11 | Serverless: Functions, Triggers/Bindings & Durable | a Functions scale controller + a Durable-Functions deterministic replay engine |
| 12 | Secrets, Key Vault & Managed Identity | IMDS/MSI token acquisition + Key Vault envelope encryption + key rotation + soft-delete state machine |
| 13 | Observability, Azure Monitor & KQL | a KQL query engine (where/summarize/bin/join) + an alert-rule evaluator |
| 14 | Reliability, HA & Operational Troubleshooting | SLA composition + retry/backoff/jitter + circuit breaker + 429/Retry-After + a triage decision tree |
| 15 | Principal Architecture, FinOps & Capstone | an end-to-end secure event-driven serverless platform model with a combined security/SLA/cost scorer |
Use the sidebar for the full clickable table of contents, and see GLOSSARY.md / CHEATSHEET.md for the running references.
How to Use This Track
- Start here, then read Phase 00 end to end — it builds the mental model (control plane, Well-Architected pillars, the tradeoff dials) that every later phase plugs into.
- For each phase: read
WARMUP.md(professor), skimHITCHHIKERS-GUIDE.md(the numbers), do the lab (lab.pyred → green againsttest_lab.py), then readBROTHER-TALK.md. - Work phases roughly in order — identity (P03–P05) and networking (P06) underpin everything after them.
- Use interview-prep/ and system-design/ as running references; the capstone (P15) ties the whole stack together.
Cross-Cutting Themes (the principal's lenses)
- Control plane vs data plane — ARM/RBAC/Policy decide what exists and who may touch it; the blob, message, secret, and token are a separate system with separate identity, throttling, and SLA.
- Identity is the perimeter — every lab that touches auth defaults to deny, validates the token's claims, and prefers a Managed Identity over a secret.
- Idempotency everywhere — ARM
PUT, Terraform apply, Service Bus dedup, Event Grid retry, Durable replay — the same operation twice must converge, not duplicate. - Quantify the tradeoff — SLA composition, retry budgets, partition/shard sizing, and the monthly bill are arithmetic you do before the design review, not after the incident.
Azure Cloud Engineer / Principal Cloud Architect
Secure, Event-Driven, Serverless Platforms on Microsoft Azure — IaC, Identity, Networking, and Operations
Role Summary
We are looking for an engineer who can design, build, operate, and evolve secure, scalable, and reliable solutions on Microsoft Azure — and who understands the platform deeply enough to be the escalation point when it breaks at 2 a.m. and the architect when the org decides what to build next.
This is not a "click in the portal" role and not a "run one Terraform module" role. It is about owning the cloud platform layer itself: the Infrastructure-as-Code that provisions everything, the identity fabric (Microsoft Entra ID) that authenticates and authorizes every call, the landing zones and governance that hundreds of engineers deploy into, the networking substrate that isolates and connects workloads, the event-driven and serverless application patterns the business runs on, and the observability and operational playbooks that keep it all available, secure, and fast.
The reference responsibilities (verbatim from the requisition):
- Provide troubleshooting and operational support for Azure cloud services and infrastructure.
- Design, implement, and maintain Infrastructure as Code (IaC) solutions using Terraform, HashiCorp CDKTF, and ARM Templates.
- Configure and manage Azure Landing Zones, Microsoft Entra ID (Azure AD), Azure Policies, RBAC, networking, and identity integrations.
- Build, deploy, and manage containerized applications using Docker and Azure Container Registry (ACR).
- Develop and maintain CI/CD pipelines to automate application builds, testing, and deployments.
- Develop and integrate RESTful APIs using JSON and implement secure authentication and authorization mechanisms (OAuth2, OpenID Connect).
- Design and implement event-driven architectures using Azure Event Grid, Azure Service Bus, and Azure Logic Apps.
- Integrate serverless applications with Azure security services, including Azure Key Vault, Managed Identities, and Microsoft Entra ID.
- Monitor, troubleshoot, and optimize cloud infrastructure to ensure high availability, security, and performance.
- Collaborate with cross-functional teams to deliver secure, scalable, and reliable cloud solutions.
What "Principal-Level" Means Here
A senior engineer uses these services correctly. A principal understands them under the hood — well enough to predict failure, defend a design with numbers, and debug across every layer of the stack:
- Control plane vs data plane. You know that every Azure write goes through Azure
Resource Manager (ARM) as an idempotent
PUTagainst a resource provider, that the deployment engine builds a dependency graph fromdependsOnand implicit references, and that the data plane (the blob you read, the message you receive, the secret you fetch) is a different system with different identity, throttling, and SLA. - Identity is the new perimeter. You can draw the OAuth2 authorization-code+PKCE flow
and the client-credentials flow from memory, validate a JWT's
iss/aud/exp/scpclaims by hand, explain how a Managed Identity acquires a token from IMDS with no secret, and reason about RBAC evaluation (role assignments,NotActions, deny assignments, scope inheritance) and Azure Policy effects as code. - Networking is software-defined. You can evaluate an NSG rule set in priority order, compute effective routes with longest-prefix matching across system/UDR/BGP routes, and explain why a Private Endpoint changes DNS resolution and how that breaks (and fixes) connectivity.
- Events and serverless are distributed systems. You know Service Bus peek-lock, sessions, duplicate detection, and dead-lettering; Event Grid's at-least-once delivery with exponential-backoff retry; the Functions scale controller; and Durable Functions' event-sourced, replay-based determinism.
- Reliability and cost are quantitative. You compose SLAs (serial vs redundant), size
retries with backoff + jitter and a retry budget, run a circuit breaker, honor
429/Retry-After, and put numbers on availability zones, paired regions, and the monthly bill before anyone asks.
The Stack You Will Master
ARM / Bicep / Azure Resource Manager Terraform (AzureRM provider) / CDKTF
Microsoft Entra ID (Azure AD) OAuth2 / OIDC / JWT / Conditional Access
RBAC / Azure Policy / Management Groups Cloud Adoption Framework / Landing Zones
VNet / NSG / UDR / Private Link / DNS Azure Firewall / ExpressRoute / Front Door
Docker / ACR / AKS / Container Apps Azure DevOps / GitHub Actions / OIDC federation
API Management / REST / JSON Key Vault / Managed Identities / IMDS
Event Grid / Service Bus / Logic Apps Azure Functions / Durable Functions
Azure Monitor / Log Analytics / KQL Application Insights / distributed tracing
Availability Zones / paired regions Well-Architected Framework / FinOps
What Success Looks Like
You are the person who can architect an Azure platform at the system level and debug it at the token, route table, manifest digest, message lock, checkpoint, and KQL-query level — and who leaves behind landing zones, IaC, policy-as-code, and runbooks that let hundreds of other engineers ship securely without reinventing the platform.
Everything in this curriculum is built, not just described: you implement working, test-verified miniatures of ARM's deployment graph, Terraform's plan engine, the OAuth2 token flows, the RBAC/Policy evaluator, the NSG/route engine, an OCI registry, a CI/CD DAG, an API gateway, a Service Bus broker, the Functions runtime, Key Vault envelope encryption, a KQL engine, and the reliability primitives — so the mechanisms become muscle memory instead of folklore.
Azure Lab Engineering Standard
Every lab in this track builds a runnable, test-verified miniature of a real Azure mechanism — the deployment graph, the token flow, the RBAC evaluator, the NSG engine, the message broker — so the internals become muscle memory instead of portal folklore. You do not "deploy a VNet"; you build the engine that decides whether a packet is allowed. That is what makes the knowledge defensible in a principal interview and useful at 2 a.m.
Why miniatures (and not live Azure)
A live Azure subscription is non-deterministic, costs money, requires credentials, and
hides the mechanism behind a REST API. A lab that reimplements the algorithm — NSG
priority evaluation, longest-prefix routing, JWT validation, peek-lock redelivery,
Durable replay — is offline, deterministic, free, and teaches the part interviewers
actually probe. Each lab README ends with a "How this maps to real Azure" section that
ties the miniature back to the production service, its limits, and its az/Terraform
equivalent.
Required files (per lab)
| File | Contract |
|---|---|
README.md | the problem, what you build, key concepts table, file map, run commands, success criteria, "How this maps to real Azure", extensions, interview/resume bullets |
lab.py | learner implementation with focused # TODO markers and signatures/docstrings already in place; never a blank file |
solution.py | complete reference; python solution.py runs a worked example and prints output; deterministic |
test_lab.py | positive, negative, boundary, and determinism tests; runnable against either module via LAB_MODULE |
requirements.txt | usually pytest only — labs are pure stdlib otherwise |
The runnable core is Python (stdlib + pytest), offline, deterministic. No network, no
Azure SDK, no credentials, no Date.now()/random without a seed. Multi-language or
real-az/Terraform work appears only as build specs in the README "Extensions"
section for the learner's own subscription.
The test contract
import importlib, os
lab = importlib.import_module(os.environ.get("LAB_MODULE", "lab"))
Run both ways; both must pass:
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
Test taxonomy every flagship lab includes: happy path; malformed / out-of-range
input (raises ValueError); deny-by-default / least-privilege behavior where
identity or policy is involved; boundary cases (the off-by-one that interviewers
probe — R+W>N strictness, NSG priority ties, lock-expiry exactly at the boundary,
longest-prefix /32 vs /0); idempotency / replay / duplicate where the service
guarantees it; and deterministic output (same input → same bytes).
The four teaching docs (per phase)
Every phase explains the same material through four lenses, because principal-level understanding is their intersection:
| Document | Voice | What it gives you |
|---|---|---|
README.md | the syllabus | why the phase exists, concept map, lab spec table, integrated-scenario ideas, deliverables checklist, key takeaways |
WARMUP.md | the professor | zero-to-principal primer: every term from first principles → what it is → why it exists → how it works under the hood (mechanism, diagrams, small code/math) → production significance → common misconceptions; then a Lab Walkthrough, Success Criteria, Interview Q&A, and References (primary sources: Microsoft Learn, RFCs, ARM/Entra docs, talks) |
HITCHHIKERS-GUIDE.md | the senior who's been there | compressed practitioner tour: 30-second mental model, the numbers to tattoo on your arm, az/Terraform one-liners, war stories, vocabulary, beginner mistakes |
BROTHER-TALK.md | your brother, off the record | candid real-talk: what nobody tells you, the 2 a.m. pager truth, what's worth caring about, the career framing |
Doc conventions (mdBook-compatible)
WARMUP.mdopens with a Table of Contents of working anchor links, kept in sync with the headings.- MathJax for any math (
\( … \)inline,$$ … $$block). Standard Markdown only. - No bare angle brackets in prose — wrap
<like-this>in backticks so mdBook does not eat them as HTML. - File references use relative links. Code fences are language-tagged.
Definition of done
A lab is complete only when: the reference suite passes (LAB_MODULE=solution), the
learner lab.py passes after the TODOs are filled in, python solution.py prints a
sensible worked example, the README's success criteria are testable, and the "How this
maps to real Azure" section is accurate about the production service and its limits.
Glossary — Every Azure Term in the JD, Defined Precisely
The JD names ~140 Azure technologies and concepts. This glossary defines each to the depth an interviewer probes, and points to the phase where you build it. If a term in the JD is not here, that is a bug — open it as a gap.
Legend: → PNN = the phase that implements/teaches it.
A. Control Plane & Resource Management
- Azure Resource Manager (ARM) — the single control-plane API and deployment engine in
front of every Azure write. A request is an idempotent
PUT/PATCH/DELETEto a URI like/subscriptions/{s}/resourceGroups/{rg}/providers/{ns}/{type}/{name}?api-version=…. ARM authenticates (Entra token), authorizes (RBAC + deny assignments), validates against Policy, then routes to the resource provider. → P00, P01 - Resource provider (RP) — a service (e.g.
Microsoft.Storage,Microsoft.Compute,Microsoft.Network) that owns a set of resource types and implements their control- plane CRUD. Must be registered on a subscription before use. → P01 - Resource ID — the canonical, hierarchical identifier:
/subscriptions/{sub}/resourceGroups/{rg}/providers/{namespace}/{type}/{name}. The unit of RBAC scope, Policy scope, tagging, and locks. → P00 - Resource group (RG) — a lifecycle and scope boundary; resources in one RG share a region for metadata (not necessarily for the resource) and are deleted together. → P01
- Management group (MG) — a container above subscriptions forming a tree (root → MGs → subscriptions → RGs → resources). Policy and RBAC assigned at an MG flow down by inheritance. Max depth 6 levels (excluding root/subscription). → P05
- ARM template — a declarative JSON document (
$schema,parameters,variables,resources,outputs) describing desired state. The deployment engine resolvesdependsOn+ implicitreference()/resourceId()dependencies into a DAG and deploys in topological order. → P01 - Bicep — a typed DSL that transpiles to ARM JSON; same engine, nicer syntax, implicit dependency detection via symbolic references. → P01
- Deployment mode —
Incremental(default; leaves un-templated resources) vsComplete(deletes anything in the RG not in the template — a foot-gun). → P01 - What-if — ARM's dry-run that diffs desired vs current state and classifies each change
(
Create/Modify/Delete/NoChange/Ignore). → P01 - Deployment stack — a resource that manages a set of resources as a unit with managed deletion and deny-settings (drift protection). → P01, P05
- Idempotency — applying the same desired state twice yields the same result and no
duplicate side effects. ARM
PUTis idempotent by design; the whole IaC model depends on it. → P01, P02
B. Infrastructure as Code
- Terraform — declarative IaC with a state file (the recorded reality), a plan
(desired vs state diff → create/update/replace/delete), and apply (executes the plan
in dependency order). The
azurermprovider maps HCL resources to ARM calls. → P02 - State — Terraform's source of truth mapping config addresses to real resource IDs + attributes. Stored remotely (Azure Storage backend with blob lease locking) to allow team collaboration. Drift = real ≠ state. → P02
- Plan graph / dependency ordering — Terraform builds a DAG from references and
depends_on, walks it to order create/destroy; destroys run in reverse. → P02 - ForceNew / replace — a change to an immutable attribute requires destroy-then-create
(
-/+), not in-place update. Recognizing which attributes areForceNewis principal- level Terraform. → P02 - CDKTF (Cloud Development Kit for Terraform) — author Terraform in TypeScript/Python/ etc.; synthesizes to Terraform JSON, then uses the normal plan/apply engine. → P02
- Drift detection — comparing real state to recorded state to find out-of-band changes
(
terraform planwith refresh, or a deployment stack's deny-settings). → P02 - Module / provider / backend — reusable config unit; the plugin that talks to a target API; where state lives. → P02
C. Identity — Microsoft Entra ID (Azure AD)
- Microsoft Entra ID — Azure's cloud identity provider (formerly Azure AD): tenants, users, groups, service principals, app registrations, and the OAuth2/OIDC token service. The control-plane authentication authority. → P03
- Tenant — an isolated Entra directory instance; identified by a tenant ID (GUID). The
token issuer (
iss) is scoped to a tenant. → P03 - App registration vs service principal — the registration is the global app definition (in its home tenant); the service principal is the local identity/credential instance in a tenant that gets role assignments. → P03
- OAuth2 — the authorization framework (RFC 6749). Grants used in Azure: authorization code (+ PKCE) for users, client credentials for daemons, on-behalf-of for middle tiers, device code for input-constrained devices. Issues access tokens. → P03
- OpenID Connect (OIDC) — an identity layer on top of OAuth2 that adds the ID token
(a JWT proving who the user is) and a discovery document
(
/.well-known/openid-configuration). → P03 - PKCE (Proof Key for Code Exchange) — RFC 7636: the public client sends
code_challenge = BASE64URL(SHA256(code_verifier)); at token exchange it presents thecode_verifier. Defeats authorization-code interception. → P03 - JWT (JSON Web Token) —
base64url(header).base64url(payload).base64url(signature). Validation = verify signature against the issuer's JWKS key (bykid), then checkiss,aud,exp,nbf, and authorization claims (scpfor delegated scopes,rolesfor app roles). → P03, P09 - Claims —
iss(issuer),aud(audience = your API's app ID URI),sub(subject),exp/nbf/iat(validity window),scp(space-delimited delegated scopes),roles(app roles),appid/azp(client),oid/tid(object/tenant). → P03 - JWKS /
kid— the issuer's published JSON Web Key Set; the token header'skidselects which public key validates the signature, enabling key rotation. → P03 - Conditional Access — policy engine that gates token issuance on signals (user, device compliance, location, risk) → grant/block/require MFA. → P03
- Managed Identity — an Entra service principal that Azure manages for you so code holds no secret. System-assigned (tied to one resource's lifecycle) or user-assigned (standalone, attachable to many). Tokens are fetched from IMDS. → P12
- IMDS (Instance Metadata Service) — the non-routable
169.254.169.254endpoint a VM/ Function calls to get a Managed-Identity access token, no credential required. → P12 - Workload identity federation — exchanging a trusted external OIDC token (e.g. GitHub
Actions, a Kubernetes service account) for an Entra token with no stored secret, by
matching the federated credential's
issuer/subject/audience. → P08, P12
D. Authorization — RBAC & Policy
- Azure RBAC — authorization model: a role assignment binds a security principal (user/group/SP/MI) to a role definition at a scope. Allow-based; assignments are additive and inherited down the scope hierarchy. → P04
- Role definition — a named set of permissions:
Actions,NotActions(subtracted),DataActions,NotDataActions, andAssignableScopes. Effective control-plane permission =Actions − NotActions, matched with wildcards (*,Microsoft.Storage/*/read). → P04 - Deny assignment — an explicit deny that overrides role assignments (used by Azure Blueprints/managed apps/deployment stacks). Evaluation: deny wins. → P04
- Control plane vs data plane authorization —
Actionsgovern managing the resource (ARM);DataActionsgovern the data inside it (e.g. read a blob, get a secret). A storageOwneris not automatically aBlob Data Reader. → P04, P12 - Azure Policy — governance engine evaluating resources against rules. Effects:
audit,deny,append,modify,auditIfNotExists,deployIfNotExists(DINE),disabled. Evaluated at create/update (control plane) and on a compliance scan. → P04 - Policy alias — maps a policy
fieldto a provider property path (e.g.Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly) so rules can read resource properties. → P04 - Initiative (policy set) — a bundle of policy definitions assigned together (e.g. a regulatory baseline). → P04, P05
- Exemption / exclusion — a documented, time-boxed carve-out from a policy/initiative
at a scope (
notScopes). → P05
E. Governance at Scale — Landing Zones
- Cloud Adoption Framework (CAF) — Microsoft's guidance for governed cloud adoption; the Azure Landing Zone (ALZ) is its reference architecture. → P05
- Azure Landing Zone (ALZ) — a pre-built, governed environment: a management-group hierarchy (Platform / Landing Zones / Sandbox / Decommissioned), policy-as-code baselines, RBAC, hub networking, logging, and a subscription-vending process. → P05
- Subscription vending — the automated, policy-compliant allocation of a new subscription into the right MG with budgets, RBAC, and networking pre-wired. → P05
- Policy-as-code — policy/initiative definitions and assignments managed in Git and deployed via pipelines, so governance is reviewed and versioned. → P05
F. Networking
- Virtual network (VNet) — an isolated L3 network in a region with a private address space (CIDR), subdivided into subnets. Software-defined: there is no physical switch you can see. → P06
- Network security group (NSG) — a stateful, priority-ordered allow/deny firewall on a subnet or NIC. Rules (100–4096) are evaluated low-number-first; first match wins; default rules allow VNet-in/out and Azure LB and deny the rest. Stateful: the return packet is auto-allowed. → P06
- User-defined route (UDR) / route table — overrides Azure's system routes. Next-hop chosen by longest-prefix match; ties break UDR > BGP > system. Used to force traffic through an NVA/firewall. → P06
- System routes / BGP — Azure's default routes (VNet-local, internet, etc.) and dynamically learned routes (via ExpressRoute/VPN). → P06
- Private Endpoint / Private Link — a private IP in your subnet that maps to a PaaS resource (storage, SQL, Key Vault), pulling it onto your VNet; it rewrites DNS so the public FQDN resolves to the private IP via a Private DNS zone. Misconfigured DNS is the #1 Private Endpoint failure. → P06, P12
- Service endpoint — an alternative that extends a VNet identity to a PaaS service over the Azure backbone (no private IP). → P06
- Azure Firewall / NVA — a managed (or third-party) stateful firewall used as the hub's egress/inspection point, targeted by UDRs. → P06
- Hub-and-spoke — a topology with shared services (firewall, DNS, gateways) in a hub VNet peered to many spoke workload VNets. → P06
- VNet peering / ExpressRoute / VPN Gateway — VNet-to-VNet private connectivity; private dedicated circuit to on-prem; encrypted tunnel to on-prem. → P06
- Azure Front Door / Application Gateway / Load Balancer — global L7 CDN+WAF; regional L7 WAF/router; regional L4 distributor. → P06, P09
G. Containers
- Docker image — an ordered stack of read-only layers + a config; identified by
a content-addressed digest (
sha256:…). A manifest lists the config + layer digests; a tag is a mutable pointer to a digest. → P07 - OCI distribution — the registry API: blobs (layers/config) are content-addressable; pulling resolves tag → manifest → layer digests, fetching only missing layers. → P07
- Azure Container Registry (ACR) — managed OCI registry with geo-replication, ACR Tasks (build/patch on push), content trust (Notary signing), soft-delete, and garbage collection of untagged manifests. → P07
- AKS (Azure Kubernetes Service) — managed Kubernetes: control plane (managed) + node
pools; the scheduler bin-packs pods onto nodes by
requests, respectinglimits, taints/tolerations, affinity, and topology. → P07 - Azure Container Apps (ACA) / Container Instances (ACI) — serverless containers (KEDA-driven scale-to-zero) and single-shot container groups; the non-Kubernetes paths. → P07
H. CI/CD & Supply Chain
- Azure Pipelines / GitHub Actions — CI/CD as a DAG of stages → jobs → steps with
dependsOn, conditions, matrix fan-out, environments, and approval gates. → P08 - OIDC deployment federation — the pipeline presents its OIDC token to Entra and gets an access token via workload-identity federation — no stored cloud secret. The modern default for CI deploys. → P08, P12
- Deployment strategy — blue-green (swap two environments), canary (shift a % then ramp), rolling — with a health gate that triggers automatic rollback. → P08, P14
- Artifact / SBOM / provenance — the built unit, its bill of materials, and signed evidence of how/where it was built (supply-chain integrity). → P08
I. APIs & Edge
- REST / JSON — resource-oriented HTTP: methods (
GET/POST/PUT/PATCH/DELETE), status codes, idempotency, pagination, and JSON bodies. → P09 - Azure API Management (APIM) — the API gateway: products/APIs/operations, a policy pipeline (inbound → backend → outbound → on-error) for JWT validation, rate-limit/quota, caching, transformation, and routing. → P09
- Scope / app-role authorization — the API validates the token's
scp(delegated) orroles(app) claim against the operation's requirement before letting the call through. → P09 - Token-bucket rate limiting — capacity + refill-rate algorithm bounding request rate;
the basis of
rate-limitandquotapolicies; returns429 Too Many Requests+Retry-After. → P09, P14
J. Eventing & Messaging
- Azure Service Bus — enterprise message broker: queues + topics/subscriptions,
peek-lock (lock → process → complete/abandon/dead-letter), sessions (FIFO per
session key), duplicate detection (dedup window by
MessageId), scheduled and deferred messages, transactions, and dead-letter queues (DLQ) on max-delivery or TTL expiry. At-least-once by default. → P10 - Azure Event Grid — a push event-routing service (publish/subscribe) using CloudEvents; at-least-once delivery with exponential-backoff retry and dead-lettering to storage; filters by event type/subject. → P10
- Event Hubs — high-throughput event streaming (partitioned log, consumer offsets); the Kafka-shaped sibling (named here for contrast). → P10
- Logic Apps — a serverless workflow orchestrator (connectors + triggers + actions) for integration; the low-code complement to Functions. → P10
- Delivery semantics — at-most-once (fire&forget), at-least-once (retry → may duplicate; the practical default), exactly-once-effect (at-least-once + idempotent consumer / dedup). → P10
K. Serverless Compute
- Azure Functions — event-driven serverless compute: a trigger (queue, HTTP, timer, Event Grid) invokes a function; bindings declaratively wire inputs/outputs. The scale controller adds/removes instances based on event source backlog. → P11
- Hosting plans — Consumption (scale-to-zero, pay-per-exec), Flex Consumption / Premium (pre-warmed, VNet), Dedicated. Cold start is the Consumption tradeoff. → P11
- Durable Functions — stateful orchestration on Functions via event sourcing: the
orchestrator function is replayed from its history on each event, so it must be
deterministic (no
DateTime.Now, no random, no I/O outside activities). Patterns: function chaining, fan-out/fan-in, async HTTP, monitor, human interaction. → P11
L. Secrets & Encryption
- Azure Key Vault — managed store for secrets, keys, and certificates, with soft-delete + purge protection (recoverable deletes), versioned objects, and two authorization models: RBAC (recommended) or legacy access policies. → P12
- Envelope encryption — encrypt data with a DEK (data encryption key), then encrypt (wrap) the DEK with a KEK (key encryption key) held in Key Vault/HSM; rotate the KEK without re-encrypting data. → P12
- Managed HSM / key rotation / versioning — FIPS 140-2 L3 single-tenant HSM; automated key rotation creating new versions while old versions still decrypt. → P12
M. Observability & Operations
- Azure Monitor — the umbrella: Metrics (numeric time series), Logs (Log Analytics workspace), Alerts, and Application Insights (APM + distributed tracing). → P13
- KQL (Kusto Query Language) — the read query language over Log Analytics:
where/project/summarize … by/bin()/join/top/render. The principal's incident tool. → P13 - Application Insights / distributed tracing — request/dependency telemetry correlated by
operation_Idacross services; sampling controls cost/cardinality. → P13 - Alert rule — threshold/aggregation over a metric or log query window → action group (page/email/webhook). → P13
N. Reliability, HA & Cost
- Availability zone (AZ) — physically separate datacenter(s) within a region; spreading replicas across zones survives a datacenter failure. → P14
- Region pair / paired region — two regions with platform-coordinated updates and (for some services) geo-replication for disaster recovery. → P14
- SLA composition — multiply availabilities of serial dependencies; combine redundant
components as
1 − ∏(1 − Aᵢ). More serial dependencies → lower composite SLA. → P00, P14 - Retry with backoff + jitter / retry budget — bounded exponential backoff with randomized jitter to avoid thundering herds, capped by a budget so retries can't amplify an outage. → P14
- Circuit breaker — closed → (failures exceed threshold) → open (fail fast) → half-open (probe) → closed; protects a struggling dependency. → P14
- Throttling /
429/Retry-After— Azure services rate-limit; well-behaved clients honorRetry-Afterand back off rather than hammering. → P14 - Well-Architected Framework (WAF) — the five pillars: Reliability, Security, Cost Optimization, Operational Excellence, Performance Efficiency — the rubric every design review scores against. → P00, P15
- FinOps — quantifying and optimizing cloud spend (reservations, right-sizing, autoscale, lifecycle tiers, scale-to-zero) as an engineering discipline. → P15
Cheat Sheet — The Numbers, Formulas & Decision Rules
One page of the things a principal Azure engineer recites without looking. Each links to the phase that derives it. Memorising the number is worthless without the why — but in an interview or an incident, having both is the difference between "sounds senior" and "is senior."
The control-plane request (P00–P01)
client → Entra (authenticate: JWT) → ARM (authorize: RBAC − NotActions, then deny wins)
→ Policy (deny/append/modify/audit) → resource provider (idempotent PUT)
Rule: every Azure write is one idempotent PUT to a resource ID. Control plane
(manage the resource) and data plane (the blob/secret/message) are different systems with
different identity, throttling, and SLA. Owner ≠ Blob Data Reader.
Resource ID shape:
/subscriptions/{sub}/resourceGroups/{rg}/providers/{namespace}/{type}/{name}
RBAC evaluation (P04)
effective control-plane perms = union(Actions) − union(NotActions) # across all assignments
data-plane perms = union(DataActions) − union(NotDataActions)
final = (matched by an allow) AND NOT (matched by any deny assignment) # DENY ALWAYS WINS
Wildcards: * matches one+ segments; Microsoft.Storage/*/read matches any read under the
RP. Assignments are additive and inherited down MG → Sub → RG → resource. There is
no "deny by role"; you remove access by not assigning or via a deny assignment.
Azure Policy effects (P04), precedence high→low
disabled < audit/auditIfNotExists < append/modify < deployIfNotExists < deny
deny blocks the write at the control plane; audit only flags; deployIfNotExists
(DINE) and modify need a managed identity with rights to remediate.
Management-group inheritance (P05)
effective at a scope = (assignments at this scope) ∪ (everything inherited from ancestors)
minus notScopes/exemptions
MG tree max depth 6 levels (under root). A subscription lives in exactly one MG.
OAuth2 / OIDC / JWT (P03, P09)
auth-code + PKCE: challenge = BASE64URL(SHA256(verifier)) # method S256
client-credentials: client_id + secret/cert → access token (no user, app perms in `roles`)
JWT validation order (fail closed on any miss):
1. parse header → pick key by `kid` from JWKS
2. verify signature
3. iss == expected issuer (tenant)
4. aud == your API's app-ID-URI / client-id
5. nbf ≤ now ≤ exp (apply ±clock-skew, default 300 s)
6. authorize: scp (delegated) or roles (app) contains required value
scp = space-delimited delegated scopes (user present). roles = app roles (daemon).
Never trust a token you didn't validate aud on — that's the confused-deputy.
Networking (P06)
NSG: rules 100–4096, evaluated lowest priority number first, first match wins;
defaults allow VirtualNetwork↔VirtualNetwork and AzureLB, deny the rest. Stateful —
return traffic is auto-allowed (don't write a reverse rule).
Effective route next-hop:
choose route with LONGEST PREFIX match to dest IP
tie-break: UDR > BGP > system
0.0.0.0/0 is the default; a /32 UDR beats it
Subnet math: a /26 = 64 addresses, −5 reserved by Azure (.0 network, .1 gateway,
.2/.3 Azure DNS-ish, .255 broadcast) → 59 usable. Smallest usable subnet is /29.
Private Endpoint: it rewrites DNS — the public FQDN must resolve to the private IP via a
Private DNS zone (e.g. privatelink.blob.core.windows.net). Broken DNS = #1 failure.
Containers / OCI (P07)
tag → manifest digest (sha256) → [config digest, layer digest, layer digest, …]
pull = resolve tag → fetch manifest → fetch only MISSING layer blobs (content-addressed)
Garbage collection = mark all blobs referenced by a live manifest, sweep the rest. Tags are
mutable; digests are immutable — pin prod by digest, not :latest.
CI/CD (P08)
Pipeline = DAG of stages (dependsOn + condition). A stage runs iff all deps succeeded
and its condition is true. OIDC federation beats stored secrets: pipeline OIDC token →
Entra (match issuer/subject/audience) → access token, nothing to leak.
Rollout: blue-green (instant swap/rollback), canary (% then ramp, gate on health).
Messaging / eventing (P10)
Service Bus peek-lock:
receive → message LOCKED for lockDuration → complete | abandon | dead-letter
lock expires → message redelivered, DeliveryCount++
DeliveryCount > MaxDeliveryCount → moved to DLQ
Dedup: within DuplicateDetectionWindow, a repeat MessageId is dropped. Sessions =
FIFO + single-consumer per session key. Event Grid: at-least-once, exponential-backoff
retry (to ~24 h), then dead-letter to storage.
Delivery semantics: at-most (no retry) → at-least (retry, may dup) → exactly-once effect (at-least + idempotent/dedup consumer).
Serverless (P11)
Functions scale controller adds instances from event backlog (queue depth / lag), up to
the plan max; Consumption scales to zero (cold start is the cost). Durable orchestrators
are replayed from history every event → must be deterministic: no clock, no random,
no I/O outside activities; use context APIs only.
Key Vault & Managed Identity (P12)
Managed Identity token: code → IMDS 169.254.169.254 → access token (NO secret)
Envelope encryption: data --DEK--> ciphertext ; DEK --KEK(in Vault)--> wrapped DEK
rotate KEK ⇒ rewrap DEK only (data untouched)
Soft-delete + purge-protection: a deleted secret/key is recoverable for the retention window and cannot be purged early when purge-protection is on. Prefer RBAC over legacy access policies.
Observability / KQL (P13)
T | where Timestamp > ago(1h)
| summarize p95 = percentile(DurationMs, 95), n = count() by bin(Timestamp, 5m), Service
| top 10 by p95 desc
summarize … by groups; bin(t, 5m) buckets time; sampling trades cost vs fidelity.
Reliability & SLA math (P00, P14)
serial dependencies: A_total = ∏ Aᵢ (more hops → lower SLA)
redundant copies: A = 1 − ∏ (1 − Aᵢ) (parallel raises SLA)
Three serial 99.9% services → 99.9%³ ≈ 99.7% (≈2× the downtime). Two redundant 99% → 1 − 0.01² = 99.99%.
Retry: exponential backoff base · 2ⁿ + full jitter random(0, backoff), capped, under
a retry budget (e.g. ≤10% of calls) so retries can't amplify an outage. Honor 429 +
Retry-After. Circuit breaker: closed → open (fail fast) → half-open (probe) → closed.
The five Well-Architected pillars (P00, P15)
Reliability · Security · Cost Optimization · Operational Excellence · Performance Efficiency
Rule: name the pillar you are trading, quantify both ends, then choose — and write the ADR. "Best architecture" is meaningless without the workload's weights.
Interview Prep — Principal Azure Engineer / Architect
A running reference for the questions a strong panel actually asks — and the level of answer that separates senior from principal. Pair each section with the phase that builds the mechanism, so your answer is "I implemented this," not "I read about it."
How principal interviews differ
A senior candidate explains what a service does. A principal explains how it works under the hood, when it breaks, what it costs, and what they'd trade. Every answer below should end with a tradeoff and, where possible, a number.
1. Control plane & IaC (P00–P02)
- "Walk me through what happens when you run
terraform applyagainst Azure." — HCL → plan (state vs desired diff → create/update/replace/delete;ForceNew⇒ replace), DAG ordering, each resource = an ARMPUT(idempotent) → RP. State lock via blob lease. Drift = real ≠ state. - "ARM template vs Bicep vs Terraform vs CDKTF — when each?" — Same engine underneath; choose by team (HCL multi-cloud → Terraform; Azure-only typed → Bicep; programmatic synth → CDKTF). Defend with blast radius, state ownership, and drift story, not taste.
- "Incremental vs Complete deployment mode — what's the foot-gun?" — Complete deletes un-templated resources in the RG. Name a time it would have wiped something.
- "How do you make a deployment idempotent and safe to re-run?" —
PUTdesired state, no imperative side effects, what-if/plan first, deployment stacks with deny-settings.
2. Identity, OAuth2 & JWT (P03)
- "Draw the authorization-code + PKCE flow." — verifier/challenge (S256), redirect with
code, back-channel token exchange with verifier, ID token (OIDC) + access token. - "How do you validate a JWT? In what order, and what fails closed?" —
kid→ JWKS key → signature →iss→aud→nbf/exp(±skew) →scp/roles. Skippingaud= the confused-deputy. - "Client credentials vs on-behalf-of vs managed identity — when?" — daemon vs middle- tier delegation vs secret-less Azure workload.
- "
scpvsroles?" — delegated (user present) vs application (daemon) authorization.
3. Authorization — RBAC & Policy (P04–P05)
- "A user is
Owneron the subscription but can't read a blob. Why?" — control-plane vs data-plane:OwnergrantsActions, notDataActions; needsStorage Blob Data Reader. - "How does deny win?" — deny assignments override role assignments; walk the evaluation.
- "How would you enforce 'no public storage accounts' across 200 subscriptions?" —
Azure Policy
denyat a management group, policy-as-code in Git, exemptions time-boxed. - "Effective permissions at a resource?" — union of inherited assignments minus NotActions, minus deny.
4. Networking (P06)
- "A VM can't reach a storage account over its Private Endpoint. Diagnose." — DNS: the FQDN must resolve to the private IP via the Private DNS zone; check NSG, then UDR next-hop, then the zone link. (Almost always DNS.)
- "Two NSG rules match a packet — which wins?" — lowest priority number; first match; stateful return.
- "Force all egress through a firewall — how?" — UDR
0.0.0.0/0→ NVA next-hop; mind longest-prefix and asymmetric routing. - "Size a VNet for 3 subnets of ~500 hosts each." —
/23per subnet (512 − 5), parent/21; show the −5 reserved.
5. Containers & CI/CD (P07–P08)
- "Why pin by digest, not
:latest?" — tags are mutable; digests are immutable + content-addressed; reproducibility + supply-chain. - "How does a registry save space across images?" — shared content-addressed layers; GC marks live-manifest blobs and sweeps the rest.
- "Deploy from CI without storing a cloud secret." — OIDC workload-identity federation (issuer/subject/audience match) → short-lived token.
- "Blue-green vs canary — pick one and defend." — instant rollback vs gradual exposure; tie to blast radius and the health gate.
6. Events & serverless (P10–P11)
- "Exactly-once with Service Bus?" — there is no exactly-once delivery; you get at-least-once + an idempotent/dedup consumer = exactly-once effect. Peek-lock, DeliveryCount, DLQ.
- "Event Grid vs Service Bus vs Event Hubs?" — reactive push routing vs enterprise broker (ordering/sessions/transactions) vs high-throughput stream.
- "Why must a Durable orchestrator be deterministic?" — it's replayed from history; a clock/random/IO call would diverge on replay. Side effects go in activities.
- "Cold start — when does it matter and how do you fix it?" — Consumption scale-to-zero; Premium/Flex pre-warmed instances, or keep-warm.
7. Secrets & reliability (P12, P14)
- "How does a Function authenticate to Key Vault with no secret?" — Managed Identity → IMDS token → Key Vault data-plane (RBAC). Envelope encryption + KEK rotation.
- "Compose the SLA of: Front Door → APIM → Function → Cosmos." — multiply the serial availabilities; show where redundancy raises it; name the weakest link.
- "A dependency is failing — retries are making it worse. What do you do?" — backoff +
jitter under a retry budget, circuit breaker, honor
429/Retry-After, shed load.
The system-design loop (use in every round)
- Clarify the workload: RPS, payload size, latency SLO, availability target, data residency, compliance.
- Sketch control plane (identity, RBAC, policy, landing zone) and data plane (network, compute, events, storage) — principals always draw both.
- Quantify: SLA composition, cost/month, partition/instance sizing, retry budget.
- Name the tradeoff per Well-Architected pillar and write the ADR sentence.
- Fail it: what's the blast radius, the throttling limit, the DR story, the rollback?
See ../system-design/README.md for worked reference designs.
System Design — Reference Azure Architectures
Worked, principal-level designs that tie the whole track together. Each one names the control plane (identity, RBAC, policy, landing zone) and the data plane (network, compute, events, storage), then quantifies the SLA, the cost, and the tradeoff per Well-Architected pillar. Use them as templates; the capstone (P15) builds one.
The design method (every time)
- Workload facts first: RPS, payload, p99 latency SLO, availability target, data residency, compliance regime, team size.
- Two planes, always:
- Control plane — which tenant/MG/subscription, RBAC model, policy baseline, IaC + pipeline, secrets strategy.
- Data plane — VNet/subnets/NSG/Private Endpoints, compute, eventing, storage, egress.
- Quantify: compose the SLA (serial ∏Aᵢ; redundant 1−∏(1−Aᵢ)), estimate $/month, size instances/partitions, set the retry budget.
- Tradeoff per pillar: Reliability, Security, Cost, Operational Excellence, Performance — name what you trade and write the ADR sentence.
- Fail it: blast radius, throttling limits, DR/region-failover, rollback.
Design 1 — Secure event-driven serverless API (the canonical platform)
Requirement: a public REST API, 2k RPS, p99 < 300 ms, 99.95% target, PII at rest, EU data residency, deploy 20×/day.
Control plane
- Landing zone: workload subscription under the
CorpMG; policy baseline denies public storage/SQL and non-EU regions; RBAC via Entra groups, least privilege. - IaC: Terraform (
azurerm) in Git; CI deploys via OIDC federation (no secret); what-if/plan gate + manual approval to prod.
Data plane
- Edge: Front Door (global, WAF) → APIM (JWT validation:
aud/iss/scp, rate-limit/quota) → Functions (Flex Consumption, VNet-integrated). - Async: API drops work on Service Bus; a Function consumes peek-lock with DLQ; an Event Grid topic fans out domain events to subscribers.
- Data: Cosmos DB (EU region, zone-redundant) via Private Endpoint; secrets/keys in Key Vault (RBAC, purge protection); compute uses Managed Identity (no secrets).
- Observability: App Insights distributed tracing correlated by
operation_Id; Log Analytics + KQL alerts on p95 latency and DLQ depth.
Quantify
- SLA: Front Door (99.99) × APIM (99.95) × Functions (99.95) × Cosmos (99.99 zone-redundant) ≈ 99.88% serial — below target ⇒ add regional redundancy on the weakest link or relax the SLO; show the math, then decide.
- Retry: Service Bus consumer backoff + jitter, MaxDelivery 5 → DLQ; client honors
429.
Tradeoffs
- Cost vs latency: Flex/Premium kills cold starts but costs more than Consumption — justify by the p99 SLO.
- Security vs simplicity: Private Endpoints + no public PaaS adds DNS complexity; worth it for PII.
Design 2 — Multi-subscription landing zone & governance (P05)
Requirement: onboard 50 teams safely; enforce guardrails; central networking/logging.
- MG hierarchy:
Root → Platform (Identity/Management/Connectivity) + Landing Zones (Corp/ Online) + Sandbox + Decommissioned. - Policy-as-code initiative at each MG: deny disallowed regions, require diagnostic settings, enforce HTTPS/TLS, audit untagged resources; exemptions time-boxed in Git.
- Subscription vending: pipeline creates a subscription in the right MG with budgets, RBAC groups, hub-spoke peering, and a Private DNS link pre-wired.
- Hub-and-spoke: shared Azure Firewall + DNS in the hub; spokes route
0.0.0.0/0via UDR to the firewall; central Log Analytics.
Tradeoff: central control vs team velocity — guardrails as deny for the
non-negotiables, audit for the rest, self-service inside the guardrails.
Design 3 — Zero-downtime deploy with rollback (P08, P14)
- Blue-green slots behind APIM/Front Door; deploy green, smoke-test, swap; health gate on error-rate/latency triggers auto-rollback (swap back).
- Canary alternative: shift 5% → 25% → 100% with KQL health gates between steps; abort on regression.
- DB changes: expand-migrate-contract (backward-compatible schema) so a rollback is safe.
Tradeoff: blue-green = instant rollback, double the resources during deploy; canary = smaller blast radius, slower and needs solid health signals.
Design 4 — Disaster recovery & multi-region (P14)
- Active-passive across a region pair: primary serves; secondary warm with geo-replicated Cosmos/storage and IaC-reproducible infra; Front Door fails over on health probe.
- RPO/RTO drive the spend: synchronous replication (low RPO, high cost) vs async (cheaper, data-loss window). State the numbers.
- Test the failover (game day) — an untested DR plan is a hope, not a control.
Anti-patterns the panel listens for
- Designing only the data plane and forgetting identity/RBAC/policy/landing zone.
- "We'll use Kubernetes" with no workload numbers to justify it over Container Apps/Functions.
- Public PaaS endpoints for PII data; secrets in app settings instead of Key Vault + MI.
- Claiming "exactly-once" delivery; quoting an SLA without composing the dependency chain.
- No rollback story, no DR test, no cost estimate.
Phase 00 — The Azure Principal: Control Plane, Well-Architected & Tradeoffs
Difficulty: ⭐⭐☆☆☆ (conceptual) → the judgment it builds is ⭐⭐⭐⭐⭐ Estimated Time: 1 week (12–18 hours) Prerequisites: none — this is the orientation that makes the other 15 phases cohere
Why This Phase Exists
Before you touch Terraform, Entra ID, NSGs, or Service Bus, you have to understand what job you are actually being hired to do on Azure — because at the principal level the job is not "operate the portal," it is "make correct tradeoffs under conflicting requirements, and debug the layer everyone else stands on."
The reference JD says it plainly: this is "not a 'click in the portal' role and not a 'run one Terraform module' role. It is about owning the cloud platform layer itself." And the hardest part is "making correct tradeoffs" and being "the escalation point when it breaks at 2 a.m."
Two truths organise everything that follows, and this phase plants both:
- There is exactly one control plane. Every write to Azure — a VM, a role assignment, a
policy, a private endpoint — is an idempotent
PUTto a resource ID through Azure Resource Manager (ARM). Authenticate (Entra token) → authorize (RBAC, then deny wins) → validate (Policy) → route to the resource provider. If you hold this one request shape, every later phase is "what does this provider do once ARM hands it the request?" - Every design is a tradeoff across five pillars. The Well-Architected Framework — Reliability, Security, Cost Optimization, Operational Excellence, Performance Efficiency — is the rubric every design review scores against. You cannot max all five; the principal names the pillar being traded, quantifies both ends with numbers (SLA math, the bill), chooses, and writes the ADR.
So Phase 00 builds the mental model and the calculator for those tradeoffs. Every later phase plugs a real Azure service into a slot in the model you build here. Skip this phase and you will learn 15 services and still not be able to architect — which is exactly the failure mode that keeps strong engineers stuck at senior.
Concepts
- Azure Resource Manager (ARM) as the single control plane. Every write is an idempotent
PUTto a resource ID; ARM authenticates, authorizes (RBAC −NotActions, then deny wins), validates (Policy), then routes to the resource provider. Re-issuing the samePUTconverges to the same state — the property the entire IaC model depends on. - Control plane vs data plane. ARM/RBAC/Policy decide what exists and who may manage
it (
Actions); the blob, secret, message, and token are a separate system with its own identity, throttling, and SLA (DataActions). A storageOwneris not automatically aBlob Data Reader. This single distinction explains a huge fraction of "I have access but I'm getting 403" incidents. - The resource ID hierarchy and scopes. The canonical ID
/subscriptions/{s}/resourceGroups/{rg}/providers/{ns}/{type}/{name}is the unit of RBAC scope, Policy scope, tagging, and locks. Scopes nest Management Group → Subscription → Resource Group → resource, and assignments inherit downward. Resource providers (Microsoft.Storage,Microsoft.Network, …) own the types and must be registered. - The Well-Architected Framework five pillars — Reliability, Security, Cost Optimization, Operational Excellence, Performance Efficiency — and how every design trades them against each other.
- SLA composition math. Serial dependencies multiply (
A = ∏ Aᵢ— more hops, lower SLA); redundant components combine (A = 1 − ∏(1 − Aᵢ)— zones/regions raise it); and(1 − A) × 43 200converts a nines-target into downtime minutes/month. Availability zones and paired regions are how you buy the redundancy term. - Cost modeling basics & "spend the headroom." Compute hours × price, storage GB-month, and egress GB are the first-order bill; unused reliability or latency headroom is a cost lever, not a virtue.
- The Architecture Decision Record (ADR). The lightweight artifact that makes a tradeoff durable and reviewable: name the pillar, quantify both ends, choose, and record what would change your mind. A design review (interview Round 5) is a room pressure-testing your ADRs.
Labs
Lab 01 — WAF Tradeoff Modeler (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the calculator behind Round-1 Azure architecture interviews: parse an ARM resource ID into its hierarchy and classify its scope, compose serial and redundant SLAs and convert them to downtime minutes/month, estimate a monthly bill (compute + storage + egress), and run a weighted Well-Architected tradeoff resolver that picks between two architectures given the workload's pillar weights and names the deciding pillar. |
| Concepts | ARM resource-ID parsing & scope classification; control-plane addressing; SLA composition (serial multiply, redundant combine); downtime arithmetic; first-order cost modeling; the five WAF pillars as a scoring function; "name the pillar, quantify both ends, then choose." |
| Steps | 1. ResourceId dataclass + parse_resource_id (subscription / RG / resource / nested types, with ValueError on malformed); 2. resource_scope_level; 3. SLA functions (serial_sla, redundant_sla, downtime_minutes_per_month); 4. monthly_cost_usd; 5. Architecture (five-pillar validation) + resolve_tradeoff with explainable, weight-driven output. |
| How to Test | pytest test_lab.py -v — covers resource-id happy/nested/malformed cases and scope levels; exact serial-vs-redundant SLA numbers (three 0.999 serial ≈ 0.997002999; two 0.99 redundant = 0.9999); downtime and cost math; pillar validation errors; the weights-flip-the-winner property; and a tie. |
| Talking Points | "What happens between az ... create and the resource existing?" "Why is Owner not enough to read a blob?" "Compose the SLA of web → SQL → Key Vault — show me the number." "Defend single-region over multi-region with numbers." |
| Resume bullet | Built a Well-Architected decision-support model that parses ARM resource-ID scopes, composes serial/redundant SLAs into downtime budgets, estimates monthly cost, and produces explainable, weight-driven architecture tradeoffs for design reviews and ADRs. |
→ Lab folder: lab-01-waf-tradeoff-modeler/
Integrated-Scenario Suggestions (carried through the whole track)
These scenarios are the spine of the capstone (Phase 15). Keep them in mind from now — each later phase contributes a real, tested component, and each one forces the five-pillar tradeoff you model here:
- A regulated multi-subscription landing zone — management-group hierarchy, policy-as- code baselines, RBAC inheritance, and subscription vending (P04–P05). Tradeoff: security & governance ↔ developer velocity.
- A secure event-driven serverless platform — Functions + Durable + Service Bus + Event Grid behind Private Endpoints, secrets in Key Vault via Managed Identity (P10–P12). Tradeoff: reliability (exactly-once-effect) ↔ operational simplicity.
- A global API front door — APIM with JWT scope authorization and token-bucket rate limiting, multi-region with Front Door (P09). Tradeoff: performance/availability ↔ cost (egress, duplicated compute).
- A private-networking hub-and-spoke — NSGs, UDRs, Azure Firewall, Private DNS (P06). Tradeoff: security isolation ↔ operability (the #1 Private Endpoint DNS failure).
- An observability + reliability backbone — Azure Monitor, KQL, alert rules, retry + circuit breaker + SLA composition (P13–P14). Tradeoff: cost (telemetry volume) ↔ fidelity / time-to-detect.
For each, write the one-paragraph WAF tradeoff now (which pillar dominates, what you'd trade, the number that decides it). You'll refine it as the matching phase lands.
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full zero-to-principal primer; read slowly
- BROTHER-TALK.md — the candid, off-the-record version
Key Takeaways
- The job is tradeoffs across five pillars, made on one control plane, encoded into platforms and defended in ADRs — not portal clicks.
- Every Azure write is one idempotent
PUTto a resource ID. Control plane (manage) and data plane (the blob/secret/message) are different systems with different identity, throttling, and SLA.Owner≠Blob Data Reader. - SLA and cost are arithmetic you do before the review, not after the incident: serial multiplies, redundant combines, nines convert to minutes, and the bill has a shape.
- "Make it reliable / cheap / fast" is not a requirement until you attach numbers to the pillars; this phase's lab is where you learn to attach them.
Deliverables Checklist
-
Lab 01 implemented; all tests pass against
solution.pyand yourlab.py. - You can draw the control-plane request path (Entra → ARM → RBAC → Policy → RP) from memory and parse any resource ID into its scope.
- You can compose a serial and a redundant SLA in your head and convert it to minutes/month (99.9 % → 43.2; 99.99 % → 4.32).
- You can take any "design X on Azure" prompt and immediately name the dominant pillar and the pillar you'd trade.
-
You can write a one-page ADR for a real decision (template in
WARMUP.md).
Warmup Guide — The Azure Principal: Control Plane, Well-Architected & Tradeoffs
Zero-to-principal primer for Phase 00. We start from "what even is Azure when you strip the portal away" and end with the arithmetic and the artifacts that turn architectural opinion into engineering: the single control plane and the resource-ID address space, the control-plane/data-plane split, the five Well-Architected pillars, SLA composition and downtime math, first-order cost modeling, and the Architecture Decision Record. Every later phase plugs a real Azure service into a slot in the model built here.
Table of Contents
- Chapter 1: Azure Resource Manager — The One Control Plane
- Chapter 2: The Resource ID — Azure's Address Space
- Chapter 3: Control Plane vs Data Plane
- Chapter 4: The Well-Architected Framework — Five Pillars
- Chapter 5: SLA Composition — The Reliability Arithmetic
- Chapter 6: Availability Zones & Paired Regions
- Chapter 7: Cost Modeling and "Spend the Headroom"
- Chapter 8: The ADR — Making a Tradeoff Durable
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Azure Resource Manager — The One Control Plane
From zero. When you click "Create a virtual machine" in the portal, run
az vm create, terraform apply, or New-AzVM, none of those is "talking to a VM." They
are all generating one thing: an HTTPS request to a single deployment-and-management API
called Azure Resource Manager (ARM). The portal is a JavaScript app that calls ARM. The
CLI is a Python app that calls ARM. Terraform's azurerm provider is a Go binary that calls
ARM. There is exactly one front door, and everything in this curriculum sits behind it.
What it is. ARM is the control-plane API and deployment engine in front of every Azure write. A request is an HTTP method against a resource URI with an API version:
PUT https://management.azure.com
/subscriptions/{sub}/resourceGroups/{rg}
/providers/Microsoft.Storage/storageAccounts/{name}?api-version=2023-01-01
Authorization: Bearer <Entra access token>
Content-Type: application/json
{ "location": "eastus", "sku": { "name": "Standard_LRS" }, "kind": "StorageV2" }
Why it exists. Before ARM (the "classic"/ASM era), each service had its own management API, its own permissions model, and no shared notion of grouping, tagging, templating, or dependency ordering. ARM unifies all of that: one auth model (Entra), one authorization model (RBAC + Policy), one identity for resources (the resource ID), one grouping primitive (resource groups), one templating/deployment engine, and one audit log (Activity Log). The payoff is that governance, automation, and reasoning generalise across every service.
How it works under the hood. A single write travels a fixed pipeline. Memorise it; it is the spine of phases 01–05:
client (portal / az / Terraform / SDK)
│ PUT /subscriptions/.../{type}/{name}?api-version=… + Bearer token
▼
┌─────────────────────────────────────────────────────────────────────────┐
│ Azure Resource Manager (management.azure.com) │
│ │
│ 1. AUTHENTICATE — validate the Entra (Azure AD) JWT: signature, iss, │
│ aud=https://management.azure.com, exp/nbf │
│ 2. AUTHORIZE — RBAC: union(Actions) − union(NotActions) across all │
│ role assignments at this scope and every ancestor; │
│ then DENY ASSIGNMENTS — a deny always wins │
│ 3. POLICY — Azure Policy: deny / append / modify / audit / │
│ deployIfNotExists evaluated against the request │
│ 4. ROUTE — hand the validated request to the resource provider │
└─────────────────────────────────────────────────────────────────────────┘
│
▼
Resource Provider (Microsoft.Storage) → reconciles desired state, returns 200/201
Idempotency is the load-bearing property. ARM PUT is idempotent by design: the body
describes the desired state of the resource, not a delta. Issue the same PUT twice and
the second one is a no-op convergence to the same state — no duplicate resource, no error.
Formally, if apply is the state transition and s the desired state, then
$$ \text{apply}(\text{apply}(x, s), s) = \text{apply}(x, s) $$
— applying twice equals applying once. This is why Terraform, Bicep, and ARM templates can
re-run safely, why a retried deployment doesn't double your VMs, and why "declarative
infrastructure" works at all. (POST actions — start/stop/regenerate-key — are the
non-idempotent exceptions; they are operations, not state.)
Production significance. When something fails to deploy, the question is which stage of
that pipeline rejected it: a 401 is authentication (token), a 403 is authorization
(RBAC or a deny assignment or a Policy deny), a 409 is a conflict, a 429 is
throttling, and a 5xx from the provider is the service itself. Knowing the pipeline turns a
vague "it won't deploy" into a one-line diagnosis.
Common misconceptions.
- "The portal does special things the API can't." No — the portal is strictly a client of ARM. Anything it does, the API does.
- "
PUTandPOSTare interchangeable." No —PUTis idempotent desired-state;POSTis a non-idempotent action. Retrying aPOSTcan double-execute. - "ARM stores my data." No — ARM manages the resource's existence and configuration (control plane). Your blobs, secrets, and messages live in the data plane (Chapter 3).
Chapter 2: The Resource ID — Azure's Address Space
From zero. Every resource in Azure has exactly one canonical name — a path, like a file path, called the resource ID:
/subscriptions/{subscriptionId}/resourceGroups/{rgName}/providers/{namespace}/{type}/{name}
A concrete example:
/subscriptions/0000…/resourceGroups/rg-prod/providers/Microsoft.Storage/storageAccounts/stprod001
Why it exists. The resource ID is not just a name — it is simultaneously the unit of RBAC scope, Policy scope, tagging, locks, and the audit trail. When you grant a role, assign a policy, place a delete-lock, or read the Activity Log, you do it at a resource ID (or a prefix of one). One address space, reused for everything.
How it works under the hood — the hierarchy and scopes. Read the segments as key/value pairs:
| Segments present | Scope level | Example |
|---|---|---|
subscriptions/{s} | subscription | /subscriptions/0000… |
…/resourceGroups/{rg} | resourceGroup | …/resourceGroups/rg-prod |
…/providers/{ns}/{type}/{name} | resource | …/Microsoft.Storage/storageAccounts/st001 |
Above subscriptions sit management groups (P05), giving the full inheritance chain:
Tenant Root MG ──▶ Management Group ──▶ Subscription ──▶ Resource Group ──▶ Resource
(/providers/Microsoft.Management/managementGroups/{name}) (this is where the ID address space starts)
A role or policy assigned at any node inherits down to every descendant. Assign Reader
at the subscription and the principal can read every RG and resource under it. This is the
single most important fact for reasoning about "who can touch this?": you cannot answer it
by looking at the resource alone — you must look up the chain.
Nested types. Some resources live inside others, and the ID nests the type path:
/subscriptions/0000…/resourceGroups/rg-net
/providers/Microsoft.Network/virtualNetworks/vnet-hub/subnets/snet-app
Here the type is the slash-joined path virtualNetworks/subnets and the name is the
leaf (snet-app), not the parent (vnet-hub). Subnets, blob containers, Key Vault
secrets, and SQL databases are all nested types. Your lab's parse_resource_id must keep
the leaf name and join the type path — exactly the rule production tooling uses.
Resource providers. The {namespace} (Microsoft.Storage, Microsoft.Compute,
Microsoft.Network, …) names the resource provider (RP) — the service that owns those
types and implements their control-plane CRUD. An RP must be registered on a subscription
before its types can be created (az provider register --namespace Microsoft.Storage); the
classic "the subscription is not registered to use namespace X" error is a missing
registration.
Production significance. Parsing and classifying a resource ID is the first move in every governance and incident task: what scope is this, what inherits onto it, and which RP do I escalate to? The lab makes you build that parser so the structure is muscle memory.
Common misconceptions.
- "The resource group's region is where my resource runs." No — the RG region is where the
RG's metadata lives; the resource has its own
location. - "GUIDs in the ID are the names." The subscription is a GUID; resource and RG names are human strings you choose. Don't confuse the two.
- "A subnet's name is the vnet." No — it's the leaf segment after
subnets/.
Chapter 3: Control Plane vs Data Plane
From zero. There are two completely separate systems hiding behind one resource:
- The control plane manages the resource's existence and configuration: create the
storage account, change its SKU, set its firewall, delete it. This is ARM (Chapter 1),
authorized by RBAC
Actions. - The data plane is the stuff inside the resource: the blobs in the account, the
secrets in the vault, the messages in the queue, the rows in the database. This is a
different endpoint (
stprod001.blob.core.windows.net, notmanagement.azure.com), with its own throttling, its own SLA, and its own authorization via RBACDataActions.
Why it exists. Managing a resource and using its data are genuinely different concerns with different blast radii. The person who provisions a storage account (an infra engineer) is often not the person who should read customer blobs (an application). Splitting the authorization lets you grant one without the other.
How it works under the hood. A role definition carries four lists:
Actions — control-plane verbs allowed (e.g. Microsoft.Storage/storageAccounts/read)
NotActions — control-plane verbs subtracted
DataActions — data-plane verbs allowed (e.g. .../blobServices/containers/blobs/read)
NotDataActions — data-plane verbs subtracted
Effective permission is union(Actions) − union(NotActions) for the control plane and the
DataActions equivalent for the data plane — evaluated independently. The canonical trap:
| Role | Control plane (Actions) | Data plane (DataActions) | Can it read a blob? |
|---|---|---|---|
| Owner | * (everything, incl. manage keys) | (none) | No — not directly by Entra identity |
| Storage Blob Data Reader | (none) | …/blobs/read | Yes |
So a storage-account Owner can change the firewall, regenerate keys, and delete the account — but is not automatically allowed to read a blob with their Entra identity. (They can grab the account key — a control-plane action — and use that, which is exactly why key-based access bypasses data-plane RBAC and why principals disable shared-key auth.)
Production significance. A large share of "I'm an Owner but I get 403 Authorization Permission Mismatch" tickets are this exact split: control-plane access, no DataAction.
The fix is to assign a data role (Storage Blob Data Reader/Contributor,
Key Vault Secrets User), not to escalate the control-plane role. Recognising it on sight is
a principal signal.
Common misconceptions.
- "Owner can do anything." On the control plane, yes; on the data plane, only via keys, not via their Entra identity by default.
- "It's the same SLA." No — control plane and data plane publish separate SLAs and have separate throttling limits.
Chapter 4: The Well-Architected Framework — Five Pillars
From zero. The Azure Well-Architected Framework (WAF) is Microsoft's rubric for "is this a good architecture?", expressed as five pillars. It exists because "good" is meaningless without axes, and these are the five every design review scores against:
- Reliability — does it stay up and recover? (SLA, redundancy, failover, retries, no single points of failure). Quantified by the math in Chapter 5.
- Security — is it safe? (identity as the perimeter, least privilege, encryption, network isolation, secrets in Key Vault, deny-by-default).
- Cost Optimization — is the spend justified? (right-sizing, reservations/savings plans, autoscale, scale-to-zero, lifecycle tiering, egress awareness — Chapter 7).
- Operational Excellence — can a team run it? (IaC, CI/CD, monitoring, runbooks, incident response — the pillar that dominates total cost over a system's life).
- Performance Efficiency — does it meet its latency/throughput targets and scale without over-provisioning? (the right SKU, caching, async, partitioning).
Why it exists and why it's a tradeoff model. You cannot maximise all five — they pull against each other:
| You add… | Pillar it helps | Pillar it hurts |
|---|---|---|
| Multi-region active/active | Reliability, Performance | Cost (2× compute + egress), Operational Excellence (more to run) |
| Private Endpoints everywhere | Security | Operational Excellence (DNS complexity — the #1 failure) |
| Aggressive autoscale-to-zero | Cost | Performance (cold starts), Reliability (warm-up gaps) |
| Strict policy-as-code gates | Security, Operational Excellence | velocity (developer friction) |
So the WAF is not a checklist you "pass" — it is a scoring function whose weights come from
the workload. A payments system weights Reliability and Security; an internal batch tool
weights Cost. The principal move is: name the pillar you are trading, quantify both ends,
choose, and write the ADR (Chapter 8). The lab's resolve_tradeoff is this scoring
function, and its headline test proves the consequence: the same two architectures flip
winners when the weights change.
The math the lab uses. Score each architecture $A$ on each pillar $p \in P$ as $s_{A,p} \in [0,1]$, with workload weights $w_p \ge 0$. The weighted, normalised score is
$$ \text{Score}(A) = \sum_{p \in P} s_{A,p} \cdot \frac{w_p}{\sum_{q} w_q} $$
The winner is the higher score; the deciding pillar is the one with the largest weighted advantage for the winner, $\arg\max_p (s_{\text{win},p} - s_{\text{lose},p}) \cdot \frac{w_p}{\sum_q w_q}$ — the one sentence you put in the ADR ("we chose multi-region because reliability dominated for this workload").
Common misconceptions.
- "There's a best architecture." No — there's a best architecture for a given set of weights. Asking "what's the best?" without the workload is a category error.
- "Security and cost are separate teams' problems." No — they are pillars you trade in one design, simultaneously.
Chapter 5: SLA Composition — The Reliability Arithmetic
From zero. A Service Level Agreement (SLA) is a published availability number — App Service 99.95 %, zone-redundant SQL 99.99 %, a single VM with premium SSD 99.9 %. The catch: Azure publishes a number per service, never a composite for your topology. You compose it by hand. This is the single most useful piece of arithmetic in a design review.
Serial dependencies multiply. If a request must traverse several components and any one being down fails the request (a hard dependency chain — web app → SQL → Key Vault), the availabilities multiply:
$$ A_{\text{serial}} = \prod_{i} A_i $$
Three 99.9 % services in series:
$$ 0.999 \times 0.999 \times 0.999 = 0.999^3 \approx 0.997002999 $$
— roughly 99.7 %, about 3× the downtime of a single component. Every hop you add to the request path lowers the composite SLA. This is the quantitative reason "just add another microservice" is never free.
Redundant copies combine. If you have several interchangeable copies and the system is down only when all of them are down (active/active across zones), the un-availabilities multiply:
$$ A_{\text{redundant}} = 1 - \prod_{i} (1 - A_i) $$
Two 99 % copies:
$$ 1 - (1 - 0.99)^2 = 1 - 0.01^2 = 1 - 0.0001 = 0.9999 $$
— two cheap 99 % things give you 99.99 %. Redundancy is how you buy nines.
Nines → minutes (the number that ends the argument). Convert availability to downtime per month (30-day month = 43,200 minutes):
$$ \text{downtime}_{\text{min/month}} = (1 - A) \times 43{,}200 $$
| Availability | Downtime / month | Downtime / year |
|---|---|---|
| 99 % (two nines) | ~432 min (7.2 h) | ~3.65 days |
| 99.9 % (three nines) | ~43.2 min | ~8.76 h |
| 99.95 % | ~21.6 min | ~4.38 h |
| 99.99 % (four nines) | ~4.32 min | ~52.6 min |
| 99.999 % (five nines) | ~0.43 min | ~5.26 min |
The leap from three to four nines is not a config flag — it's multi-AZ or multi-region, a different architecture and a different bill. Knowing the minutes makes the conversation concrete: "you're asking for four nines; that's 4 minutes a month; here's the redundancy that buys it and here's what it costs."
Production significance. Before you commit to an SLA in a contract, you compose it: list the serial path, multiply, then decide which links need a redundant pair to hit the target. The lab makes you compute exactly this.
Common misconceptions.
- "My SLA is the weakest component's SLA." No — serial is the product, which is worse than the weakest link.
- "Redundant copies are independent." Rarely fully — a shared region, shared dependency, or
correlated failure breaks independence, so
1 − ∏(1 − Aᵢ)is an upper bound. Say so in the interview; it's the honest, principal-level caveat (and the lab's extension). - "99.9 % and 99.99 % are basically the same." They are 43 minutes vs 4 minutes a month — a 10× difference and usually a different architecture.
Chapter 6: Availability Zones & Paired Regions
From zero. The redundant-SLA math in Chapter 5 only helps if you have somewhere to put the copies that fails independently. Azure gives you three tiers of blast-radius isolation:
- Availability Zone (AZ) — physically separate datacenter(s) within one region, with independent power, cooling, and network. Spreading replicas across ≥2 zones survives a datacenter-level failure and is the cheapest way to add the redundant term (the cross-zone latency is sub-millisecond; egress between zones in-region is cheap or free for many services). This is how you go from 99.9 % to 99.99 % within one region.
- Region — a geography (East US, West Europe). Multi-region active/active survives an entire region outage but adds full cross-region egress cost and replication complexity — the most expensive reliability you can buy.
- Paired region — most Azure regions have a designated pair in the same geography with platform-coordinated maintenance (the pair isn't updated simultaneously) and, for some services, built-in geo-replication for disaster recovery (e.g. GRS storage, geo-redundant backups). The pair is your DR target, not necessarily your active/active target.
How it maps to the math. "Zone-redundant" services apply Chapter 5's redundant formula for you across zones and publish the higher composite (e.g. zone-redundant SQL 99.99 % vs 99.9 % single-zone). When you go multi-region yourself, you apply the formula — and you discount it for correlation, because a region pair shares a geography and some platform fate.
Production significance. The reliability ladder is: single instance → multi-AZ (zone-redundant) → multi-region active/passive (failover) → multi-region active/active. Each rung adds a redundant term and a cost/operability term. The principal picks the lowest rung that hits the workload's downtime target, because every rung above it is paid for forever.
Common misconception. "Multi-region is always more reliable than multi-AZ." For most workloads multi-AZ already hits four nines at a fraction of the cost and complexity; multi-region buys you region-outage survival specifically, and you should only pay for it if a region outage is in your threat model and your data layer can actually replicate that far without breaking consistency.
Chapter 7: Cost Modeling and "Spend the Headroom"
From zero. You cannot be the cost-optimization person WAF asks for without arithmetic. The first-order monthly bill has three terms, and most real bills are dominated by one of them:
$$ \text{cost}{\text{month}} = \underbrace{H \cdot p{\text{vcpu}}}{\text{compute}} + \underbrace{G_s \cdot p{\text{storage}}}{\text{storage}} + \underbrace{G_e \cdot p{\text{egress}}}_{\text{egress}} $$
where $H$ = compute (vCPU-)hours, $G_s$ = stored GB-months, $G_e$ = egress GB, and the prices approximate Azure list ($p_{\text{storage}}\approx$0.018$/GB-month LRS hot, $p_{\text{egress}}\approx$0.087$/GB internet after the free tier).
The terms to watch.
- Compute is usually the headline, and the levers are right-sizing the SKU, reserved instances / savings plans (up to ~72 % off for a 1–3 year commit), autoscale, and scale-to-zero (Consumption Functions/Container Apps) — trading cold-start latency for near-zero idle cost.
- Storage is cheap per GB but accumulates; lifecycle tiering (hot → cool → archive) and not hoarding redundant copies (LRS vs GRS vs ZRS is a 1–2× multiplier) are the levers.
- Egress is the ambush. Ingress is free; egress (data leaving Azure, or crossing regions) is billed per GB and is what makes chatty multi-region designs quietly expensive. A cross-region call pattern can make egress dominate a bill that "looked like compute."
"Spend the headroom." This is the principal mindset that separates cost-aware design from cheese-paring. If your reliability target is met with headroom, don't buy more nines. If your latency SLA is 15 minutes and you consume 40 seconds, the other ~14 minutes is a cost lever — batch writes harder, use spot/low-priority compute, compact lazily, scale to zero. Juniors optimise the dimension they can see; principals find the unused headroom in one pillar and convert it into savings in another. The same logic runs in reverse: if you're over budget, look for the pillar you're over-delivering on and trade it down.
Production significance. "Make it cheaper" becomes concrete the moment you (a) tag every resource with an owner so the bill is a map, and (b) write the three-term model so you can see which term to attack. Optimising compute when egress dominates is the classic wasted sprint.
Common misconceptions.
- "Storage is the big cost." Usually compute is, then sometimes egress; raw storage is rarely the headline (small files and request costs aside).
- "Reserved instances lock me in dangerously." They commit spend, not a specific VM; savings plans are even more flexible. For steady-state baseline load, not reserving is leaving 30–70 % on the table.
- "Cheaper architecture = better." Only if operability holds — an architecture that needs a dedicated on-call team is not cheaper.
Chapter 8: The ADR — Making a Tradeoff Durable
From zero. A principal's decisions outlive their memory of them and must survive their absence. The Architecture Decision Record (ADR) is the one-page artifact that makes a tradeoff reviewable, teachable, and reversible-on-purpose. It is the output of everything above — you do the SLA math and the cost model, then you record the call.
The template you'll reuse all track:
# ADR-NNN: <short title>
Status: proposed | accepted | superseded by ADR-MMM
Date: YYYY-MM-DD
## Context
The workload's requirements as numbers on the five pillars: target SLA (and the
composed math), budget, latency target, security/compliance constraints, who operates it.
## Decision
What we chose, in one sentence — and the DECIDING PILLAR.
## Alternatives Considered
Each option, and the pillar it traded the wrong way for OUR weights.
## Consequences
What gets better, what gets worse, what we must now monitor, and the number (or
event) that would make us revisit this.
A worked one-liner the lab can emit: "For the payments workload (weights: reliability 4, security 2, cost 1), we chose multi-region active/active over single-region because reliability dominated — composing to 99.99 % (4.3 min/month) vs the single region's 99.9 % (43 min/month). We accept ~2× compute + egress cost and higher operational load; we revisit if a minute of downtime costs less than $X or if the data layer can't sustain cross-region consistency."
Why it matters for your career. An architecture review (interview Round 5, and your day job) is a room full of people pressure-testing your ADRs. The discipline the ADR enforces is exactly Chapter 4's: name the pillar, quantify both ends, choose, and write down what would change your mind. The person whose decisions are legible gets trusted with bigger ones.
Common misconception. "ADRs are bureaucratic overhead." A good ADR is one page and takes ten minutes; it saves the hour-long re-litigation six months later when someone asks "why did we do it this way?" — and you point at the numbers instead of arguing from memory.
Lab Walkthrough Guidance
Lab 01 — WAF Tradeoff Modeler, suggested order (matches the file top-to-bottom):
ResourceId+parse_resource_id(Ch. 1–2). Split on/, strip the leading slash, reject empty/odd segments, then readsubscriptions/{s}→ optionalresourceGroups/{rg}→ optionalproviders/{ns}/{type}/{name}[/{type2}/{name2}…]. For nested types, join the type segments with/and keep the leaf name. RaiseValueErroron every malformed shape — interviewers will hand you a bad ID to see if you validate.resource_scope_level(Ch. 2). Parse, then classify by the deepest field set: provider →resource; resource_group →resourceGroup; elsesubscription.- SLA functions (Ch. 5):
serial_sla(product),redundant_sla(1 − ∏(1 − aᵢ)),downtime_minutes_per_month((1 − A) × 43200). Validate each availability in[0, 1]. The exact-number tests (0.999³ ≈ 0.997002999, two0.99→0.9999) are the boundary probes. monthly_cost_usd(Ch. 7): three terms, all inputs non-negative.Architecture.__post_init__(Ch. 4): all five pillars present, none unknown, each in[0, 1].resolve_tradeoff(Ch. 4): weight-normalised score per architecture; winner + deciding pillar; tie withintol. Maketest_weights_flip_the_winnerpass — and make sure you understand why it's the point of the whole lab.
Success Criteria
You are ready for Phase 01 when you can, from memory:
- Draw the control-plane request pipeline (Entra authenticate → ARM authorize via RBAC,
deny-wins → Policy → resource provider) and say which stage a
401/403/409/429came from. - Explain why every ARM write is an idempotent
PUTto a resource ID, and what idempotency buys IaC. - Parse any resource ID into subscription / RG / provider / type / name (including a nested type) and name its scope and what inherits onto it.
- State why a storage
Ownercannot read a blob with their Entra identity, and the fix. - List the five WAF pillars and give a real tradeoff between two of them.
- Compose a serial and a redundant SLA in your head and convert it to minutes/month.
- Write the three-term cost model and name which term ambushes multi-region designs.
- Write a one-page ADR that names the pillar, quantifies both ends, and states what would change your mind.
Interview Q&A
Q: Walk me through what happens between az storage account create and the account
existing.
The CLI acquires an Entra access token (audience https://management.azure.com) and issues
an idempotent PUT to the resource ID /subscriptions/…/providers/Microsoft.Storage/ storageAccounts/{name}?api-version=…. ARM authenticates the token (signature, iss, aud,
exp), authorizes via RBAC — union(Actions) − union(NotActions) across every assignment at
this scope and its ancestors, then deny assignments where deny wins — evaluates Azure
Policy (a deny effect blocks the write here), and routes the validated request to the
Microsoft.Storage resource provider, which reconciles desired state and returns 201/200.
Re-issuing the same PUT converges with no duplicate — that's the idempotency the whole IaC
model rests on.
Q: I'm an Owner on a storage account but I get 403 reading a blob. Why?
Control plane vs data plane. Owner grants Actions: * — manage the account, set its
firewall, regenerate keys — but no DataActions, so your Entra identity can't read blob
data directly. The fix isn't a bigger control-plane role; it's a data role like
Storage Blob Data Reader. (You could pull the account key — a control-plane action — and
use shared-key auth, which bypasses data-plane RBAC; that's exactly why hardened
environments disable shared-key access and rely on DataActions + Managed Identity.)
Q: A team wants 99.99 % availability. How do you respond?
First I make it concrete: 99.99 % is 4.32 minutes of downtime a month versus 99.9 %'s 43
minutes — a 10× ask. Then I compose their actual topology: if the request path is web → SQL
→ Key Vault, that's serial, so the composite is the product — three 99.9 % services already
fall to ~99.7 %, worse than the target, before we add redundancy. So I find which links
need a redundant pair (zone-redundant SQL gets that link to 99.99 % via 1 − ∏(1 − Aᵢ)),
price the multi-AZ option, and only reach for multi-region if a region outage is in scope —
because that's the most expensive nine and it adds egress and operational load. I write the
ADR with the composed number and the cost.
Q: How do you make a "make it cheaper" mandate concrete? Attribute first — tag every resource with an owner so the bill becomes a map — then write the three-term model (compute + storage + egress) to see which term dominates. The levers are mechanical per term: compute falls with right-sizing + reservations/savings plans + autoscale + scale-to-zero; storage with lifecycle tiering and the right redundancy SKU; egress by killing chatty cross-region patterns. Crucially I hunt for unused headroom — if the SLA or latency target has slack, I spend it: batch harder, use spot, compact lazily. Most "expensive" systems are over-delivering on a pillar nobody is paying for.
Q: Why is "what's the best architecture?" a bad question? Because "best" is undefined without the workload's pillar weights. The Well-Architected pillars conflict — multi-region buys reliability and costs you cost and operability — so the same two designs genuinely flip winners when the weights change (my lab proves this with a test). The right question is "best for these requirements," and the principal's job is to extract those requirements as numbers, score the options, and record the deciding pillar in an ADR. Anyone who answers "best architecture" without asking about the workload is reciting patterns, not engineering.
Q: Difference between a senior and a principal Azure engineer, in one sentence? A senior deploys a correct architecture for a given design; a principal defines the design by composing the SLA, modeling the cost, naming the pillar being traded, and recording the decision in an ADR — so hundreds of other engineers inherit correct tradeoffs by default.
References
- Azure Resource Manager overview — the control plane, request pipeline, resource providers.
- Resource ID & scopes and Understand scope — the address space and inheritance.
- Control plane and data plane operations — the split this phase hammers.
- Azure Well-Architected Framework — the five pillars and the design-review methodology.
- WAF Reliability pillar and target metrics / availability math — SLA composition and downtime targets.
- Azure SLAs for Online Services — the published per-service numbers you compose by hand.
- Availability zones & region pairs and cross-region replication — the redundancy you buy nines with.
- Azure pricing calculator and bandwidth (egress) pricing — the cost model's real numbers.
- Michael Nygard, Documenting Architecture Decisions (2011) — the ADR pattern.
- The track's own CHEATSHEET.md and GLOSSARY.md — the running references.
🛸 Hitchhiker's Guide — Phase 00: The Azure Principal
Read this if: you can click "Create resource" in the portal but "architect our Azure platform and defend it in a design review" still sounds like a different job. It is. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP.
0. The 30-second mental model
Everything you do to Azure is one idempotent PUT to a resource ID through one control
plane (ARM) — authenticate (Entra), authorize (RBAC, then deny wins), validate (Policy),
route to the provider. And every design is a tradeoff across five Well-Architected
pillars that you settle with numbers (SLA math, the bill) and record in an ADR. One
sentence to tattoo: you don't click resources, you compose tradeoffs on a single control
plane — and "best architecture" is meaningless without the workload's weights.
1. The control-plane request, one breath
client (portal / az / Terraform) ── PUT /subscriptions/.../{type}/{name} + Bearer token ──▶ ARM
ARM: authenticate (Entra JWT) → authorize (RBAC − NotActions, then DENY wins)
→ Policy (deny/append/modify/audit) → resource provider (idempotent PUT)
A 401 is auth, a 403 is RBAC/deny/Policy, a 409 is conflict, a 429 is throttling, a
5xx is the provider. That mapping turns "it won't deploy" into a one-line diagnosis.
2. The resource ID is the whole address space
/subscriptions/{sub}/resourceGroups/{rg}/providers/{namespace}/{type}/{name}
It's simultaneously the RBAC scope, Policy scope, tag target, lock target, and audit key.
Scopes nest MG → Subscription → RG → resource, and assignments inherit downward — so
you can never answer "who can touch this?" from the resource alone; look up the chain.
Nested types: …/virtualNetworks/vnet/subnets/snet → type is virtualNetworks/subnets, name
is the leaf snet.
3. Control plane ≠ data plane (the trap)
| Control plane | Data plane | |
|---|---|---|
| Endpoint | management.azure.com | stX.blob.core.windows.net |
| Authorized by | RBAC Actions | RBAC DataActions |
| Example role | Owner (manage the account) | Storage Blob Data Reader (read a blob) |
Owner ≠ Blob Data Reader. A storage Owner can delete the account but can't read a blob | ||
| with their Entra identity. Half of "I'm Owner but I get 403" tickets are this. |
4. The numbers to tattoo on your arm
| Thing | Number |
|---|---|
| Minutes in a month (SLA math) | 43,200 (30-day convention) |
| 99.9 % downtime | ~43.2 min/month |
| 99.99 % downtime | ~4.32 min/month |
| Three 99.9 % serial | 0.999³ ≈ 99.7 % (≈3× the downtime) |
| Two 99 % redundant | 1 − 0.01² = 99.99 % |
| Serial SLA | ∏ Aᵢ (multiply — more hops, lower) |
| Redundant SLA | 1 − ∏(1 − Aᵢ) (combine — redundancy raises it) |
| Internet egress | ~$0.087/GB (ingress free — egress is the ambush) |
| LRS hot blob storage | ~$0.018/GB-month |
| Reserved instance / savings plan | up to ~72 % off steady-state compute |
| MG tree max depth | 6 levels under root |
5. The five pillars (score every design on these)
Reliability · Security · Cost Optimization · Operational Excellence · Performance Efficiency
You can't max all five. Name the pillar you're trading, quantify both ends, choose, write the ADR. Multi-region buys Reliability + Performance, costs Cost + Operational Excellence. Scale-to-zero buys Cost, costs Performance (cold start). There is no "best architecture" — only "best for these weights."
6. The reliability ladder (buy the lowest rung that hits the target)
single instance → multi-AZ (zone-redundant) → multi-region active/passive → active/active
99.9% 99.99% (cheap nines) region-outage survival most expensive nine
Multi-AZ already hits four nines cheaply; multi-region is for region-outage survival specifically — and you discount the redundant-SLA formula for correlation (copies aren't truly independent).
7. az one-liners worth muscle memory
az account show --query id -o tsv # current subscription id
az resource list --query "[].id" -o tsv # every resource id (the address space)
az resource show --ids "<resource-id>" # round-trip a parsed id
az role assignment list --scope "<resource-id>" -o table # who can touch this (at this scope)
az role assignment create --assignee <oid> --role "Storage Blob Data Reader" \
--scope "<account-id>" # grant a DATA role, not Owner
az provider register --namespace Microsoft.Storage # fix "not registered to use namespace"
az policy assignment list --scope "<scope-id>" -o table # what policy gates this scope
8. War-story shapes you'll relive
- "I'm Owner but I get 403 on the blob." → control-plane role, no
DataAction. Assign a data role; don't escalate to a bigger control-plane role. - "Our 'three nines' SLA isn't being met." → the path is serial, so the composite is the product (≈99.7 %), not 99.9 %. Add a redundant pair on the weak link, not more retries.
- "The bill doubled and nobody added VMs." → cross-region/egress traffic. Ingress is free; egress isn't. Look at the bandwidth line, not compute.
- "Multi-region is down even though we have two regions." → the copies weren't independent
(shared dependency / paired-region fate). The
1 − ∏(1 − Aᵢ)number was an upper bound. - "The subscription isn't registered to use namespace Microsoft.X." → a resource provider that was never registered. One command fixes it.
9. Vocabulary that signals you've held the pager
- Control plane / data plane — manage the resource vs use its data; different identity, throttling, SLA.
- Idempotent
PUT— desired-state write that converges, not duplicates; the basis of IaC. - Resource ID / scope — the address that is the RBAC/Policy unit; inheritance flows down.
- Deny wins — a deny assignment overrides any allow; RBAC is allow-additive otherwise.
- Serial vs redundant SLA — multiply vs
1 − ∏(1 − Aᵢ); the only two SLA operations. - Deciding pillar — the one-sentence reason a tradeoff went the way it did; goes in the ADR.
- Spend the headroom — convert unused reliability/latency margin into cost savings.
- ADR — the durable record of a decision and what would reverse it.
10. Beginner mistakes that mark you in interviews
- Saying "best architecture" without asking the workload's pillar weights.
- Thinking
Ownercan read data — forgetting the control/data-plane split. - Quoting an SLA as the weakest link's number instead of the product of the serial path.
- Saying "99.9 % vs 99.99 %, basically the same" — it's 43 vs 4 minutes, a 10× ask and a different architecture.
- Optimising compute when egress dominates the bill (optimise the term that's actually big).
- Treating multi-region as "free reliability" instead of the most expensive, correlation- discounted nine.
- Making decisions in chat instead of ADRs — so they're re-litigated every quarter.
11. How this phase pays off later
- The control-plane request is P01–P05's spine (ARM deploy graph, Terraform plan, RBAC, Policy, landing zones all live on it).
- The resource ID + scope model is exactly how P04/P05 reason about inheritance.
- The control/data-plane split recurs in P09 (API auth), P10 (Service Bus), P12 (Key Vault).
- The SLA + cost math is Round-1 mental arithmetic and the backbone of P14 (reliability) and P15 (FinOps capstone).
- The ADR habit is Round-5 (architecture leadership) and your day job as a principal.
Now read the WARMUP slowly, then build the modeler. You'll use its functions as back-of-envelope checks for the rest of the track.
👨🏻 Brother Talk — Phase 00, Off the Record
No slides, no certification rubric. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. You've probably been told that becoming a principal Azure engineer means collecting certifications — AZ-104, then 204, then 305, 500, 700 — and then you're "principal." It doesn't work like that. I've watched people with the full alphabet of badges still get stuck at senior, because nobody told them the secret:
The portal is the easy part. The judgment is the job.
Here's the thing that took me too long to learn. When a director says "architect our Azure
platform," they are not testing whether you know which blade to click or which az flag to
pass. They assume you can find the button. What they're watching is: do you compose the SLA
before you promise four nines? Do you say "that's 4 minutes a month, here's the multi-AZ that
buys it and here's the bill"? Do you name the pillar you're trading instead of hand-waving
"it'll be reliable and cheap"? That move — pricing a tradeoff out loud — is the whole game.
Everyone else in the room is reciting service names. You're doing engineering.
So let me give you the mindset shifts, brother to brother.
Internalise that there's one front door, and it changes everything. The single most
freeing realization in this whole track is that every Azure write — VM, role assignment,
policy, private endpoint — is the same shape: an idempotent PUT to a resource ID through
ARM. Once you hold that, the 15 services ahead stop being 15 manuals. Each one is just "what
does this provider do after ARM hands it the request, and what's its data plane?" People who
never grasp this learn Azure as a thousand disconnected portal recipes and drown. People who
grasp it learn one system with fifteen plugins. Be the second kind. That's what this phase is
for.
Stop saying "highly available" and "scalable." I mean it — those words are banned for you now. Every time you want to say "highly available," say a number: "99.99 %, which is 4.32 minutes a month, composed from a zone-redundant data layer." Every time you want to say "scalable," say "sized to 6× average, sheds load via the rate limiter past that." The moment you do this you sound three levels more senior, because you've revealed that availability is a budget you compose, not an adjective you sprinkle. A junior wants everything multi-region. A principal knows multi-region is the most expensive nine on the menu and orders it only where a region outage is actually in the threat model.
Fall in love with the boring pillar: operational excellence. Everyone optimizes performance and reliability because they benchmark well and demo nicely. Nobody brags about "this is easy to debug at 2 a.m." But operability is the pillar that decides whether your platform survives, because a system runs for years and gets debugged thousands of times. The "elegant" private-endpoint-everywhere design that takes a DNS PhD to troubleshoot is a liability. The boring design any on-call can reason about is an asset. Choose boring on purpose — it's a flex, not a compromise. (And when the WAF review asks you to score it, the boring one quietly wins the operational-excellence pillar that everyone else forgot to weight.)
Learn the control-plane/data-plane split until it's reflex. This is the one that makes you look senior in seconds. The moment someone says "I'm an Owner but I'm getting 403 on the blob," and you say "right, Owner is control plane — you need a data role like Storage Blob Data Reader, or you're falling back to the account key" — you've just diagnosed in one breath what others debug for an hour. It's not because you're smarter. It's because you hold one distinction they never internalized. Collect distinctions like that. They're the whole signal.
Write the ADR even when nobody asks. Here's a career hack that feels like overhead and pays off enormously: when you make a real architecture call, write the one-pager — context as numbers on the five pillars, the decision, the alternatives, the deciding pillar, and what would change your mind. Do it even for decisions nobody requested documentation for. Six months later when someone asks "why are we single-region?", you don't defend it from memory and emotion — you point at the doc and say "here was the composed SLA and the cost; have the numbers changed?" You become the person whose decisions are legible. That person gets trusted with bigger decisions. That's the ladder, and it's quieter and surer than chasing another badge.
The honest truth about this role's difficulty: it's hard because it's wide, not because any one piece is deep beyond reach. Nobody is born knowing ARM and Entra and networking and Service Bus and Durable Functions. The people who got here didn't have bigger brains; they had a framework — one control plane, one address space, five pillars, two SLA formulas, a three-term bill — that let them absorb each new service as "oh, this is the messaging slot, here's its data plane, here's how it scores on the pillars." That framework is this entire phase. Internalize it and the other 15 phases stop being 15 unrelated services and become one platform you already understand.
One more thing, and it's the most important. You don't have to be the smartest person in the room to be the principal in the room. You have to be the one who turns vague fear — "will this scale? is this secure? what will it cost?" — into specific, answerable questions with numbers attached. Calm comes from numbers. When the room is panicking about "can this survive a region outage," and you say "the data layer can't replicate that far without breaking consistency, so active/active is off the table; here's the multi-AZ that gets us 99.99 % and here's the egress cost if we want DR to the paired region" — you're not smarter than them. You just did the arithmetic they were too anxious to do. That's leadership in this job. It's learnable. It starts with the lab in this phase.
Go build the modeler. Then come find me in Phase 01 — that's where we make the deployment graph and idempotency click, the stuff everyone name-drops and quietly doesn't understand.
— your brother 👨🏻
Lab 01 — WAF Tradeoff Modeler
Phase: 00 — The Azure Principal: Control Plane, Well-Architected & Tradeoffs Difficulty: ⭐⭐☆☆☆ (the arithmetic is easy; the judgment is ⭐⭐⭐⭐⭐) Time: 3–4 hours
Round-1 of an Azure architecture interview — and every design review you will ever run — is back-of-envelope arithmetic worn as architecture. This lab builds the calculator: parse an ARM resource ID and classify its scope, compose SLAs (serial multiply, redundant combine), turn availability into downtime minutes/month, model a monthly bill, and run a weighted Well-Architected tradeoff resolver whose entire point is that the same two architectures flip winners when the workload's pillar weights change — which is why "best architecture" is a meaningless phrase.
What you build
parse_resource_id/ResourceId— turn the canonical ARM URI/subscriptions/{s}/resourceGroups/{rg}/providers/{ns}/{type}/{name}into a frozen dataclass; support subscription-, resource-group-, and resource-level IDs and nested types (virtualNetworks/vnet/subnets/sub); reject malformed input withValueError.resource_scope_level— classify any ID assubscription/resourceGroup/resource— the unit of RBAC, Policy, tagging, and locks, and the first move in every "who can touch this?" question.serial_sla/redundant_sla/downtime_minutes_per_month— the SLA arithmetic a principal does before the design review: serial dependencies multiply (∏ Aᵢ), redundant copies combine (1 − ∏(1 − Aᵢ)), and(1 − A) × 43200is the minutes/month that make a nines-target real.monthly_cost_usd— a first-order bill (compute hours + storage GB-month + egress GB) so you can see which term dominates before you optimise the wrong one.Architecture(five WAF pillars) +resolve_tradeoff— score two designs under the workload's weights and return the winner and the deciding pillar (the one sentence for your ADR).
Key concepts
| Concept | What to understand |
|---|---|
| ARM is the one control plane | every write is an idempotent PUT to a resource ID; the ID is the address, the RBAC scope, the Policy scope |
| Scope inheritance | a role/policy at MG → Sub → RG → resource flows down; the scope of an ID decides who already has access |
| Control plane ≠ data plane | Owner (manage the storage account) ≠ Blob Data Reader (read a blob) — different Actions vs DataActions |
| Serial multiplies | more hops in the request path → strictly lower composite SLA; adding a microservice is never free |
| Redundancy combines | 1 − ∏(1 − Aᵢ) — zones/regions raise the SLA, and the jump from three to four nines is an architecture, not a flag |
| Downtime makes it real | 99.9 % = 43.2 min/month; 99.99 % = 4.32 — the number that ends the argument |
| Weights decide the winner | "best architecture" is a category error; best for these requirements is engineering |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers — your implementation |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All tests pass against your implementation (and
LAB_MODULE=solution). - You can explain why
parse_resource_idrejects an odd number of path segments (ARM IDs are key/value pairs — a dangling key has no value). - You can explain why a nested ID's
resource_typeisvirtualNetworks/subnetswhile itsnameis the leaf (snet-app), not the parent vnet. - You can explain why
test_serial_sla_exactasserts three 99.9 % services compose to 0.997002999 (≈ 3× the downtime of one), and why two 99 % redundant copies give 0.9999. - You can explain why
test_weights_flip_the_winneris the whole point of the lab. - Given any "design X on Azure" prompt you can, in two minutes, name the dominant pillar, write a serial+redundant SLA estimate, and a monthly bill on paper.
How this maps to real Azure
| The miniature | The production mechanism | Where to verify it |
|---|---|---|
parse_resource_id | The ARM resource ID is the literal addressing scheme for every resource; az resource show --ids <id> and every REST URI use it. Nested types (subnets, containers, secrets) are real. | az resource list --query "[].id" -o tsv |
resource_scope_level | The scope string is exactly what you pass to az role assignment create --scope <id> and az policy assignment create --scope <id>; assignments inherit down the hierarchy. | az role assignment list --scope <id> |
serial_sla / redundant_sla | Azure publishes per-service SLAs (e.g. App Service 99.95 %, zone-redundant SQL 99.99 %); you compose them by hand because Azure does not publish a composite for your topology. | Azure SLAs |
downtime_minutes_per_month | The "nines → minutes" table every reliability review uses; 43,200 = 30-day-month minutes. | WAF Reliability pillar |
monthly_cost_usd | The shape of a real bill: compute (vCPU-hours), storage (GB-month, e.g. | Azure Pricing Calculator |
resolve_tradeoff | A scored Well-Architected review: each pillar weighted by the workload, with the deciding pillar written into an ADR. | Azure Well-Architected Framework |
Limits of the miniature (be honest in the interview): availabilities are not
independent (a region outage takes correlated copies down together, so redundant_sla
over-estimates real multi-region availability); real bills add reserved-instance and
savings-plan discounts, request/transaction costs, and tiered egress; and a WAF score is a
structured conversation, not a number you can sum mechanically across every workload.
Extensions (build these on your own subscription)
- Add
availability_zone_sla(component_sla, zones, correlation)that introduces a correlation factor so redundant copies aren't treated as fully independent — the honest version of the multi-region math. - Add
egress_cost_tiered(gb)with Azure's banded egress pricing (first 100 GB free, then banded) and show how a chatty cross-region call pattern dominates the bill. - Extend
resolve_tradeoffto N architectures returning a ranked list with the pairwise deciding pillar — then defend the ranking against a friend who changes the weights. - Write the matching ARM/Bicep: a
Microsoft.Network/virtualNetworks/subnetsresource and confirmaz resource show --idsreturns the same nested ID your parser produced. - Emit an ADR from a
TradeoffResult(context = the weights, decision = the winner, consequence = the pillar you traded away) using the template inWARMUP.mdCh. 7.
Interview / resume
- Talking points: "Walk me through what happens between
az ... createand the resource existing." "Why isOwnernot enough to read a blob?" "Compose the SLA of a web app calling SQL calling Key Vault — show me the number." "Defend single-region over multi-region with numbers." - Resume bullet: Built a Well-Architected decision-support model that parses ARM resource-ID scopes, composes serial/redundant SLAs into downtime budgets, estimates monthly cost, and produces explainable, weight-driven architecture tradeoffs for design reviews and ADRs.
Phase 01 — Azure Resource Manager & the Deployment Engine
Difficulty: ⭐⭐⭐☆☆ (the algorithm) → ⭐⭐⭐⭐⭐ (the judgment about modes, idempotency, and async) Estimated Time: 1 week (12–18 hours) Prerequisites: Phase 00 (control plane vs data plane, the resource-ID shape, the Well-Architected lens). Comfortable reading JSON. No live Azure subscription required for the lab.
Why This Phase Exists
Phase 00 established the single most important sentence in this entire track: every
write in Azure is one idempotent PUT to a resource ID, routed through Azure Resource
Manager. This phase opens that sentence up and builds the machine inside it.
Here is the thing almost nobody can actually explain in an interview, even people who've
used Azure for years: when you click "Create" in the portal, or run terraform apply,
or az deployment group create, what literally happens? They can name the services. They
cannot draw the request pipeline (authenticate → authorize → validate → route to the
resource provider), they cannot explain how ARM knows to build the virtual network
before the network interface that sits in it, and they go pale when you ask why
re-running the same deployment is safe but --mode Complete once deleted their
production database.
That last one is not hypothetical. The Complete-mode story is a real, recurring outage shape: an engineer deploys a template that's missing a resource someone added by hand, in Complete mode, and ARM — doing exactly what it was told — tears that resource down to make the resource group match the template. The blast radius is "everything in the RG not in this file." Understanding why that's the defined behavior (not a bug) is the difference between fearing the tool and wielding it.
So this phase makes you build the deployment engine — the dependency graph, the topological scheduler, the idempotent PUT, the mode logic, the what-if diff — because once you've implemented the algorithm, ARM stops being magic and Terraform's plan engine (Phase 02), CloudFormation, Pulumi, and Bicep all reveal themselves as the same graph algorithm with different front-end syntax. This is the keystone phase for all of IaC.
What "Principal-Level" Means Here
A senior engineer writes a working ARM/Bicep template and runs the deploy. A principal understands the engine well enough to:
- Predict the deploy order from the template without running it — by reading the
implicit
reference()/resourceId()edges, not just thedependsOnthey typed. - Debug a stuck or failed deployment by reasoning about
provisioningState, the async-operation poll, and which resource in the graph is blocking its dependents. - Choose the deployment mode deliberately and explain the blast radius of each — and never reach for Complete mode without a reason and a what-if.
- Defend idempotency as an architecture property: why CI can re-run the same template
on every commit, why drift converges, and where idempotency quietly breaks (a resource
with a generated name, a
reference()to something deleted out-of-band). - Read a
what-ifdiff like a code review and catch the Delete nobody intended before it ships.
Concepts
- The ARM control-plane request pipeline — every write is a REST call to
…/providers/{ns}/{type}/{name}?api-version=…; ARM authenticates it (validates the Entra JWT), authorizes it (RBACActions − NotActions, then deny assignments win), validates it (Azure Policy candeny/modify/append), and routes it to the resource provider that owns the type. Get this pipeline cold; it's the spine of P03, P04, and P05. api-version— every RP exposes a dated contract; the request must name one, and the version pins the request/response schema. New properties only exist on new versions.- Asynchronous operations — most provisioning is long-running: ARM returns
201/202with aprovisioningState(Accepted → Running → Succeeded|Failed) and anAzure-AsyncOperation/LocationURL the client polls until terminal. "The deploy is done" means the poll reachedSucceeded, not that the PUT returned. - ARM template anatomy —
$schema,contentVersion,parameters,variables,resources,outputs, and the template functions (resourceId(),reference(),concat(),parameters(),[...]expressions) the engine evaluates at deploy time. - The dependency graph — the heart of it. Edges come from explicit
dependsOnand the implicit dependencies ARM infers when one resource's propertyreference()s orresourceId()s another. ARM topologically orders the graph, deploys independent resources in parallel, and detects cycles. - Idempotency —
PUTof desired state (not an imperative command) means re-applying converges: Create if absent, Modify if drifted, NoChange if identical. This is the property the entire IaC discipline stands on. - Deployment modes — Incremental (default; adds/updates, leaves un-templated resources alone) vs Complete (makes the RG match the template exactly — deletes the rest). The foot-gun, and the cleanup tool.
- What-if — the dry-run diff that classifies every resource as
Create/Modify/Delete/NoChange/Ignorewithout touching anything. Your PR-time safety net. - Bicep — a typed DSL that transpiles to ARM JSON; same engine, symbolic
references (so dependencies are inferred from
vnet.id, not hand-writtendependsOn), modules, loops, and far less ceremony. - Deployment stacks & deny-settings — a stack manages a set of resources as a unit, with managed deletion (drop a resource from the stack → it's deleted) and deny-settings that block out-of-band changes (drift protection at the control plane).
Labs
Lab 01 — ARM Deployment Engine (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build a miniature ARM deployment engine: extract explicit + implicit dependencies from a resource, build the dependency graph, topologically order it deterministically with cycle detection, apply the template as an idempotent PUT (Create/Modify/NoChange), implement the Complete-mode delete, and produce a non-mutating what-if diff |
| Concepts | Implicit vs explicit dependencies; Kahn's topological sort with deterministic tie-break; cycle detection; idempotent desired-state convergence; Incremental vs Complete blast radius; read-only dry run |
| Steps | 1. parse reference()/resourceId() out of (nested) property strings; 2. union with dependsOn → extract_dependencies; 3. build_dependency_graph with unknown-reference + duplicate-name validation; 4. deterministic topological_order (Kahn's, tie-break by name, raise on cycle); 5. deploy with idempotent PUT + mode logic; 6. side-effect-free what_if |
| How to Test | pytest test_lab.py -v — 25 tests covering implicit + explicit extraction, nested references, graph validation, topo correctness + determinism, cycle + self-cycle detection, idempotent re-deploy (all NoChange), Modify on changed props, Incremental-leaves vs Complete-deletes, and what-if-doesn't-mutate |
| Talking Points | "Walk me through az deployment group create." / "How does ARM order the VNet before the NIC if I never wrote a dependsOn?" / "Incremental vs Complete — which is default and why is the other dangerous?" / "Why is an ARM PUT idempotent and why does CI depend on it?" |
| Resume bullet | Built a dependency-graph deployment engine (implicit/explicit reference resolution, deterministic topological scheduling with cycle detection, idempotent PUT convergence, non-mutating what-if diff) modeling the Azure Resource Manager control plane |
→ Lab folder: lab-01-arm-deployment-engine/
Integrated-Scenario Suggestions (carried through the whole track)
The phases compound. Keep these in mind as you build — each plugs the deployment engine into a larger system:
- Landing-zone bootstrap — a deployment that lays down a management-group subtree, policy assignments, and a hub VNet. The order (policy before the resources it governs; networking before the workloads) is pure dependency-graph reasoning. (→ P05)
- Idempotent CI deploy — a pipeline that runs
what-ifon PR (the diff is the review) anddeployon merge, safely re-applying on every commit because the PUT is idempotent. (→ P08) - Drift control — a deployment stack with
denySettingsthat blocks out-of-band portal edits; reconcile detected drift by re-deploying desired state. (→ P05, P02 for the Terraform-state version of the same idea) - Multi-resource provisioning order — a VM that needs a NIC that needs a subnet that needs a VNet, plus a Key Vault the VM reads a secret from — the canonical "why did this deploy in that order" graph. (→ P06, P12)
- Cross-RP routing — a single template touching
Microsoft.Storage,Microsoft.Network, andMicrosoft.Compute, each routed to a different resource provider, each with its ownapi-versionand async behavior. (→ all later phases)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- Everything in Azure is a
PUTthrough ARM, and the deploy engine is, at its core, a dependency-graph topological scheduler over idempotent PUTs. Build that once and every IaC tool — Bicep, Terraform, Pulumi, CloudFormation — becomes a front-end you already understand. - Implicit dependencies are real edges.
reference()andresourceId()create ordering constraints you never typed; the engine (and you) must find them. - Idempotency is the load-bearing property of IaC. Desired-state PUTs converge, which is why you can re-run a template on every commit and why drift is recoverable.
- Mode is a blast-radius decision. Incremental is additive and safe-ish; Complete makes the RG exactly the template — and deletes the rest. Never run Complete without a what-if you've read.
- Async is the default. "The PUT returned" is not "the resource is ready"; the
provisioningStatepoll is. Debugging stuck deploys lives here.
Deliverables Checklist
-
Lab 01 implemented; all 25 tests pass against
solution.pyand yourlab.py - You can draw the ARM request pipeline (authenticate → authorize → validate → route) from memory
- Given a template, you can list its implicit + explicit edges and write the deploy order without running it
- You can explain Incremental vs Complete and the exact blast radius of each
-
You can explain why
PUTis idempotent and trace a re-deploy to all-NoChange -
You can describe the
provisioningStateasync-poll loop
Warmup Guide — Azure Resource Manager & the Deployment Engine
Zero-to-principal primer for Phase 01. We start from "what even is ARM" and end with you able to draw the control-plane request pipeline, predict a deployment's order from implicit references alone, explain the Complete-mode foot-gun, and reason about the async-operation poll — the things interviewers probe and incidents turn on. Every mechanism is opened under the hood: the pipeline, the graph algorithm, the idempotent PUT, the diff.
Table of Contents
- Chapter 1: What ARM Is — The One API in Front of Everything
- Chapter 2: The Control-Plane Request Pipeline
- Chapter 3: ARM Template Anatomy
- Chapter 4: The Dependency Graph — Explicit and Implicit Edges
- Chapter 5: Topological Ordering and Cycle Detection
- Chapter 6: Idempotency — The PUT That Converges
- Chapter 7: Deployment Modes — Incremental vs Complete
- Chapter 8: What-If — The Dry-Run Diff
- Chapter 9: Asynchronous Operations and provisioningState
- Chapter 10: Bicep — The Typed Transpiler
- Chapter 11: Deployment Stacks and Deny-Settings
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What ARM Is — The One API in Front of Everything
From zero. When you create anything in Azure — through the portal, the az CLI,
PowerShell, a Terraform run, a GitHub Action — you are not talking to "the storage
service" or "the VM service" directly. You are talking to one front door:
Azure Resource Manager (ARM). It is a single, global REST API and deployment engine
that sits in front of every Azure resource.
What it is. ARM is the control plane: the system that decides what resources exist, what their configuration is, and who may manage them. Every management operation — create, read, update, delete, tag, lock, assign a role — is an HTTP request to an ARM endpoint whose URL is the resource's canonical ID:
PUT https://management.azure.com
/subscriptions/{subId}
/resourceGroups/{rg}
/providers/{namespace}/{type}/{name}
?api-version=2023-01-01
That hierarchy — subscription → resource group → provider/type/name — is the resource ID, and it's the unit of RBAC scope, Policy scope, tagging, and locks (Phase 00 drilled this). Memorize the shape; you'll read it in error messages for the rest of your career.
Why it exists. Before ARM (the "classic"/ASM era), every Azure service had its own
API, its own auth, its own deployment story. There was no single place to apply
governance, no atomic "deploy these ten things together," no uniform RBAC. ARM unifies all
of it: one auth model (Entra), one authorization model (RBAC + Policy), one
deployment grammar (templates), and one idempotent verb (PUT desired state). Every
hard problem in this curriculum — identity, governance, networking, secrets — plugs into
this pipeline.
The control-plane / data-plane split (recall from P00). ARM is the control plane: it
creates the storage account, it does not read your blob. Reading the blob is the data
plane — a different endpoint ({account}.blob.core.windows.net), different auth (a SAS
token or a data-plane RBAC role), different throttling, different SLA. A PUT to create
Key Vault goes through ARM; getting a secret out of it does not. Conflating the two is
the single most common Azure misconception; keep them in separate boxes in your head.
Common misconception. "The portal does its own thing." No — the portal is just a
JavaScript client of the same ARM REST API you could call with curl. There is no
privileged back channel. Everything the portal can do, ARM can do, and so can you with a
token.
Chapter 2: The Control-Plane Request Pipeline
From zero. A request doesn't go straight from your az command to the disk that
stores your VM. It passes through a pipeline of gates, in a fixed order. This is the
single most valuable diagram in the phase:
┌─────────────────────────────────────────────┐
az / portal / TF ───▶ │ Azure Resource Manager │
(a signed request) │ │
│ 1. AUTHENTICATE validate the Entra JWT │
│ (signature, iss, aud, exp) │
│ 2. AUTHORIZE RBAC: Actions − NotActions │
│ then DENY assignments win │
│ 3. VALIDATE Azure Policy effects │
│ (deny / modify / append / audit) │
│ 4. ROUTE to the resource provider │
└──────────────────────┬──────────────────────┘
│
▼
┌─────────────────────────────────────────────┐
│ Resource Provider (Microsoft.Storage, …) │
│ does the idempotent PUT, returns │
│ provisioningState (async) │
└─────────────────────────────────────────────┘
Walk each gate under the hood:
- Authenticate. Your token is a JWT issued by Microsoft Entra ID. ARM verifies the
signature against Entra's published keys (JWKS, selected by the
kidin the header), and checksiss(issuer = your tenant),aud(audience =https://management.azure.com), and the validity window (nbf ≤ now ≤ exp). No valid token →401. You build this validator in Phase 03; here, just know it's gate one. - Authorize. ARM computes your effective permissions: the union of
Actionsacross all your role assignments at this scope and every ancestor scope, minusNotActions. Then — deny assignments win — any explicit deny overrides an allow. No matching allow, or a matching deny →403. You build this in Phase 04. - Validate. Azure Policy evaluates the request against assigned rules. A
denyeffect blocks the write (RequestDisallowedByPolicy);modify/appendmutate the request (e.g. add a required tag) before it proceeds;auditjust records non-compliance. Policy runs at create/update time, here, on the control-plane path. You build this in Phase 04 too. - Route. Only now does ARM hand the request to the resource provider — the service
(
Microsoft.Storage,Microsoft.Network,Microsoft.Compute) that owns this resource type and implements its actual CRUD. The RP must be registered on the subscription, or you getMissingSubscriptionRegistration.
Why this order. It is fail-closed and cheapest-check-first: reject the unauthorized caller before spending a cycle on policy, reject the policy-violating request before bothering the resource provider. Identity is the perimeter; it's gate one for a reason.
Production significance. Almost every "I can't deploy" ticket is one of these four
gates: a 401 (bad/expired token), a 403 (RBAC or a deny assignment — and "I'm Owner"
doesn't help if a deny assignment from a deployment stack or managed app is blocking you),
a RequestDisallowedByPolicy, or a MissingSubscriptionRegistration. Knowing the pipeline
is the triage tree.
Common misconception. "RBAC and Policy are the same thing." No. RBAC answers who may
perform this action; Policy answers is this resource configuration allowed. You can have
permission to create a storage account (RBAC says yes) and still be blocked because Policy
requires HTTPS-only and your request didn't set it (deny). Two different gates, both must
pass.
Chapter 3: ARM Template Anatomy
From zero. A template is a JSON document describing desired state — the resources you want to exist, declaratively, not the steps to create them. You hand ARM the "what"; ARM figures out the "how" and the "in what order." The top-level shape:
{
"$schema": "https://schema.management.azure.com/schemas/2019-04-01/deploymentTemplate.json#",
"contentVersion": "1.0.0.0",
"parameters": { "location": { "type": "string", "defaultValue": "eastus" } },
"variables": { "saName": "[concat('stg', uniqueString(resourceGroup().id))]" },
"resources": [
{
"type": "Microsoft.Storage/storageAccounts",
"apiVersion": "2023-01-01",
"name": "[variables('saName')]",
"location": "[parameters('location')]",
"sku": { "name": "Standard_LRS" },
"kind": "StorageV2"
}
],
"outputs": { "saId": { "type": "string", "value": "[resourceId('Microsoft.Storage/storageAccounts', variables('saName'))]" } }
}
Each section, under the hood:
$schema— the template-language version; lets the engine and tooling validate the document shape.parameters— the typed inputs callers supply at deploy time (with optional defaults, allowed values, constraints). The seam between the template and the environment.variables— computed, reusable values; pure functions of parameters and other variables, resolved at the start of the deployment.resources— the heart: the array of desired resources, each with atype,apiVersion,name, and properties.outputs— values returned after deploy (a connection string, a resource ID) for the caller or a parent/nested deployment to consume.
The [...] expressions and template functions. Anything in [ ] is an expression ARM
evaluates at deploy time. The functions that matter for this phase:
resourceId('type', 'name')— builds a resource's full ID. Crucially, when you use it to point one resource at another, ARM infers an implicit dependency (Chapter 4).reference('name')— reads runtime properties of another resource (its endpoints, its generated keys). Because it must read a resource that exists, it also creates an implicit dependency.dependsOn— the explicit ordering you write by hand: "deploy these first."parameters(),variables(),concat(),uniqueString()— value plumbing.
Why declarative. Because declarative + idempotent (Chapter 6) is what makes the file re-runnable. An imperative script ("create X, then create Y") run twice creates two X's; a declarative desired-state template run twice converges to the same single X. That property is the whole reason IaC exists.
Common misconception. "Order in the resources array is the deploy order." It is
not. ARM ignores array position and deploys in dependency order (Chapter 5).
Resources with no dependency relationship deploy in parallel.
Chapter 4: The Dependency Graph — Explicit and Implicit Edges
This is the chapter the lab is built on, and the one most engineers get wrong.
From zero. ARM cannot deploy a network interface before the subnet it lives in, or a VM before its NIC. It learns these ordering constraints by building a directed graph: a node per resource, an edge from a resource to each resource it depends on. Edges come from two sources:
1. Explicit edges — dependsOn. You literally list the names:
{ "type": "…/virtualMachines", "name": "vm1", "dependsOn": ["nic1"] }
2. Implicit edges — the ones you didn't type. When a resource's property references
another resource via reference() or resourceId(), ARM infers the dependency
automatically:
{
"type": "…/networkInterfaces", "name": "nic1",
"properties": {
"ipConfigurations": [{
"properties": { "subnet": { "id": "[resourceId('…/subnets', 'vnet1', 'subnet1')]" } }
}]
}
}
You never wrote "dependsOn": ["vnet1"] — but ARM adds that edge, because resourceId
names vnet1, and you can't get a subnet's ID until the VNet exists. This is why a
correct template often has almost no dependsOn: the references already encode the
order.
Under the hood — what the lab does. The engine scans every string value in a
resource's properties (recursing into nested dicts and lists, because real properties
nest deeply) for the pattern reference('NAME') or resourceId('NAME'), pulls out
NAME, and unions those with the explicit dependsOn list:
deps(resource) = set(resource.dependsOn) ∪ { NAME : reference('NAME') or resourceId('NAME') ∈ properties }
A resource never depends on itself (ARM drops a self-edge; a self-edge would be a trivial
cycle). And every referenced name must resolve to a resource in the template — a
reference to a name that isn't there is ARM's deploy-time InvalidTemplate: the resource '…' is not defined in the template error, which the lab raises as a ValueError.
Production significance. When you over-specify dependsOn (adding edges the references
already imply), you don't break correctness — but you can serialize a deployment that
could have run in parallel, making it slower. When you under-specify (a hidden ordering
the references don't capture, e.g. an eventual-consistency timing dependency), you get
flaky deploys. The principal reads the graph, not the dependsOn list.
Common misconception. "I need dependsOn everywhere." Usually the opposite: prefer
references (Bicep makes this automatic and symbolic). Reach for explicit dependsOn only
for an ordering ARM can't infer from a reference.
Chapter 5: Topological Ordering and Cycle Detection
From zero. Given the dependency graph, the engine must produce an order in which every
resource comes after all of its dependencies. That ordering is a topological sort,
and it exists iff the graph is a DAG (directed acyclic graph). A cycle —
A dependsOn B, B dependsOn A — has no valid order and is un-deployable.
Under the hood — Kahn's algorithm. The lab implements the classic queue-based topo sort:
1. in_degree[n] = number of n's dependencies # how many edges point OUT to deps
2. ready = every node with in_degree 0 # nothing left to wait for
3. while ready is non-empty:
n = pop the SMALLEST-named node from ready # deterministic tie-break
emit n
for each m that depends on n:
in_degree[m] -= 1
if in_degree[m] == 0: add m to ready
4. if we emitted fewer nodes than the graph has → a CYCLE remains
Two design choices in that pseudocode are worth their own paragraph:
Determinism via tie-break. At any step, several nodes may be "ready" at once (all their deps are done). Real ARM deploys all of them in parallel — order among them is undefined. But an undefined order is untestable, so the lab always pops the alphabetically smallest ready node. That gives one canonical, reproducible order: the same template always yields the same plan. Internalize that the determinism is a lab affordance for testability — production ARM parallelises ties. (The Extensions section shows how to emit parallel "waves" instead.)
Cycle detection for free. If the graph has a cycle, the nodes in the cycle never reach
in_degree 0 (each is waiting on the next), so they're never emitted. After the loop, if
len(emitted) < len(graph), the leftover nodes are the cycle — the lab raises
ValueError("cycle: ...") naming them. This is the same mechanism ARM uses to reject a
circular dependsOn.
A worked trace. Graph: vnet1 → ∅, subnet1 → {vnet1}, nic1 → {subnet1},
vm1 → {nic1, storage1}, storage1 → ∅.
| Step | ready (sorted) | pop | emitted so far |
|---|---|---|---|
| 0 | storage1, vnet1 | storage1 | storage1 |
| 1 | vnet1 | vnet1 | storage1, vnet1 |
| 2 | subnet1 | subnet1 | …subnet1 |
| 3 | nic1 | nic1 | …nic1 |
| 4 | vm1 | vm1 | …vm1 |
Order: storage1 → vnet1 → subnet1 → nic1 → vm1. Every resource lands after its deps.
Complexity. \( O(V + E) \) — linear in resources plus edges. ARM deployments are small (tens to low hundreds of resources), so this is instant; the value is correctness, not speed.
Common misconception. "Topo sort gives one answer." A DAG generally has many valid topological orders; the tie-break picks one. What's invariant is the partial order (dependencies before dependents), not the total order.
Chapter 6: Idempotency — The PUT That Converges
From zero. An operation is idempotent if doing it twice has the same effect as
doing it once. PUT (set this resource to this desired state) is idempotent; POST
(create a new thing) generally is not. ARM resources are deployed with PUT, and that
single fact is what makes Infrastructure-as-Code work.
Under the hood — the three outcomes. For each resource, the resource provider compares the desired state in the template against the actual state of the resource and converges:
| Situation | Action | Effect |
|---|---|---|
| resource does not exist | Create | provision it to desired state |
| exists, desired ≠ actual | Modify | reconcile it to desired state |
| exists, desired = actual | NoChange | nothing happens (a safe no-op) |
The lab's deploy does exactly this against a state dict (name → properties): absent →
Create, present-but-different → Modify, identical → NoChange.
Why it matters — the convergence property. Because a PUT of already-matching state is
a NoChange, you can re-run the same template as many times as you want and it stays
correct. This is the property your CI/CD depends on (Phase 08): every commit can re-apply
the full template; nothing duplicates; drift gets reconciled back to desired state. Run the
lab's idempotency test and watch a second deploy of the same template return all
NoChange — that is IaC's foundational guarantee, in code.
$$ \text{deploy}(T, \text{deploy}(T, S).\text{state}) ;\Rightarrow; \text{every action} = \texttt{NoChange} $$
Where idempotency quietly breaks (principal territory). It is not magic:
- A resource with a generated/random name (
uniqueString(newGuid())done wrong) makes every deploy Create a new one — non-idempotent by construction. - A
reference()to a resource someone deleted out-of-band fails the whole deploy. - Some properties are immutable — changing them isn't a Modify, it forces a
delete-and-recreate (this is Terraform's
ForceNew; you'll build it in Phase 02). - A
PATCH-style partial update can behave differently from a fullPUTof desired state.
Common misconception. "Idempotent means it does nothing the second time." No — it means
the end state is the same. The second run still checks every resource; it just finds
them already correct and reports NoChange. "Same result," not "no work."
Chapter 7: Deployment Modes — Incremental vs Complete
From zero. When you deploy a template to a resource group, you pick a mode that decides what happens to resources in the RG that aren't in your template.
Incremental (the default). ARM adds the resources in the template and updates the ones that exist — and leaves everything else alone. Your template is a superset instruction: "make sure these exist and look like this." Resources in the RG not mentioned in the template are untouched.
Complete. ARM makes the resource group match the template exactly — which means it deletes every resource in the RG that is not in the template. Your template becomes the whole truth of the RG.
RG before: [ storage1, vnet1, rogue_db ]
Template: [ storage1, vnet1 ]
Incremental → RG after: [ storage1, vnet1, rogue_db ] (rogue_db untouched)
Complete → RG after: [ storage1, vnet1 ] (rogue_db DELETED)
The lab models this precisely: in Complete mode, any name in state but not in the
template is given a Delete action and removed from the final state; in Incremental
mode it's never touched.
Production significance — the foot-gun. Complete mode is how production resources get
deleted by a "routine" deploy. The classic incident: someone added a resource by hand (or
another team's template owns it), you deploy your template — which doesn't mention it —
in Complete mode, and ARM dutifully deletes it. This is not a bug; it is the defined
contract. The defenses: (1) Incremental is the default — stay there unless you have a
reason; (2) always run what-if first (Chapter 8) and read the Deletes; (3) use
resource locks (CanNotDelete) on anything precious; (4) prefer deployment stacks
(Chapter 11) for managed, intentional deletion.
When Complete is the right tool. When you want the RG to be exactly the template — e.g. an ephemeral environment you own end-to-end, or cleaning up resources a previous template version created and the new one dropped. Intentional, scoped, what-if'd.
Common misconception. "Complete redeploys everything from scratch." No — it doesn't re-create the resources that match; those are still NoChange/Modify. It only adds the delete behavior for un-templated extras. Complete = Incremental + delete-the-rest.
Chapter 8: What-If — The Dry-Run Diff
From zero. Before you let ARM change anything, you want to see what it would do. What-if is ARM's dry run: it computes the diff between your template (desired) and the current state (actual) and reports, per resource, a change type — without deploying anything.
The change classification mirrors the lab's Change objects:
| Change type | Meaning |
|---|---|
| Create | resource will be added |
| Modify | resource exists; listed properties will change |
| Delete | resource will be removed (only in Complete mode / stacks) |
| NoChange | identical; nothing happens |
| Ignore | exists but outside the deployment's scope; left alone |
Under the hood. What-if runs the same classification logic as deploy — build the
graph, order it, compare desired vs actual per resource — but writes the results to a list
instead of to state. The non-negotiable property: it must not mutate anything. The lab
asserts this explicitly: what_if simulates against a throwaway copy so the real state
is byte-identical before and after. A "dry run" that mutates state is a bug, and a
dangerous one.
Production significance. What-if is your PR-time safety net and the thing that
catches the Complete-mode Delete before it ships. The principal workflow (Phase 08):
what-if on the pull request (the diff is the review — reviewers read the Creates,
Modifies, and especially the Deletes), then deploy on merge. A what-if that surfaces an
unexpected Delete has paid for itself a hundred times over.
Common misconception. "What-if is exact." It's a strong predictor, not a contract —
some changes only manifest when the resource provider actually evaluates the request
(api-version quirks, computed defaults). Read it as "here's what ARM intends," and treat
surprising Deletes as a stop sign.
Chapter 9: Asynchronous Operations and provisioningState
From zero. Most Azure resources don't provision instantly — a VM, an AKS cluster, a
SQL database take minutes. So ARM operations are asynchronous: the PUT returns
quickly with "I've accepted this, it's in progress," and you poll until it's done.
Under the hood — the poll loop. The PUT returns 201 Created or 202 Accepted with:
- a
provisioningStateon the resource:Accepted → Creating/Running → Succeeded(orFailed/Canceled), and - an
Azure-AsyncOperation(orLocation) header — a URL the clientGETs repeatedly, honoring theRetry-Afterinterval, until the state is terminal.
client: PUT resource … → 202 Accepted, Azure-AsyncOperation: <url>
client: GET <url> (wait Retry-After) → { "status": "InProgress" }
client: GET <url> (wait Retry-After) → { "status": "InProgress" }
client: GET <url> (wait Retry-After) → { "status": "Succeeded" } ← terminal
az and Terraform hide this loop behind their progress spinner, but it's what they're
doing. "The deploy finished" means the poll reached a terminal state — not that the
PUT returned.
Why it matters — ordering and debugging. Because the engine deploys dependencies before
dependents (Chapter 5), it must wait for a dependency's poll to reach Succeeded before
starting its dependents — otherwise the NIC would try to attach to a subnet that's still
Creating. And when a deploy is "stuck," the principal looks at which resource's
provisioningState is non-terminal and why (a quota error, a policy modify, a
dependency that Failed), rather than staring at the spinner. The lab abstracts the poll
away (resources "complete" instantly) so the graph logic is naked; the Extensions add a
provisioningState state machine so you can model the real loop.
Common misconception. "If the CLI command returned 0, the resource is ready." Only if
the CLI waited for the poll (most do by default; --no-wait does not). With --no-wait
you've only submitted the operation — the resource may still be Creating.
Chapter 10: Bicep — The Typed Transpiler
From zero. ARM JSON is verbose, expression-heavy, and painful to write by hand.
Bicep is a domain-specific language that compiles ("transpiles") down to ARM JSON.
It is not a different engine — bicep build emits the same template the ARM deployment
engine consumes. Same pipeline, same graph, same modes; nicer front-end.
The same storage account, in Bicep:
param location string = 'eastus'
resource sa 'Microsoft.Storage/storageAccounts@2023-01-01' = {
name: 'stg${uniqueString(resourceGroup().id)}'
location: location
sku: { name: 'Standard_LRS' }
kind: 'StorageV2'
}
output saId string = sa.id
What Bicep buys you, under the hood:
- Symbolic references and automatic dependencies. You write
sa.id, notresourceId(...). Becausesais a symbol, Bicep infers the dependency graph from the symbol references — you almost never writedependsOn. (This is Chapter 4's implicit edges, made first-class.) - Types and IntelliSense. The compiler knows each resource type's schema for the chosen
api-versionand catches typos and invalid properties before deploy. - Modules, loops, conditionals. First-class
module,for, andifinstead of copy-paste andcopyblocks.
Production significance. Bicep is Microsoft's recommended authoring experience for
native Azure IaC, and the what-if/modes/stacks story is identical (it's the same engine).
But — and this is the principal point — it's still ARM underneath. When a Bicep deploy
fails, you debug the ARM error, the ARM graph, the ARM provisioningState. Bicep is
ergonomics; the engine you built in the lab is the truth.
Common misconception. "Bicep is a competitor to ARM." It compiles to ARM. "Bicep vs Terraform" is a real comparison (different engines, different state models — Phase 02); "Bicep vs ARM" is not (same engine).
Chapter 11: Deployment Stacks and Deny-Settings
From zero. A normal deployment is fire-and-forget: ARM applies it and forgets which resources came from which template. A deployment stack is a resource that remembers the set of resources it manages and governs their lifecycle as a unit.
Two principal features, under the hood:
- Managed deletion. Drop a resource from the stack's template and re-deploy → the stack
deletes that resource (you choose
detachResourcesto leave it, ordeleteResourcesto tear it down). This is a safer, scoped version of Complete mode: the blast radius is "resources this stack manages," not "everything in the RG." - Deny-settings (drift protection). A stack can apply a deny assignment to its
managed resources —
denySettingsMode: denyDeleteordenyWriteAndDelete— that blocks out-of-band changes at the control plane. Someone tries to delete or edit a stack-managed resource in the portal → ARM denies it (deny assignments win, Chapter 2). The desired state is protected by ARM itself, not by hope.
Production significance. This closes the IaC loop: not just "deploy desired state," but "keep the real world equal to desired state and reject drift." It's the control-plane analog of Terraform's drift detection (Phase 02) — except enforced by ARM rather than detected after the fact. For a landing zone (Phase 05) where hundreds of engineers share an estate, deny-settings are how you stop a well-meaning portal click from deleting shared infrastructure.
Common misconception. "A deployment stack is just a deployment." A deployment is an event (it happened, then it's history). A stack is a resource (it persists, tracks its managed set, enforces deny-settings, and governs deletion on the next update).
Lab Walkthrough Guidance
Lab 01 — ARM Deployment Engine, suggested order (each step has tests waiting):
_implicit_refs_in_value+ the regex (Ch. 4). Write the regex that capturesNAMEfromreference('NAME')andresourceId('NAME'). Recurse into dicts and lists —test_extract_implicit_resourceid_and_nestedproves nested properties count.extract_dependencies(Ch. 4). UniondependsOnwith the implicit refs; raise on a missingname; drop the self-edge. Tests: explicit+implicit union, self-reference dropped, missing-name raises.build_dependency_graph(Ch. 4). Map name → deps; raise on a duplicate name and on any reference to a name not in the template. Tests cover both unknowndependsOnand unknown implicit references.topological_order(Ch. 5). Kahn's algorithm, tie-break by name for determinism, raiseValueError("cycle: ...")if not every node is emitted. Tests: order respects deps, deterministic tie-break, cycle + self-cycle raise.deploy(Ch. 6–7). Validate the mode; topo-order; classify Create/Modify/NoChange against a copy ofstate(don't mutate the caller's dict); in Complete mode, Delete un-templated extras (sorted). Tests: all-Create from empty, idempotent re-deploy = all-NoChange, Modify on changed props, Incremental leaves extras, Complete deletes them, bad mode raises, state not mutated.what_if(Ch. 8). Same classification, returned asChangeobjects, mutating nothing. Tests assert the input state is byte-identical after the call.
Run pytest test_lab.py -v after each step; commit when a section goes green. Then
python solution.py to see the worked plan: graph, deploy order, all-Create, idempotent
re-deploy, a Modify via what-if, and a Complete-mode Delete.
Success Criteria
You are ready for Phase 02 when you can, from memory:
- Draw the ARM request pipeline: authenticate (JWT) → authorize (RBAC, deny wins) → validate (Policy) → route (resource provider) — and name the HTTP error each gate throws.
- Given a template, list its implicit (
reference/resourceId) and explicit (dependsOn) edges and write a valid deploy order. - Run Kahn's algorithm on paper and explain how the leftover-nodes test detects a cycle.
- State why an ARM
PUTis idempotent and trace a re-deploy to all NoChange. - Explain Incremental vs Complete and the exact blast radius of each — and why
what-ifis the defense. - Describe the provisioningState async-poll loop and why dependents wait for a
dependency's
Succeeded. - Explain that Bicep transpiles to ARM and that deployment stacks + deny-settings protect against drift.
Interview Q&A
Q: Walk me through exactly what happens when I run az deployment group create.
The CLI acquires an Entra token and sends a deployment PUT to ARM. ARM authenticates the
JWT (signature, iss, aud, exp), authorizes via RBAC — union of Actions minus
NotActions across my assignments and inherited scopes, with deny assignments overriding —
and validates against Azure Policy (which may deny or modify the request). Then ARM
parses the template, builds a dependency graph from explicit dependsOn and implicit
reference()/resourceId() edges, topologically orders it, and deploys independent
resources in parallel — each routed to its resource provider as an idempotent PUT. Most
are async, so ARM returns 202 and polls provisioningState to Succeeded before
starting dependents. If I used the default Incremental mode, un-templated resources are
left alone; in Complete mode they'd be deleted.
Q: I never wrote a dependsOn, but my NIC deployed after my subnet. How?
Implicit dependencies. The NIC's subnet.id property uses resourceId(...) (or in Bicep,
a symbolic subnet.id reference) naming the subnet. ARM infers an edge from that reference
— you can't compute a subnet's ID until the VNet/subnet exists — so it orders the subnet
first automatically. Most correct templates have very few hand-written dependsOn precisely
because references already encode the order; explicit dependsOn is for orderings ARM
can't infer from a reference.
Q: Incremental vs Complete — which is the default, and when would Complete bite you?
Incremental is the default: it adds and updates the template's resources and leaves
everything else in the RG untouched. Complete makes the RG match the template exactly —
it deletes any resource not in the template. It bites you when something exists that
your template doesn't mention — a hand-created resource, another team's resource — and you
deploy in Complete mode: ARM deletes it, by design. Defenses: stay on Incremental, always
run what-if and read the Deletes, lock precious resources, and prefer deployment stacks
for intentional, scoped deletion.
Q: Why is an ARM deployment idempotent, and why do you care?
Because resources are deployed with PUT of desired state, not imperative commands. The
resource provider compares desired to actual and converges: Create if absent, Modify if
drifted, NoChange if identical. So re-running the same template is safe — it doesn't
duplicate anything; matching resources are NoChange. I care because it's the foundation of
CI/CD: every commit can re-apply the full template, drift reconciles back to desired state,
and a retry after a transient failure just resumes convergence. The classic break is a
resource with a randomly generated name — that Creates a new one every run and isn't
idempotent.
Q: A deployment is stuck. How do you debug it?
I look at the dependency graph and the provisioningState of each resource. A "stuck"
deploy is usually one resource whose async operation hasn't reached a terminal state —
because of a quota limit, a policy modify/deny, a resource-provider error, or a
dependency that Failed and is blocking its dependents (they correctly wait for the
dependency's Succeeded). I pull the deployment operations (az deployment operation group list) to find the specific resource and its error code, rather than watching the spinner.
Q: What's the difference between ARM, Bicep, and Terraform here?
ARM is the engine and the JSON template language — the control-plane API every Azure write
goes through. Bicep is a typed DSL that transpiles to ARM JSON: same engine, same graph,
same modes, nicer syntax with symbolic references and automatic dependencies. Terraform is a
different engine (Phase 02) with its own state file, plan/apply model, and ForceNew
replace semantics — it maps HCL to ARM calls via the azurerm provider but doesn't use
ARM's template engine. So "Bicep vs ARM" is front-end vs engine; "Terraform vs ARM/Bicep"
is two genuinely different deployment engines.
Q: How does what-if work, and why trust it?
It runs the same desired-vs-actual classification as a real deploy — build the graph,
compare each resource — but writes the result to a diff instead of to Azure, mutating
nothing. It classifies each resource as Create/Modify/Delete/NoChange/Ignore. I trust it as
a strong predictor and a safety net — especially for catching an unintended Delete before a
Complete-mode deploy — but not as a hard contract, since some changes only surface when the
resource provider actually evaluates the request.
References
- Microsoft Learn — Azure Resource Manager overview
- Microsoft Learn — Understand the structure and syntax of ARM templates
- Microsoft Learn — Define the order for deploying resources (dependsOn + implicit dependencies)
- Microsoft Learn — Deployment modes (Incremental and Complete)
- Microsoft Learn — ARM deployment what-if operation
- Microsoft Learn — Track asynchronous Azure operations
- Microsoft Learn — What is Bicep?
- Microsoft Learn — Azure deployment stacks
- Microsoft Learn — Azure Resource Manager resource provider registration
- Arthur B. Kahn, Topological sorting of large networks (1962) — the algorithm in Chapter 5
- The track's own CHEATSHEET.md and GLOSSARY.md
🛸 Hitchhiker's Guide — Phase 01: ARM & the Deployment Engine
Read this if: you've run
az deploymentorterraform applya hundred times but couldn't draw what ARM actually does between "create" and "the resource exists." This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the engine.
0. The 30-second mental model
Every write in Azure is one idempotent PUT to a resource ID, through ARM. A
deployment is ARM building a dependency graph from your template, topologically
ordering it, and PUTting desired state to each resource provider — Create if absent,
Modify if drifted, NoChange if identical. The whole engine is: graph → topo sort →
idempotent PUT → poll till Succeeded. Build that and Bicep, Terraform, Pulumi, and
CloudFormation are all the same algorithm wearing different syntax.
One sentence to tattoo: ARM doesn't run your steps — it reconciles your desired state, in dependency order, idempotently.
1. The request pipeline, one breath
client → ARM: AUTHENTICATE (Entra JWT) → AUTHORIZE (RBAC: Actions−NotActions, deny wins)
→ VALIDATE (Azure Policy: deny/modify/append/audit) → ROUTE (resource provider)
→ RP does idempotent PUT, returns provisioningState (async)
Fail-closed, cheapest-check-first. Almost every "can't deploy" ticket is one of these four
gates: 401 (token), 403 (RBAC / deny assignment), RequestDisallowedByPolicy,
MissingSubscriptionRegistration.
2. The dependency graph (the part everyone gets wrong)
Edges come from two places:
- Explicit:
"dependsOn": ["nic1"]— you typed it. - Implicit: a property uses
reference('x')orresourceId('x')→ ARM adds an edge toxautomatically. You never wrotedependsOn, but the order is enforced anyway.
A correct template often has almost no dependsOn because references already encode the
order. Bicep makes this fully automatic via symbolic references (vnet.id). Reach for
explicit dependsOn only for an ordering ARM can't infer.
3. Topo sort + cycles, the algorithm
Kahn's: emit any node whose deps are all emitted; repeat. A cycle = nodes that never
reach in-degree 0 → un-deployable → ARM rejects it (the lab raises cycle: ...).
Independent resources deploy in parallel in real ARM; the lab serializes ties by
name purely so the plan is deterministic and testable. Complexity \(O(V+E)\).
4. Idempotency, the load-bearing property
PUT desired state → absent: Create | drifted: Modify | identical: NoChange
Re-run the same template → all NoChange. That's why CI can re-apply on every commit and
why drift reconciles. It breaks when a resource has a random name (Creates a new one
every run) or when a property is immutable (Modify becomes destroy-and-recreate — that's
Terraform's ForceNew, Phase 02).
5. Modes — the foot-gun
| Mode | Un-templated resources in the RG | Use |
|---|---|---|
| Incremental (default) | left alone | almost always |
| Complete | DELETED (RG = template exactly) | ephemeral envs you own end-to-end |
Complete = Incremental + delete-the-rest. The production-deletion story: deploy a template
that's missing a resource, in Complete mode → ARM deletes the resource. By design, not a
bug. Defense: stay Incremental, always what-if first, lock precious resources, use
deployment stacks.
6. The numbers & names to tattoo on your arm
| Thing | Value |
|---|---|
| Default deployment mode | Incremental |
| Resource ID shape | /subscriptions/{s}/resourceGroups/{rg}/providers/{ns}/{type}/{name} |
| Every write is | one idempotent PUT + api-version |
| Implicit-dependency functions | reference(), resourceId() |
| provisioningState path | Accepted → Running → Succeeded | Failed | Canceled |
| Async signals | 202 Accepted + Azure-AsyncOperation header to poll |
| MG tree max depth | 6 levels under root (P05, but same control plane) |
| what-if change types | Create / Modify / Delete / NoChange / Ignore |
| Topo sort complexity | \(O(V + E)\) |
7. az one-liners you'll actually type
# Dry-run first — read the Deletes before you ship anything
az deployment group what-if -g rg -f main.bicep -p @params.json
# Deploy (Incremental is default; be explicit when it matters)
az deployment group create -g rg -f main.bicep --mode Incremental
# Complete mode — only with a what-if you've read and a reason
az deployment group create -g rg -f main.bicep --mode Complete
# Find the resource that's blocking a stuck/failed deploy
az deployment operation group list -g rg -n <deploymentName> \
--query "[?properties.provisioningState!='Succeeded'].{res:properties.targetResource.id, state:properties.provisioningState, err:properties.statusMessage}"
# Bicep is just a transpiler — see the ARM JSON it emits
az bicep build -f main.bicep # → main.json
# Deployment stack with drift protection (deny out-of-band delete/edit)
az stack group create -g rg --name app -f main.bicep \
--deny-settings-mode denyWriteAndDelete --action-on-unmanage deleteResources
# Register a resource provider before first use of its types
az provider register --namespace Microsoft.ContainerService
8. War story shapes you'll relive
- "Complete mode deleted prod." → a template missing a hand-created DB, deployed Complete. ARM did exactly what it was told. Fix: Incremental + what-if + locks; never Complete blind.
- "Deploy is stuck forever." → one resource's
provisioningStateis non-terminal (quota, aFaileddependency blocking dependents). Pull deployment operations; find that resource. - "It deployed in the wrong order / flaky." → a real ordering not captured by a reference;
add the missing
dependsOn. Or the reverse: over-dependsOnserialized a parallel deploy and made it slow. - "It worked locally, 403 in CI." → the pipeline's identity lacks the RBAC
Action, or a deny assignment from a stack/managed-app is overriding the allow. Deny wins. - "Re-running created duplicates." → a
uniqueString(newGuid())-style random name broke idempotency. Desired state must be stable to converge.
9. Vocabulary that signals you've held the pager
- Idempotent PUT — desired state, not a command; re-run converges to NoChange.
- Implicit dependency — the edge
reference()/resourceId()creates without adependsOn. - provisioningState — the async truth; "PUT returned" ≠ "resource ready."
- Incremental vs Complete — additive vs "RG = template exactly (deletes the rest)."
- what-if — the read-only diff; the PR review and the Delete-catcher.
- Resource provider (RP) — the service that owns a type; must be registered.
- Deployment stack / deny-settings — managed deletion + control-plane drift protection.
- Transpile — Bicep → ARM JSON; same engine underneath.
10. Beginner mistakes that mark you in interviews
- Thinking array position in
resourcesis the deploy order (it's the graph). - Adding
dependsOneverywhere instead of letting references imply edges. - Not knowing Incremental is the default — or what Complete deletes.
- Saying "the deploy is done" when the PUT returned but
provisioningStateisn't terminal. - Calling a dry run "what-if" while mutating state — a what-if never mutates.
- Treating idempotency as magic and getting bitten by a random resource name.
- Confusing "Bicep vs ARM" (front-end vs engine — same thing) with "Terraform vs ARM" (genuinely different engines).
11. How this phase pays off later
- The request pipeline is the spine of P03 (the JWT/auth gate), P04 (RBAC + Policy gates), and P05 (inheritance down the MG tree).
- The dependency graph + topo sort + idempotency is exactly Terraform's plan/apply
engine (P02) — you'll add
ForceNewand a state file to the same algorithm. - Idempotent re-apply is what makes the CI/CD pipeline (P08) safe to run on every commit.
- Modes, what-if, and deny-settings are how you protect a shared landing zone (P05) from drift and accidental deletion.
Now read the WARMUP slowly, then build the engine. After this, no IaC tool is a black box.
👨🏻 Brother Talk — Phase 01, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. Almost everyone who's "good at Azure" can write a Bicep file and run a deploy. What almost nobody can do — and what instantly separates a senior from a principal in an interview — is explain what happens between the moment you hit Enter and the moment the resource exists. That gap, the inside of the deployment engine, is this whole phase. Once you can narrate it, ARM stops being a vending machine you put YAML into and becomes a system you operate.
Here's the thing that took me too long to internalize: ARM is not running your script. There is no script. You handed it a description of the world you want, and it figured out the order, did the diffs, and converged reality to match. The mental shift from "I'm telling the computer the steps" to "I'm declaring the end state and the engine reconciles" is the entire shift from imperative ops to infrastructure-as-code. Get that in your bones and you understand Terraform, Kubernetes, Bicep, and every reconciler ever built, because they're all the same loop: observe actual, compare to desired, converge.
Now let me give you the things that actually bite people.
Respect Complete mode like a loaded gun. I'm not being dramatic. The single most
expensive Azure mistake I've watched happen — twice — is someone deploying in --mode Complete with a template that was missing a resource, and ARM deleting a production
database to make the resource group match the file. And here's the part that matters for
you: when it happened, the junior said "ARM has a bug, it deleted my DB." The principal
said "no — Complete mode does exactly that, it's documented, and the real failure is that we
deployed Complete without a what-if." The grown-up move isn't to fear the tool; it's to
know its contract so precisely that you reach for it deliberately and never by accident.
Default to Incremental. What-if before anything destructive. Lock the precious stuff. That's
not caution, that's competence.
Idempotency is the most beautiful idea in this phase, and it's worth loving. The fact
that you can run the same deploy a thousand times and the world doesn't drift — that the
hundredth run is all NoChange — is why you can put infrastructure in CI and re-apply it on
every commit without holding your breath. The whole modern practice of "infrastructure is
code, reviewed in PRs, applied automatically" stands on that one property. When you build
the lab and watch the second deploy come back all-NoChange, sit with it for a second. That
green is the foundation everything else is built on. And learn where it cracks — a random
resource name, an immutable property — because that's the bug that pages you at 2 a.m.,
when a deploy that should've been a no-op quietly recreated something.
The implicit-dependency thing is your party trick. Most people genuinely believe ARM
deploys in the order you wrote the resources, or only in the order you put dependsOn. When
you can look at a template and say "the NIC will deploy after the subnet even though there's
no dependsOn, because the subnet.id reference creates an implicit edge" — you've shown
you understand the engine, not just the syntax. That's a small thing that lands huge in an
interview, because it proves you've been under the hood. Build the dependency extractor in
the lab by hand and it'll be permanent knowledge, not a fact you memorized.
Don't skip the async part because the spinner hides it. az shows you a nice progress
bar and then says "done," and it's tempting to think the PUT is the whole story. It isn't.
Under that spinner is a poll loop checking provisioningState until it's Succeeded. The
reason this matters: the day a deploy is stuck, the only useful question is "which
resource's provisioningState is stuck, and why" — and if you've never thought about the
async model, you'll just stare at the spinner feeling helpless. The principal pulls the
deployment operations, finds the one resource that's Failed or blocked on quota or a
policy modify, and fixes that. Calm in an incident comes from knowing what's actually
happening, not from hoping the bar fills up.
The honest truth about this phase: the algorithm is not hard. Topological sort is a first-year data-structures exercise. Idempotency is one comparison. What makes this principal-level isn't the code — it's the judgment wrapped around it: knowing that Complete is a blast-radius decision, that what-if is a safety culture and not a command, that idempotency is a property you can break, that "deployed" means "polled to Succeeded." The lab is easy to pass and impossible to fully appreciate until you've connected each function to a real incident it would've prevented. So do that connecting. For every function you write, ask: what 2 a.m. page does this represent?
And here's the career angle, brother to brother. This phase is the keystone of all of IaC, which is half of this job. Terraform (next phase) is this same engine with a state file bolted on. Kubernetes is this same reconcile-to-desired loop. CloudFormation, Pulumi, Crossplane — same idea. If you truly own the ARM deployment engine — graph, idempotency, modes, async — you don't learn five more IaC tools, you recognize five more skins on the machine you already built. That's the leverage. That's why we start here. The people who plateau learn each tool fresh; the people who go principal learn the engine once and map everything to it.
Go build the engine. Watch the re-deploy come back all-NoChange. Then come find me in Phase
02 — that's where we put state and plan and ForceNew on top of this, and you'll see
Terraform was never magic either.
— your brother 👨🏻
Lab 01 — ARM Deployment Engine
Phase: 01 — Azure Resource Manager & the Deployment Engine | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Every single write in Azure — a storage account, a subnet, a role assignment — is one idempotent
PUTthat ARM routes through a pipeline: authenticate, authorize, validate, then resolve a dependency graph and deploy resources in topological order. This lab builds that engine: the implicit/explicit dependency resolver, a deterministic topological sort with cycle detection, the idempotent Create/Modify/NoChange decision, theComplete-mode delete foot-gun, and thewhat-ifdry run. Strip away the network, the JWT, and the resource providers, and what's left is a graph algorithm — the one ARM, Bicep, Terraform, and CloudFormation all share.
What you build
extract_dependencies(resource)— the union of explicitdependsOnand the implicit dependencies parsed out ofreference('NAME')/resourceId('NAME')calls hiding in (possibly nested) property strings. This is the step everyone gets wrong by hand.build_dependency_graph(resources)—name → {dependencies}, raising on a dependency to a resource that isn't in the template (ARM'sInvalidTemplate) and on duplicate names.topological_order(graph)— deterministic Kahn's algorithm, tie-breaking ready nodes by name so the same template always produces the same plan; raisesValueError("cycle: ...")on a circular dependency.deploy(resources, state, mode)— process in topo order; idempotentPUT→Create/Modify/NoChange;Completemode alsoDeletes un-templated resources. Returns the ordered actions and the converged state, without mutating the caller's state.what_if(resources, state, mode)— the side-effect-free dry run: the same classification asdeploy, returned as a list ofChangeobjects, mutating nothing.
Key concepts
| Concept | What to understand |
|---|---|
| Implicit vs explicit deps | reference()/resourceId() create edges you didn't type; ARM finds them, so must you |
| Topological order | dependencies deploy before dependents; independents run in parallel (we serialise by name for determinism) |
| Cycle detection | a dependsOn loop is un-deployable; Kahn's leftover-nodes test finds it |
Idempotent PUT | desired state, not a command → re-running converges (all NoChange), which is why IaC is safe to re-apply |
| Incremental vs Complete | Incremental leaves extras alone; Complete deletes anything not in the template — the classic 2 a.m. mistake |
what-if is read-only | the diff you read in the PR; mutating state in a "dry run" is a bug |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py prints a worked deployment plan |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 25 tests pass against your implementation.
- You can explain why
test_topo_order_is_deterministic_tie_break_by_namematters — real ARM parallelises independent resources; we serialise ties by name only so the plan is reproducible and testable. The determinism is a lab affordance, not how ARM schedules. - You can explain why
test_redeploy_is_idempotent_all_no_changeis the whole reason IaC works: aPUTof desired state that already matches is a safe no-op. - You can explain, in one breath, why
test_complete_mode_deletes_extrasis the most dangerous line in the suite — and when you'd ever wantCompletemode. - Given a 5-resource template on paper, you can draw its dependency graph and write the deploy order in under two minutes.
How this maps to real Azure
| The lab | Real ARM |
|---|---|
extract_dependencies | ARM's template expression engine evaluating reference()/resourceId() and building implicit edges, plus your literal dependsOn |
build_dependency_graph | the deployment's internal DAG; an unknown reference is the deploy-time InvalidTemplate error |
topological_order | the deployment scheduler — except ARM deploys all currently-ready resources in parallel (bounded fan-out), not one-at-a-time |
deploy Create/Modify/NoChange | the resource provider's idempotent PUT: it compares desired vs actual and converges; provisioningState goes Accepted → Running → Succeeded and the client polls an async operation |
Complete mode delete | az deployment group create --mode Complete — deletes every resource in the RG not in the template |
what_if | az deployment group what-if / New-AzResourceGroupDeployment -WhatIf — the dry-run diff |
What the miniature leaves out (and why it's fine): real ARM also authenticates the
Entra JWT, authorizes via RBAC + deny assignments, runs Azure Policy (which can deny
or modify the request), registers resource providers, calls a different REST endpoint
per api-version, and handles long-running async operations by returning 202 Accepted with an Azure-AsyncOperation URL the client polls. None of that changes the
graph algorithm — which is exactly the part interviewers probe and incidents turn on.
Extensions (build these in your own subscription)
- Async operations: give each resource a
provisioningStatethat walksAccepted → Running → Succeeded|Failedover N "ticks," and makedeploypoll until terminal — now you've modeled the202/Azure-AsyncOperationloop. - Parallel waves: instead of a flat topo list, emit waves (all nodes whose deps are satisfied deploy together) — the real ARM scheduler. Assert that independent resources land in the same wave.
- Bicep front-end: parse a tiny Bicep-like syntax (
resource sa 'type' = { ... }) with symbolic references (sa.id) into your resource-dict format, proving Bicep is just a transpiler to the same graph. - Deployment stack / deny-settings: track which resources a "stack" manages; on the
next deploy,
Deletethe ones dropped from the stack (managed deletion) and reject an out-of-bandModifyto adenySettings: denyDeleteresource (drift protection). - Wire it to a real RG:
az deployment group what-if -g rg -f main.bicepand diff your predicted plan against Azure's — they should classify the same resources the same way.
Interview / resume
- Talking points: "Walk me through what happens when I run
az deployment group create." / "How does ARM know to deploy the VNet before the NIC?" / "What's the difference between Incremental and Complete mode, and which is the default?" / "Why is an ARMPUTidempotent and why does that matter for CI?" - Resume bullet: Built a dependency-graph deployment engine (implicit/explicit
reference resolution, deterministic topological scheduling with cycle detection,
idempotent
PUTconvergence, and a non-mutatingwhat-ifdiff) modeling the Azure Resource Manager control plane.
Phase 02 — Terraform, CDKTF & IaC State Internals
Difficulty: ⭐⭐⭐☆☆ (the algorithm) → ⭐⭐⭐⭐⭐ (the judgment about state, replace, and locking)
Estimated Time: 1 week (12–18 hours)
Prerequisites: Phase 01 (the ARM deployment engine — the dependency graph, idempotent
PUT, and what-if). This phase is the stateful sibling of that stateless engine, and
the contrast is the whole lesson. No live Azure subscription required for the lab.
Why This Phase Exists
Phase 01 taught you the single most important sentence about ARM: every write is an
idempotent PUT of desired state, and ARM derives the diff by asking the resource
provider what's actually there. ARM is stateless — there is no file recording what it
believes exists, so there is nothing to drift, nothing to lock, nothing to lose.
Terraform makes the opposite bet, and that one architectural decision is the reason it
behaves the way it does — including every way it surprises people. Terraform keeps a
state file: a recorded map from your configuration's addresses
(azurerm_storage_account.main) to the real resource IDs and attributes it created. It
trusts that file as its model of reality. The entire tool is the diff between the world
you wrote (config) and the world it remembers (state), turned into a plan and executed
by apply.
That design buys Terraform things ARM doesn't have — a fast plan that doesn't have to read every resource, a portable engine that works across 4,000+ providers, a single graph that spans Azure and DNS and GitHub and Datadog. And it costs Terraform things ARM never worries about:
- State can lie. Someone edits a resource in the portal; Terraform's state still says
the old value. That gap is drift, and it's why
terraform planrefreshes before it diffs — and why a plan can show changes you didn't write. - State can be lost or corrupted, and losing it means Terraform forgets it owns your
production estate — the most feared
tfstateincident there is. - State must be shared and locked. Two engineers running
applyagainst the same state at once corrupt it, so remote state takes a lock (an Azure Storage blob lease) for the duration of an apply.
And one decision dwarfs all the others in blast radius: the difference between update in
place and replace (destroy-then-create). A change to a mutable attribute is a quiet
~. A change to an immutable (ForceNew) attribute — a storage account's location,
a VM's certain SKUs, a database's name — is a -/+: Terraform will destroy the real
resource and build a new one, with whatever downtime and data loss that implies. The
principal skill is reading a plan and catching the -/+ before you type yes.
So this phase makes you build the plan/apply engine: the per-resource diff, the
ForceNew replace decision, the dependency-graph ordering (and its reversal for deletes),
and the drift detector. Once you've implemented it, Terraform stops being magic, CDKTF
reveals itself as "the same engine with a real language on the front," and you can debug a
confusing plan by reasoning about which of the three artifacts is wrong.
What "Principal-Level" Means Here
A senior engineer writes a working Terraform module and runs apply. A principal
understands the engine well enough to:
- Read a plan as a code review and flag every
-/+(replace) as a potential outage, every unexpected~as drift or a provider default, and every-as a deletion to justify — before approving. - Predict replace vs update from the provider schema: know which attributes are
ForceNewand never let a "small change" silently recreate a database. - Reason about state as a separate, fragile artifact — where it lives (remote backend),
how it's locked (blob lease), how it drifts, how it's recovered, and why
terraform importandstate mv/rmexist. - Explain Terraform vs ARM/Bicep at the model level: state-file diff vs stateless
desired-state
PUT, and when each model's failure mode bites. - Operate CDKTF knowing it changes the authoring layer (a real language, loops, abstractions) and not the engine — it synthesizes to Terraform JSON and the same plan/apply runs.
Concepts
- The three artifacts. Configuration = the desired world (
.tf/.tf.json/ synthesized CDKTF). State = the recorded reality (terraform.tfstate: address → real ID + attributes). Plan = the diff of config against state, per resource, in dependency order.applyexecutes the plan and writes a new state. Hold these three apart and almost every Terraform mystery dissolves. - The plan algorithm. For each resource, compare config to state and emit exactly one
of: create (in config, not in state,
+), update-in-place (differs, only mutable attrs,~), replace (differs, an immutable attr moved,-/+), delete (in state, not in config,-), no-op (identical). This five-way classification is Terraform. ForceNew/ immutability → replace. Provider schemas mark some attributesForceNew: truebecause the underlying API cannot change them in place (you can't move a storage account to another region). Changing one forces destroy-then-create. The hardest, most valuable skill in this phase is recognising immutable attributes and the blast radius of recreating the resource.- Replace ordering. Default is destroy-before-create (downtime while the new one
builds). The
create_before_destroylifecycle flag inverts it (new one up first, then old torn down) — essential for zero-downtime replaces, but it requires non-colliding names/addresses. - The dependency DAG. Terraform builds a graph from references
(
${azurerm_storage_account.main.id}is an edge) plus explicitdepends_on. It applies in topological order (a resource after the things it needs) and destroys in reverse topological order (a dependent before the thing it depends on). - Drift detection. Real ≠ state because of out-of-band changes (the portal, another
tool, a human at 2 a.m.).
terraform planrefreshes — reads each resource through the provider — then diffs the refreshed reality against config, so drift shows up as changes you didn't write. State is a claim, continually re-checked, not ground truth. - Remote state + locking. State on a developer's laptop doesn't scale to a team. The
Azure Storage backend keeps
tfstatein a blob and takes a blob lease lock for each apply, so concurrent applies can't corrupt it. This is the backbone of team Terraform. - CDKTF. Author infrastructure in TypeScript/Python/Go/Java/C#,
cdktf synthto Terraform JSON (cdk.tf.json), then run the same plan/apply engine. CDKTF buys you a real language (loops, conditionals, classes, tests) on top of an unchanged core. - Terraform vs ARM/Bicep (the P01 contrast). ARM/Bicep: stateless, idempotent
desired-state
PUT, Azure-only, no state file, drift is recomputed each deploy. Terraform: a state file it diffs against, multi-cloud/multi-provider, aplanyou review, but a fragile artifact you must store, lock, and protect.
Labs
Lab 01 — Terraform State Engine (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build a miniature Terraform plan/apply engine: a per-attribute diff, a four-way per-resource decision (create / no-op / update / replace) that turns a ForceNew change into a destroy-then-create, a plan that orders creates/updates topologically and deletes in reverse and marks orphaned state for deletion, an apply that returns a new state without mutating the input, and a detect_drift that finds out-of-band changes |
| Concepts | Config vs state vs plan; the five-way diff; ForceNew → replace and its blast radius; dependency-graph topological ordering and its reversal for deletes; idempotent re-plan; non-mutating apply; drift as real ≠ state |
| Steps | 1. diff_attributes (the leaf comparison); 2. plan_resource (create/no-op/update/replace, the ForceNew boundary); 3. plan (classify all, mark orphans delete, topo-order creates/reverse-order deletes, summary counts); 4. apply (execute, return new state, never mutate input); 5. detect_drift (real vs recorded) |
| How to Test | pytest test_lab.py -v — 28 tests covering diff add/remove/change, the four-way decision and the ForceNew boundary, topo-order creates vs reverse-order deletes, orphan deletion, replace-on-immutable-change, apply correctness + input-immutability + determinism, and drift found vs none-when-synced |
| Talking Points | "What are Terraform's three artifacts and what does apply do?" / "When is a change an update and when a replace, and why does that matter at 2 a.m.?" / "Why does Terraform destroy in reverse dependency order?" / "What is drift and how does plan surface it?" / "Terraform vs Bicep — state file vs stateless PUT." |
| Resume bullet | Built a Terraform-style plan/apply state engine (per-attribute diff, ForceNew replace detection, dependency-graph topological ordering with reverse-order teardown, non-mutating apply, and out-of-band drift detection) to model IaC state internals |
→ Lab folder: lab-01-terraform-state-engine/
Integrated-Scenario Suggestions (carried through the whole track)
The phases compound. Keep these in mind as you build — each plugs the state engine into a larger platform:
- Team remote-state bootstrap — a storage account + container holding
tfstatewith blob-lease locking, the first thing any real Terraform org provisions (often itself created out-of-band or by a bootstrap module). The lock is the difference between a team that can runapplysafely and one that corrupts state weekly. (→ P05, P12) - The dangerous replace — a config change that looks trivial (rename a storage
account, move a region, change a SKU) but is a
ForceNew→ the plan shows-/+and the apply destroys a production resource. Catching this in PR review is the whole skill. (→ P06, P11) - Drift reconciliation — a portal edit (someone bumped a tier in an incident) creates
drift; the next
planshows changes nobody wrote. Decide: re-apply (config wins, revert the edit),import/update config (reality wins), orignore_changes(accept the edit). This is the daily reality of shared estates. (→ P04, P05) - CDKTF for a landing zone — express a repeated pattern (N spokes, each a VNet +
subnet + NSG) as a CDKTF loop in Python,
synthto JSON, and watch the same engine plan it — the case where a real language beats hand-written HCL. (→ P05, P06) - Terraform vs Bicep on the same estate — model one resource group both ways and
compare the failure modes: Bicep's stateless
PUT(no state to lose, Azure-only) vs Terraform's state diff (multi-cloud, reviewable plan, fragile state). The ADR that chooses between them is a Round-5 architecture question. (→ P01, P15)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- Terraform is the diff between config and state. Three artifacts — config (desired),
state (recorded reality), plan (the diff) — and
applyexecutes the plan. Hold them apart and the tool stops surprising you. ForceNewis the load-bearing concept. Update is a~; replace is a-/+that destroys real infrastructure. Reading a plan is reading for the-/+.- Order matters and reverses for deletes. Apply in topological order; destroy in reverse topological order. The graph drives both.
- State is a fragile, separate artifact. It drifts (real ≠ state), it must be stored remotely, and it must be locked (Azure blob lease) so a team can share it. Most Terraform horror stories are state-management failures, not config bugs.
- CDKTF changes the authoring layer, not the engine. Synthesize to Terraform JSON; the same plan/apply runs. A real language buys abstraction, not a different model.
- Stateful (Terraform) vs stateless (ARM/Bicep) is a design tradeoff, not a quality ranking. State buys a portable, reviewable, multi-cloud diff; it costs you a fragile artifact to store, lock, and protect.
Deliverables Checklist
-
Lab 01 implemented; all 28 tests pass against
solution.pyand yourlab.py -
You can name Terraform's three artifacts and say exactly what
applydoes -
Given a diff and a provider schema, you can call
updatevsreplacecorrectly and explain the blast radius of a replace - You can explain why deletes run in reverse dependency order
-
You can define drift, explain how
plan's refresh surfaces it, and list the three ways to reconcile it - You can explain remote state + blob-lease locking and why a team needs it
-
You can contrast Terraform's state model with ARM/Bicep's stateless
PUT(P01)
Warmup Guide — Terraform, CDKTF & IaC State Internals
Zero-to-principal primer for Phase 02. Phase 01 built ARM's stateless deployment engine. This phase builds its stateful sibling: Terraform, whose entire behavior — the good and the terrifying — follows from one decision, to keep a state file. We go from "what is a state file" to "why does my plan show a
-/+that's about to delete my database," from first principles to the mechanism under the hood, with the diagrams, tables, and small code you need to defend every line in an interview and at 2 a.m.
Table of Contents
- Chapter 1: Why Terraform Has State (and ARM Doesn't)
- Chapter 2: The Three Artifacts — Config, State, Plan
- Chapter 3: The Plan Algorithm — The Five-Way Diff
- Chapter 4: ForceNew — Update vs Replace, the Load-Bearing Concept
- Chapter 5: The Dependency DAG — Order, and Its Reversal
- Chapter 6: Remote State and Locking
- Chapter 7: Drift — When State Lies
- Chapter 8: CDKTF — A Real Language, the Same Engine
- Chapter 9: Terraform vs ARM / Bicep — The Stateful/Stateless Tradeoff
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Terraform Has State (and ARM Doesn't)
From zero. "Infrastructure as Code" means you describe the infrastructure you want in text, and a tool makes the cloud match it. There are two ways a tool can figure out what to change to make reality match your text.
- Ask reality every time (stateless). This is ARM/Bicep (Phase 01). Each deploy is a
PUTof desired state; ARM hands it to the resource provider, which compares your desired state to what it actually has and converges. There is no file recording what ARM "thinks" exists — the resource provider is the source of truth, queried fresh each time. Nothing to lose, nothing to drift, nothing to lock. - Remember what you made (stateful). This is Terraform. When Terraform creates a
resource, it writes an entry into a state file — a JSON document mapping your
config's address (
azurerm_storage_account.main) to the real resource's ID and attributes. Next time, instead of asking the cloud about everything, it diffs your config against this remembered state.
Why would anyone choose the harder, riskier stateful model? Because it buys things the stateless model can't:
- One graph across many clouds. ARM only knows Azure. Terraform's state can hold an Azure VNet, an AWS bucket, a Cloudflare DNS record, a GitHub repo, and a Datadog monitor in one dependency graph with one plan. The state file is what makes that universal — every provider records into the same format.
- A fast, reviewable plan. Because Terraform remembers,
plancan show you the diff without a full deploy, and you review it like a PR before anything changes. - Tracking resources the cloud can't uniquely identify by config alone — Terraform's state remembers which real resource corresponds to which config block, even when the config is a loop producing ten near-identical resources.
The cost is the rest of this phase: state can be lost, stale (drift), or contended (two people applying at once). Every famous Terraform horror story is one of those three. A principal doesn't fear state; they understand it as a separate, fragile artifact with its own lifecycle, and they protect it deliberately.
Production significance. "Where does your state live and how is it locked?" is the
fastest way to tell a Terraform team that's been burned from one that hasn't. The burned
team has remote state in an Azure Storage account with locking, restricted access, and
versioning. The other team has terraform.tfstate on someone's laptop and a story coming.
Common misconception. "State is just a cache; I can delete it and re-run." No — if you
delete state, Terraform forgets it owns your infrastructure. The next plan sees an
empty state and a full config and says "create everything" — against resources that already
exist, which then collide. Losing state is the worst non-data Terraform incident there is.
Chapter 2: The Three Artifacts — Config, State, Plan
From zero. Almost every confusing thing Terraform does becomes obvious the moment you hold these three apart:
| Artifact | What it is | Lives in | Analogy |
|---|---|---|---|
| Configuration | the world you want | .tf / .tf.json / synthesized CDKTF | the desired end state |
| State | the world Terraform remembers it built | terraform.tfstate (remote blob) | its memory / model of reality |
| Plan | the diff: config minus state, per resource | computed on plan, applied on apply | the changeset to reconcile them |
The relationship, as a diagram:
you write Terraform remembers
┌──────────────┐ ┌──────────────────────┐
│ CONFIG │ │ STATE │
│ (desired) │ │ (recorded reality) │
└──────┬───────┘ └──────────┬───────────┘
│ │
└──────────── diff ────────────┘
│
┌─────▼──────┐
│ PLAN │ create / update / replace / delete / no-op
└─────┬──────┘
│ apply
┌─────▼──────┐
│ NEW STATE │ (and real resources changed)
└────────────┘
terraform plan computes the diff and shows it. terraform apply executes the plan and
writes the new state. That's the whole loop. In the lab, config is a list of
Resource, state is a dict (address → attributes), and plan() produces the diff that
apply() executes into a new state dict.
Under the hood — what a real tfstate entry looks like:
{
"resources": [{
"type": "azurerm_storage_account",
"name": "main",
"instances": [{
"attributes": {
"id": "/subscriptions/…/storageAccounts/stappdata",
"name": "stappdata",
"location": "eastus",
"account_tier": "Standard"
}
}]
}]
}
The address azurerm_storage_account.main is type.name. The attributes block is the
recorded reality the next plan diffs against. Our lab's state["azurerm_storage_account.main"] = {"name": …, "location": …, "account_tier": …} is exactly this, with the envelope
stripped away.
Common misconception. "The plan and the apply are the same operation." They are not.
plan is read-only (modulo the refresh, Ch. 7); apply mutates. Best practice is to
plan -out=tfplan and apply tfplan so the thing you reviewed is exactly the thing that
runs — otherwise the world can change between plan and apply and you apply a different diff
than you approved.
Chapter 3: The Plan Algorithm — The Five-Way Diff
From zero. The plan is computed one resource at a time. For each resource Terraform compares the config version against the state version and emits exactly one action:
| Symbol | Action | Condition |
|---|---|---|
+ | create | in config, not in state |
~ | update in place | in both, differs, only mutable attributes changed |
-/+ | replace | in both, differs, at least one immutable (ForceNew) attribute changed |
- | delete | in state, not in config |
| (none) | no-op | in both, identical |
That five-way classification is the plan engine. Everything else — ordering, drift, rendering — wraps around it.
Under the hood — the algorithm in pseudocode (and exactly what plan_resource /
plan implement in the lab):
for each address in (config ∪ state):
desired = config.get(address) # None ⇒ removed from config
current = state.get(address) # None ⇒ never created
if desired and not current: → create
elif current and not desired: → delete
elif desired == current: → no-op
else:
changed = diff(desired, current) # attr → (old, new)
if any(attr is ForceNew for attr in changed): → replace
else: → update
The leaf primitive is the per-attribute diff (diff_attributes in the lab): walk the union
of keys; a key in both with different values is a change (old, new); a key only in config
is an add (None, new); a key only in state is a removal (old, None). The whole resource
decision rides on whether that change set touches a ForceNew attribute — Chapter 4.
Production significance. Reading a plan is this algorithm in your head. When you see
~ tags, you know "in both, mutable attr changed." When you see -/+ name, your stomach
should drop: an immutable attr moved, a real resource is about to be destroyed.
Common misconception. "No-op means Terraform did nothing useful." No-op means
converged — the most valuable plan in production is the all-no-op plan, the proof that
reality matches config. Re-running a converged config is all no-op, which is exactly
Terraform's idempotency (the lab's test_plan_idempotent_when_state_matches).
Chapter 4: ForceNew — Update vs Replace, the Load-Bearing Concept
From zero. Some attributes of a cloud resource can be changed in place; others cannot.
You can change a storage account's account_tier (Standard → Premium) — it's a mutable
property and the Azure API supports an update. You cannot change its location — a
storage account lives in one region forever; to "move" it you must delete it and create a
new one somewhere else. The provider schema encodes this: each attribute is marked
ForceNew: true (immutable) or not.
When a change touches a ForceNew attribute, an in-place update is physically impossible,
so Terraform plans a replace — shown as -/+ — which means destroy the existing real
resource, then create a new one. The blast radius is everything that resource held: the
data in the storage account, the IP address of the public IP, the identity of the database.
This is the single most important skill in the phase. A senior runs apply and trusts
the plan. A principal reads the plan for -/+ lines and asks "is this replace
acceptable, and is there data behind it?" — because the difference between ~ and -/+
on the same attribute name is the difference between a tweak and an outage.
Under the hood — why the boundary is exact. The lab's plan_resource makes the call:
delta = diff_attributes(desired, current) # {attr: (old, new)}
if not delta: return "no-op"
if any(a in force_new for a in delta): return "replace" # one immutable attr is enough
return "update"
One immutable attribute in the change set is enough to turn the entire resource into a
replace, even if nine other changed attributes are mutable. And the same diff is an
update if force_new is empty. That boundary is the lab's
test_plan_resource_force_new_boundary, and it's exactly the question an interviewer
probes: "this plan shows a replace — which attribute caused it?"
Replace ordering — destroy-before-create vs create-before-destroy. A replace is two operations, and their order is a downtime decision:
- Default: destroy-then-create. Terraform destroys the old resource, then creates the new one. There is a window where the resource doesn't exist — downtime, and if the create fails you're left with nothing.
create_before_destroy = true(alifecycleblock): create the new resource first, cut traffic over, then destroy the old. Zero (or near-zero) downtime. The catch: the new and old resources coexist briefly, so any attribute that must be globally unique (a name, a fixed IP) will collide unless you let it be generated or usename_prefix. Many acreate_before_destroyhas failed on a hard-coded name.
default (destroy-then-create): [destroy old] ──► [create new] ← outage window
create_before_destroy: [create new] ──► [cut over] ──► [destroy old] ← no gap
Production significance. "Change the VM size" can be an update on some SKUs and a
-/+ on others; "rename a resource" is almost always a -/+; "tighten a name to satisfy a
new policy" can quietly destroy a stateful resource. The plan tells you which — if you
read it. The famous incident shape is approving a plan whose -/+ on a database you
skimmed past.
Common misconception. "Terraform updated my resource, so my data is fine." Only if it
was a ~. A -/+ destroyed and recreated it — the data is gone unless it lived
elsewhere (a managed disk you detached, a backup you restored). Update and replace are not
two flavors of the same thing; one preserves the resource, the other annihilates it.
Chapter 5: The Dependency DAG — Order, and Its Reversal
From zero. Resources depend on each other: a subnet needs its VNet, a NIC needs its subnet, a storage container needs its storage account. Terraform must create them in an order that respects those dependencies, and — the part everyone forgets — destroy them in the opposite order.
Where the edges come from. Two sources, exactly as in ARM (Phase 01):
- Implicit (references). Writing
subnet_id = azurerm_subnet.main.idin a config makes that resource depend onazurerm_subnet.main— Terraform parses the reference and adds the edge. This is the common case and the reason you rarely write dependencies by hand. - Explicit (
depends_on). Adepends_on = [azurerm_role_assignment.x]block for ordering that isn't expressed through a reference (e.g. "this must exist after that for a reason the attributes don't capture").
Under the hood — topological order, and why it reverses for deletes. Terraform builds a directed acyclic graph (DAG) and walks it:
rg.main ──► sa.main ──► container.data (depends-on edges)
CREATE / UPDATE order (topological): rg.main → sa.main → container.data
DELETE order (REVERSE topological): container.data → sa.main → rg.main
Why reverse for deletes? Because you cannot delete a resource something else still depends
on. You can't delete a storage account while a container still lives in it; you can't delete
a VNet while a subnet still sits in it. So Terraform tears down dependents first,
walking the graph backwards. The lab encodes exactly this: creates/updates/replaces come
out in topological order, deletes in reversed(topological_order) — that's
test_plan_deletes_orphaned_state_in_reverse_order.
For determinism (so the lab is testable and a plan is reproducible) the topo sort uses Kahn's algorithm with a name tie-break: among resources that are all currently ready, emit the smallest address. Real Terraform parallelises independent resources; we serialise ties by name so the same config always yields the same plan.
The math. A valid plan order is any topological sort of the DAG ( G = (V, E) ), where
an edge ( a \to b ) means "(a) depends on (b), so (b) before (a)." A cycle in
(G) makes a topological order impossible — Terraform errors with Cycle: …. The number of
valid orders can be large; the tie-break collapses them to one canonical order.
Common misconception. "I'll just add depends_on everywhere to be safe." Over-using
depends_on serialises resources that could run in parallel (slower applies) and hides the
real reference graph. Prefer implicit dependencies (use the attribute reference); reach for
depends_on only when there's genuinely no reference to express the ordering.
Chapter 6: Remote State and Locking
From zero. If state lives as terraform.tfstate on your laptop, you are the only person
who can apply, and if your laptop dies the state dies with it. A team needs remote
state: the file lives in shared storage everyone can reach. On Azure that's the
AzureRM backend — a blob in a storage account:
terraform {
backend "azurerm" {
resource_group_name = "rg-tfstate"
storage_account_name = "sttfstate"
container_name = "tfstate"
key = "prod.terraform.tfstate"
}
}
The problem remote state introduces: concurrency. Now two engineers can both run
apply against the same state at once. Engineer A reads state, computes a plan, starts
creating resources, and writes state back — while Engineer B, working from the old state,
does the same. Their writes interleave; the state ends up describing neither reality. State
is corrupted.
Under the hood — the lock. Before apply (and other state-mutating operations),
Terraform acquires a lock. With the AzureRM backend the lock is an Azure Storage blob
lease on the state blob:
apply #1: acquire blob lease ── lease held ──► read state → plan → mutate → write state → release lease
apply #2: acquire blob lease ✗ "Error acquiring the state lock" (lease already held)
A blob lease is a short-lived exclusive claim on the blob; while A holds it, B's lock acquisition fails fast with a clear error instead of silently corrupting state. When A finishes (or is interrupted), the lease is released (or expires), and B can proceed. The lab doesn't simulate the lease — it's offline and single-threaded — but you must be able to explain it, because "how do you stop two people corrupting state?" is a stock interview question and the answer is "the backend's lock; on Azure, a blob lease."
Production significance. A locked, remote, versioned state backend is table stakes for team Terraform. The trio you want: remote (shared, durable), locked (no concurrent corruption), versioned + access-controlled (recover a bad apply, restrict who can read secrets that land in state). Note that last point: state often contains secrets (a generated password, a connection string) in plaintext — which is why the state blob must be encrypted at rest and access-restricted like the secret store it effectively is.
Common misconception. "terraform force-unlock fixes a stuck lock, so locking is no big
deal." force-unlock is a loaded gun: if you force-unlock while another apply is actually
running, you re-introduce the exact corruption the lock prevents. Use it only when you're
certain the lock is orphaned (a crashed run), never to "get past" a teammate's live apply.
Chapter 7: Drift — When State Lies
From zero. State is Terraform's memory of what it built, and memory goes stale. Someone opens the Azure portal during an incident and bumps a storage account's tier. Another tool changes a tag. A human deletes a resource by hand. Now the real world differs from what state records — that gap is drift, and "real ≠ state" is the one-line definition to memorise.
Why it matters. Terraform plans by diffing config against state. If state is stale,
the plan is computed against a lie — it might miss a change, or propose to "fix" something
that's actually correct. So before diffing, terraform plan does a refresh.
Under the hood — the refresh. terraform plan (and apply) runs in two phases:
1. REFRESH: for each resource in state, read its real attributes through the provider
(a GET against the Azure API) → update an in-memory copy of state to match reality
2. DIFF: compare CONFIG against the refreshed reality → produce the plan
So drift surfaces in the plan as changes you didn't write: you didn't touch the tier in
your .tf, but the plan shows ~ account_tier "Premium" -> "Standard" because someone
bumped it in the portal and refresh caught it. The lab's detect_drift(state, real) is a
pure model of the refresh-and-compare: it returns {address: {attr: (state_val, real_val)}}
for every recorded resource whose real attributes diverge — and an empty result when state
matches reality (test_detect_drift_none_when_in_sync).
Reconciling drift — three choices, and they're a decision:
| Choice | Meaning | When |
|---|---|---|
| Re-apply | config wins; revert the out-of-band change | the portal edit was unauthorized/wrong; config is truth |
Update config (or import) | reality wins; bring config up to match | the change was intentional and should be permanent |
ignore_changes | accept ongoing drift on that attribute | something else legitimately owns it (an autoscaler sets instance_count) |
A principal doesn't reflexively re-apply; they ask why the drift exists and choose. The worst outcome is a blind re-apply that reverts an emergency fix someone made in the portal at 3 a.m. for a good reason.
Production significance. Drift is the daily reality of any estate humans can also touch.
The mature answer is to reduce the surface: restrict portal write access, run a scheduled
plan that alerts on drift, and treat unexpected drift as an incident signal, not noise.
Common misconception. "Terraform manages everything, so there's no drift." Drift happens the moment anything outside Terraform can change a resource — a human, another pipeline, an Azure-managed mutation, an autoscaler. The question is never "will there be drift" but "do I detect it and have I decided how to reconcile each kind."
Chapter 8: CDKTF — A Real Language, the Same Engine
From zero. HCL (Terraform's config language) is declarative and pleasant for static infrastructure, but it's awkward when you want real programming: complex loops, conditionals, shared abstractions, unit tests, types. CDKTF (Cloud Development Kit for Terraform) lets you author infrastructure in a real language — TypeScript, Python, Go, Java, C# — using classes and loops and functions.
The crucial insight: CDKTF changes the authoring layer, not the engine. Here is the whole pipeline:
your Python/TS program ──cdktf synth──► cdk.tf.json (Terraform JSON) ──► the SAME plan/apply engine
cdktf synth synthesizes — it runs your program, which builds an object tree, and emits
Terraform JSON (cdk.tf.json), which is just Terraform configuration in JSON instead of
HCL. From there it's the identical engine you built in the lab: config → diff against
state → plan → apply, same ForceNew rules, same DAG, same state file, same locking. CDKTF
does not have its own state model or its own diff algorithm. It is a front-end that
produces config.
# CDKTF (Python): a loop that HCL would express clumsily
from cdktf import App, TerraformStack
from cdktf_cdktf_provider_azurerm.resource_group import ResourceGroup
class Spokes(TerraformStack):
def __init__(self, scope, id):
super().__init__(scope, id)
for region in ["eastus", "westus", "northeurope"]:
ResourceGroup(self, f"rg-{region}", name=f"rg-spoke-{region}", location=region)
App().synth() # → writes cdk.tf.json that `terraform plan` consumes
Production significance. Reach for CDKTF when the generation of config is the hard part — N near-identical spokes, config driven by data, abstractions you want to unit-test, teams who'd rather write Python than HCL. Stay with HCL when the infrastructure is static and the team values the simplicity and the enormous HCL ecosystem. Either way, the thing you learn in this lab — the plan/apply engine — is what runs underneath both.
Common misconception. "CDKTF is a different tool from Terraform." It's the same
Terraform engine with a programmatic front-end; debugging a CDKTF plan means reading the
synthesized cdk.tf.json and reasoning about it exactly as you would HCL. If you understand
the engine, you understand CDKTF for free.
Chapter 9: Terraform vs ARM / Bicep — The Stateful/Stateless Tradeoff
From zero. Phase 01 built ARM's engine; this phase builds Terraform's. They solve the same problem — make the cloud match your code — with opposite bets, and a principal can defend the choice between them with this table, not with taste:
| Dimension | ARM / Bicep (P01) | Terraform (P02) |
|---|---|---|
| Model | stateless: idempotent PUT of desired state; RP is the source of truth | stateful: diff config against a state file |
| State file | none — nothing to lose, store, or lock | terraform.tfstate — must be stored, locked, protected |
| Drift | recomputed every deploy (no stale memory to drift) | state can go stale; plan refreshes to detect it |
| Scope | Azure only | multi-cloud / multi-provider (one graph spans clouds + SaaS) |
| Review artifact | what-if (dry-run diff) | plan (dry-run diff) — both are PR-time safety nets |
| Order | dependency-graph topo order; reverse for Complete-mode deletes | dependency-graph topo order; reverse for all deletes |
| Replace | RP decides; what-if shows it | provider ForceNew; -/+ in the plan |
| Ecosystem | native to Azure, first-class with new RP features | 4,000+ providers, but Azure features can lag the RP |
| Failure mode | Complete-mode mass deletion (P01); async polling | state loss/corruption/contention; the dreaded -/+ |
The honest framing. Neither is "better." ARM/Bicep is stateless, Azure-native, and has no state file to lose — but it's Azure-only and you don't get a portable plan that also spans your DNS provider and your SaaS. Terraform gives you one reviewable plan across every provider on earth — at the cost of a fragile state artifact you must store, lock, version, and protect. The ADR that chooses between them weighs exactly those: "do we need multi-cloud / a unified graph?" (→ Terraform) vs "are we all-in on Azure and want to avoid state management entirely?" (→ Bicep). Many orgs run both: Bicep for Azure-native landing zones, Terraform where the graph crosses clouds.
Common misconception. "Terraform is more powerful because it has a plan and Bicep
doesn't." Bicep has what-if — the same idea. The real difference is the state model, and
it's a tradeoff (portability + a unified graph vs a fragile artifact), not a power ranking.
Lab Walkthrough Guidance
Lab 01 — Terraform State Engine, suggested order (each maps to a chapter):
Resource+diff_attributes(Ch. 2–3). The model (address=type.name,attributesdict) and the leaf comparison: union of keys →(old, new)for changed/added/removed, omit identical. Validate both sides are dicts.plan_resource(Ch. 3–4). The four-way decision.current is None→create; empty diff →no-op; any changed attr inforce_new→replace; elseupdate. TheForceNewboundary is the heart of the lab — test it both ways (force_newempty vs not) on the same diff.plan(Ch. 3, 5). Classify every config resource (look up its type'sforce_newset), mark addresses in state-but-not-config asdelete, then order: creates/ updates/replaces in topological order, deletes in reverse. Don't emitno-opactions but count them in the summary. Reject duplicate addresses.apply(Ch. 2). Execute the plan into a new state dict — create/update/replace write the desired attributes, delete removes the address — and never mutate the input (copy first; the test asserts the input is byte-for-byte unchanged).detect_drift(Ch. 7). For each address in both state and real, diff recorded vs actual; include only diverging addresses; empty result when in sync.
Run it red, make it green: pytest test_lab.py -v (28 tests). Then
LAB_MODULE=solution pytest test_lab.py -v to confirm the reference agrees, and
python solution.py to watch a worked plan (update vs replace) and a drift example print.
Success Criteria
You are ready for Phase 03 when you can, from memory:
- Name Terraform's three artifacts (config, state, plan) and say exactly what
applydoes — and why ARM has no state file. - Run the five-way diff in your head: given a config attr, a state attr, and a
ForceNewflag, call create/update/replace/delete/no-op correctly. - Explain the
ForceNewboundary — why one immutable attribute turns the whole resource into a-/+, and what a-/+destroys. - Explain create-before-destroy vs destroy-before-create and the name-collision catch.
- Explain why creates go in topological order and deletes in reverse, with the subnet/NIC example.
- Explain remote state, the AzureRM backend, and the blob-lease lock — and why
force-unlockis dangerous. - Define drift (real ≠ state), describe
plan's refresh phase, and list the three ways to reconcile it. - Explain that CDKTF synthesizes to Terraform JSON and runs the same engine.
- Contrast Terraform's state model with ARM/Bicep's stateless
PUTas a tradeoff.
Interview Q&A
Q: What are Terraform's three artifacts, and what does apply actually do?
Configuration is the desired world I write; state (terraform.tfstate) is the recorded
reality Terraform remembers it built — an address-to-attributes map; the plan is the diff of
config against state, per resource, in dependency order. terraform plan computes and shows
that diff (after a refresh); terraform apply executes the plan against the real cloud and
writes the new state. The whole tool is "reconcile config and state," and almost every
confusing behavior is explained by which of the three is stale or wrong.
Q: My plan shows -/+ on a resource. What does that mean and what do you do?
-/+ is a replace: a change touched a ForceNew (immutable) attribute, so Terraform
can't update in place — it will destroy the real resource and create a new one. First I
find which attribute caused it (a region, a name, a SKU that can't change in place). Then I
ask the load-bearing question: is there state or identity behind this resource? If it's a
stateless resource, fine. If it's a database, a storage account with data, or a public IP
something depends on, a blind apply is an outage — I'd reconsider the change, or use
create_before_destroy for zero-downtime where names don't collide, or move the data first.
The skill is catching the -/+ in review before anyone types yes.
Q: When is a change an update and when a replace?
It's an update (~) when every changed attribute is mutable — the provider's API can
modify it in place. It's a replace (-/+) the instant any one changed attribute is
marked ForceNew in the provider schema, because that attribute is immutable on the real
resource (you can't move a storage account's region). One immutable attribute in a sea of
mutable ones still forces a full replace. That boundary is exactly what the lab's
plan_resource decides.
Q: Why does Terraform destroy resources in reverse dependency order? Because you can't delete a resource something else still depends on. A storage container lives inside a storage account; a subnet inside a VNet; a NIC references a subnet. To tear down cleanly Terraform walks the dependency graph backwards — dependents first, their dependencies last — so nothing is deleted while something still needs it. Creates and updates go the forward (topological) way for the same reason: a thing is built only after what it depends on exists.
Q: What is drift and how does terraform plan surface it?
Drift is "real ≠ state" — the real resource diverged from what state records, because
something changed it outside Terraform (a portal edit, another tool, a human). plan runs a
refresh first: it reads each managed resource's real attributes through the provider and
updates an in-memory copy of state to match reality, then diffs config against that
refreshed reality. So drift appears in the plan as changes I didn't write. I don't blindly
re-apply — I decide: re-apply if config should win, update config/import if the change was
intentional, or ignore_changes if something else legitimately owns the attribute.
Q: How do you stop two engineers corrupting shared state?
Remote state with locking. On Azure the backend is an Azure Storage blob, and before any
state-mutating operation Terraform acquires a blob lease on the state blob — an exclusive
short-lived claim. A second concurrent apply fails fast with "Error acquiring the state
lock" instead of interleaving writes and corrupting state. I'd also version the blob (recover
a bad apply) and restrict access, because state often holds secrets in plaintext.
force-unlock exists for orphaned locks but is dangerous — using it against a live apply
re-creates the corruption the lock prevents.
Q: What does CDKTF change about Terraform, and what does it leave the same?
CDKTF changes the authoring layer: I write infrastructure in a real language (Python,
TypeScript, …) with loops, conditionals, classes, and tests, then cdktf synth to Terraform
JSON (cdk.tf.json). It leaves the engine completely unchanged — the synthesized JSON
feeds the exact same plan/apply, the same ForceNew replace rules, the same DAG, the same
state file and locking. CDKTF is a config generator, not a new model. So everything in this
lab is what runs under CDKTF too.
Q: Terraform or Bicep for a new Azure platform?
It's a tradeoff, not a ranking. If we're all-in on Azure and want to avoid managing a state
file at all, Bicep's stateless PUT model (with what-if for the dry-run) is simpler — no
state to store, lock, lose, or leak secrets through. If our graph spans clouds or SaaS, or
we want one reviewable plan across everything, Terraform's state model earns its keep despite
the fragility. Many orgs run both: Bicep for Azure-native landing zones, Terraform where the
dependency graph crosses provider boundaries. I'd write the ADR around "do we need a unified
multi-provider graph?" and "what's our appetite for managing state?"
References
- HashiCorp — Terraform: State (purpose, remote state, locking):
https://developer.hashicorp.com/terraform/language/state - HashiCorp — Resources: behavior & lifecycle (
create_before_destroy,prevent_destroy,ignore_changes):https://developer.hashicorp.com/terraform/language/resources/behavior - HashiCorp — Plugin SDK: schema behaviors (
ForceNewand how providers declare immutability):https://developer.hashicorp.com/terraform/plugin/sdkv2/schemas/schema-behaviors - HashiCorp — Backends: azurerm (Azure Storage state backend + blob-lease locking):
https://developer.hashicorp.com/terraform/language/settings/backends/azurerm - HashiCorp — Manage resource drift (refresh,
-refresh-only, reconciliation):https://developer.hashicorp.com/terraform/tutorials/state/resource-drift - HashiCorp — CDK for Terraform (synthesize to Terraform JSON, the same engine):
https://developer.hashicorp.com/terraform/cdktf - Terraform Registry — AzureRM provider (per-resource schema; which attributes are
ForceNew):https://registry.terraform.io/providers/hashicorp/azurerm/latest/docs - Microsoft Learn — Store Terraform state in Azure Storage:
https://learn.microsoft.com/azure/developer/terraform/store-state-in-azure-storage - Microsoft Learn — Bicep vs Terraform / what-if (the stateless contrast):
https://learn.microsoft.com/azure/azure-resource-manager/bicep/deploy-what-if - The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 02: Terraform, CDKTF & IaC State Internals
Read this if: you can write a Terraform module and run
apply, but a-/+in the plan doesn't make your stomach drop yet, and "where does your state live and how is it locked" isn't a question you can answer in one sentence. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the lab.
0. The 30-second mental model
Terraform is the diff between the world you wrote and the world it remembers. Three
artifacts: config (desired), state (recorded reality, terraform.tfstate), plan
(the diff). apply executes the plan and writes new state. ARM (P01) is stateless — it
re-derives reality every deploy. Terraform is stateful — it trusts a file. Everything good
(a portable, reviewable, multi-cloud plan) and everything scary (state loss, drift, the
-/+ that deletes your database) follows from that one decision.
One sentence to tattoo: read the plan for -/+ — that's a real resource about to be
destroyed.
1. The plan symbols, one breath
+ create in config, not in state
~ update differs, only MUTABLE attrs changed (in place, safe-ish)
-/+ replace differs, a ForceNew (IMMUTABLE) attr (DESTROY then create ← danger)
- delete in state, not in config
(none) no-op identical (= converged = good)
Plan: N to add, M to change, K to destroy is just these counted. The whole job of reading
a plan is hunting the -/+ and the - you didn't intend.
2. The numbers and facts to tattoo on your arm
| Thing | Number / fact |
|---|---|
| Terraform artifacts | 3: config, state, plan |
| Plan actions | 5: create / update / replace / delete / no-op |
ForceNew change | always a replace (-/+) — destroys the real resource |
| Replace default order | destroy-then-create (downtime); create_before_destroy inverts it |
| Delete order | reverse topological (dependents first) |
| Azure state backend | Azure Storage blob; lock = blob lease |
| State secrets | stored in plaintext in state → encrypt + restrict access |
| CDKTF output | cdk.tf.json (Terraform JSON) → same plan/apply engine |
| Drift | real ≠ state; plan refreshes (provider GET) then diffs |
| Lost state | Terraform forgets it owns your infra → re-creates / collides |
3. The dials you'll turn forever
update ⟷ replace (mutable attr = ~ ; immutable = -/+ destroys real infra)
destroy-then-create ⟷ create-before-destroy (downtime vs name collisions)
implicit deps ⟷ depends_on (prefer references; depends_on serialises & hides the graph)
config wins ⟷ reality wins ⟷ ignore_changes (the three drift reconciliations)
local state ⟷ remote+locked (laptop = solo & fragile; blob+lease = team-safe)
HCL ⟷ CDKTF (static & simple vs generated config in a real language)
Terraform ⟷ Bicep (multi-cloud + reviewable plan vs no state file to lose)
The interview signal: when they say "I want to rename this storage account," can you say
"that's a ForceNew — it'll be a -/+, which destroys it; is there data behind it?"
4. The terraform / az one-liners you'll actually type
# bootstrap remote state (the first thing a real team does)
az group create -n rg-tfstate -l eastus
az storage account create -n sttfstate -g rg-tfstate --sku Standard_LRS --encryption-services blob
az storage container create -n tfstate --account-name sttfstate
# the core loop
terraform init # download providers, configure the backend
terraform plan -out=tfplan # compute the diff; SAVE it so apply runs exactly it
terraform apply tfplan # execute the saved plan (takes the blob-lease lock)
# read the plan for danger
terraform plan | grep -E '(-/+|# .* will be (destroyed|replaced))' # find the replaces/deletes
# drift
terraform plan -refresh-only # show drift WITHOUT proposing config changes
terraform apply -refresh-only # accept reality into state (no infra change)
# state surgery (know these exist; use with care)
terraform state list # what state thinks it owns
terraform state show azurerm_storage_account.main
terraform import azurerm_resource_group.main /subscriptions/…/resourceGroups/rg-app # adopt an existing resource
terraform state mv <old> <new> # rename without destroy/create
terraform state rm <addr> # forget a resource (does NOT delete it)
terraform force-unlock <LOCK_ID> # ⚠️ only for an ORPHANED lock, never a live apply
# CDKTF
cdktf synth # run your program → cdk.tf.json
cdktf plan ; cdktf deploy # same engine, programmatic front-end
5. War story shapes you'll relive
- "The apply deleted our database." → the plan had a
-/+on it (aForceNewattr — a rename, a region, a SKU) and someone skimmed past it and typedyes. Fix is reading the plan: every-/+is a destroy. Often the cause is "tightened the name to satisfy a new naming policy" — and the name is immutable. - "Two of us applied at once and state is garbage." → no locking, or someone
force-unlocked a live run. Fix is remote state + blob-lease locking, and never force-unlocking a running apply. - "The plan wants to change things I never touched." → drift. Someone edited the
resource in the portal; refresh caught it. Decide: revert (re-apply), keep (update config
/
import), orignore_changes. - "We lost the state file." → state on a laptop, no remote backend, no versioning. Now
Terraform thinks it owns nothing and wants to create resources that already exist. Hours
of
terraform import. This is why remote + versioned state is non-negotiable. - "create_before_destroy still caused an outage." → the resource had a hard-coded
unique name, so the new one collided with the old. Use
name_prefix/ let it be generated.
6. Vocabulary that signals you've held the pager
- State — Terraform's recorded reality; an address→attributes map it trusts.
ForceNew/ replace (-/+) — an immutable attribute changed → destroy-then-create.create_before_destroy— the lifecycle flag that makes a replace zero-downtime.- Drift — real ≠ state; the out-of-band change
plan's refresh surfaces. - Backend — where state lives (AzureRM = a storage blob) and how it's locked.
- Blob lease — the Azure lock that prevents concurrent state corruption.
import/state mv/state rm— adopt / rename / forget without touching infra.- Refresh — the read-real-resources phase before the diff.
- Synthesize (
cdktf synth) — turn a CDKTF program into Terraform JSON. prevent_destroy— the lifecycle guardrail that fails the plan on a delete/replace of a protected resource — the seatbelt for stateful resources.
7. Beginner mistakes that mark you in interviews
- Not knowing the three artifacts — talking about
applywithout distinguishing config, state, and plan. - Treating
~and-/+as the same ("it'll just change it") — a replace destroys the resource. - Saying "Terraform handles drift automatically" — it detects drift via refresh; you decide how to reconcile.
- Not having an answer to "where does state live and how is it locked."
- Forgetting deletes go in reverse dependency order.
depends_oneverywhere — serialises parallelisable resources and hides the real graph.- Thinking CDKTF is a different engine — it synthesizes to Terraform JSON and runs the same plan/apply.
- Forgetting state holds secrets in plaintext — so it needs encryption + access control like the secret store it effectively is.
force-unlockas a routine fix — it re-creates the corruption locking prevents.
8. How this phase pays off later
- The plan/apply engine (the lab) is the engine under every IaC tool — CDKTF, and the same shape as ARM/Bicep (P01) and CloudFormation/Pulumi.
- The
ForceNewreplace instinct protects every stateful resource you'll ever manage — databases (P00 data layer), public IPs and subnets (P06), Key Vaults (P12). - Remote state + locking is the same shared-mutable-state-with-a-lock pattern you'll see in Service Bus sessions (P10) and distributed coordination everywhere.
- Drift reconciliation is the daily reality of governed estates (P04 RBAC, P05 landing zones) where humans and policy can also mutate resources.
- The stateful vs stateless tradeoff is a Round-5 architecture ADR (P15).
Now read the WARMUP slowly, build the state engine, and never type yes on a plan you
haven't read for -/+ again.
👨🏻 Brother Talk — Phase 02, Off the Record
No slides, no certification objectives. This is me, your brother, telling you what I wish someone had told me before I learned it the expensive way. Read it once now, and again the day you're about to run an
applyagainst production.
Listen. Almost everyone who "knows Terraform" knows it the way you know how to drive without
knowing what's under the hood: you can get from A to B, and you're completely lost the moment
something goes wrong. They can write a module, run apply, and feel productive. And then one
day the plan says -/+ next to a database, they don't notice, they type yes, and the
database is gone. Not down. Gone. Recreated empty.
I'm not telling you that to scare you. I'm telling you because the entire difference between the person who causes that outage and the person who catches it in review is one habit: they read the plan. That's it. That's the whole separation between senior and principal in IaC. Not memorizing more resource types. Reading the plan like it's a loaded document, because it is.
So let me give you the things that actually matter, brother to brother.
The state file is not a config file. It's a load-bearing wall. Here's the mental shift
that took me too long: config is just text you can rewrite anytime. State is Terraform's
memory of what it owns in the real world. Lose it and Terraform doesn't lose your config
— it loses the knowledge that your production estate belongs to it. The next plan looks at
an empty memory and a full config and cheerfully proposes to create everything you already
have. I've watched a team spend two days terraform import-ing a production environment back
into a state file someone deleted "to start clean." Treat state like you'd treat the master
key to the building: remote, locked, versioned, access-controlled, and never on a laptop.
Fall in love with the -/+. Everyone's eyes glaze over reading a plan — it's long, it's
green, it scrolls. Train yourself to do the opposite of glaze: scan for -/+ and - first,
every single time, before you read anything else. Those two symbols are the only ones that
can hurt you. + creates, ~ tweaks — fine. -/+ destroys and recreates; -
destroys. The moment you make "grep the plan for -/+" a reflex, you've internalized the
one thing that makes you safe to give production access to. I literally pipe my plans through
a grep for it. It's not paranoia; it's the job.
Immutability is a fact about the world, not a Terraform quirk. You can't move a house to
a different city; you build a new one and tear the old one down. A storage account's region,
a resource's name, certain VM SKUs — same thing. ForceNew isn't Terraform being annoying;
it's Terraform being honest that the underlying cloud API physically cannot change that
attribute in place. So when a "small rename" turns into a -/+, don't fight it — understand
it. The question is never "why is Terraform recreating this?" It's "is there data or identity
behind this resource that recreation destroys?" If yes, you have a real engineering problem
(move the data, use create-before-destroy, or don't make the change) — not a Terraform
problem.
Drift is not Terraform's failure; it's the world being the world. People get weirdly
moralistic about drift — "someone touched the portal, that's against the rules." Sure, reduce
the surface, restrict portal access, alert on drift. But also: sometimes the person who
edited the resource in the portal at 3 a.m. was right — they were stopping an incident and
didn't have time for a PR. The principal move isn't to reflexively re-apply and revert their
fix. It's to ask why the drift exists and choose: revert it, adopt it into config, or
ignore it. A blind terraform apply that undoes someone's emergency fix is its own incident.
Drift is a conversation, not a violation.
Locking is the cheapest insurance you'll ever buy, and force-unlock is a loaded gun.
The whole reason remote state takes a blob lease is so two people can't apply at once and
shred the state. It works silently and you never think about it — until someone's apply
crashes and leaves an orphaned lock, and the "fix" everyone reaches for is force-unlock.
Here's the trap: if you force-unlock while someone's apply is actually still running, you
just re-created the exact corruption the lock was preventing. Use it only when you're certain
the lock is orphaned. When in doubt, find the human, don't unlock.
CDKTF won't save you from understanding the engine — and that's good news. Some people
reach for CDKTF hoping a real language will let them skip learning how Terraform actually
works. It won't. CDKTF is just a fancier way to write the config; it synthesizes to
Terraform JSON and runs the identical plan/apply, the identical state file, the identical
-/+ rules. Which means the thing you're learning in this lab — the engine — is exactly what
makes you good at CDKTF for free. Learn the engine once, and HCL, CDKTF, and honestly the
shape of ARM and Pulumi and CloudFormation all become front-ends you already understand.
That's the leverage of going under the hood: you stop learning tools and start recognizing
the same machine wearing different clothes.
The honest truth about this phase's difficulty: the algorithm is not hard. A five-way
diff and a topological sort — you'll write it in an afternoon. What's hard is the judgment:
knowing that a -/+ on a stateless thing is fine and on a database is a catastrophe, knowing
when drift should be reverted versus adopted, knowing that state is the fragile thing to
protect and not the config. That judgment is what the interview is actually testing when they
ask "walk me through what happens when you run apply." They're not checking if you can
recite the steps. They're checking if you respect the state file and fear the -/+ — because
that respect is the difference between someone you can trust with production and someone whose
first bad day takes the company down with it.
One more thing, and it's the one that compounds. Write down which resources can never be
replaced, and protect them. Put prevent_destroy on your stateful crown jewels — the
production database, the state storage account itself, the Key Vault. It's a lifecycle flag
that makes the plan fail if anything would destroy them. It feels like overhead until the
day it turns a 2 a.m. catastrophe into a clean error message that says "no." That's the whole
game in this role: turning the irreversible into the merely annoying, on purpose, before you
need it.
Go build the state engine. Read every plan for the -/+ from now on. Then come find me in
Phase 03, where we make identity click — OAuth, OIDC, JWTs — the stuff everyone name-drops and
quietly can't draw on a whiteboard.
— your brother 👨🏻
Lab 01 — Terraform State Engine
Phase: 02 — Terraform, CDKTF & IaC State Internals | Difficulty: ⭐⭐⭐☆☆ | Time: 3–4 hours
ARM (Phase 01) is stateless: every
PUTre-derives reality from the resource provider, so there is no file to drift. Terraform is stateful: it remembers what it created in aterraform.tfstatefile, and the entire tool is the diff between the world you wrote (config) and the world it remembers (state). This lab builds that engine — the per-resource diff, theForceNew"replace not update" decision that destroys real infrastructure, the dependency ordering that flips for deletes, and the drift detector that catches the portal edit nobody told Terraform about.
What you build
A miniature Terraform plan/apply engine over three artifacts — config (desired), state (recorded reality), plan (the diff):
Resource—address("azurerm_storage_account.main"=type.name, the state key),attributes(a flat dict), and the set ofForceNewattribute names.diff_attributes(desired, current)— the leaf comparison: attribute →(old, new)for every changed / added / removed key.plan_resource(desired, current, force_new)— the four-way decision:create(no state) ·no-op(identical) ·update(only mutable attrs moved) ·replace(aForceNewattr moved → destroy-then-create).plan(config, state, force_new_by_type, deps)— the whole diff: classify every resource, mark orphaned state fordelete, and order it — creates/updates/replaces in topological order, deletes in reverse topological order. Returns aPlanwith.actionsand a.summarycount.apply(plan, config, state)— execute the plan, return the new state without mutating the input (the propertyterraform applydepends on to be re-runnable).detect_drift(state, real)—address → {attr: (state_val, real_val)}for resources whose real attributes diverge from recorded state: the out-of-band changeterraform plansurfaces after its refresh.
Key concepts
| Concept | What to understand |
|---|---|
| Three artifacts | config = desired, state = recorded reality, plan = the diff; apply executes the plan |
| The plan algorithm | per resource: compare config vs state → create / update / replace / delete / no-op |
ForceNew → replace | an immutable attribute changed ⇒ destroy-then-create (-/+), not in-place |
| Dependency DAG | build from references; apply in topo order, destroy in reverse topo order |
| Drift | real ≠ state (out-of-band change); refresh surfaces it; state is a claim, not truth |
| Idempotency | re-planning a converged config is all no-op; apply never mutates the input |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and signatures in place |
solution.py | complete reference; python solution.py runs a worked plan + drift example |
test_lab.py | the proof — 28 tests; run them red, make them green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All 28 tests pass against your implementation.
- You can explain why
test_plan_resource_force_new_boundaryis the crux of the lab: the same diff is anupdatewith an emptyforce_newand areplacethe instant one changed attribute is immutable — and areplacedestroys real infrastructure. - You can explain why
test_plan_deletes_orphaned_state_in_reverse_orderreverses the order: you cannot delete a subnet a NIC still sits in, so dependents die first. - You can explain why
applymust not mutate its inputstate(a partially-applied run must leave the on-disk state recoverable; tests assert it stays untouched). - You can read a
terraform planand instantly flag every-/+(replace) line as a potential outage before you typeyes.
How this maps to real Azure (and real Terraform)
| This lab | Real Terraform / AzureRM |
|---|---|
state dict (address → attributes) | terraform.tfstate JSON, stored in an Azure Storage blob backend |
detect_drift | the refresh phase of terraform plan (reads each resource via the AzureRM provider, compares to state) |
plan_resource → replace | the provider schema's ForceNew: true on an attribute (e.g. a storage account's location, account_kind) |
deps map | edges Terraform infers from ${azurerm_x.y.id} references + explicit depends_on |
| reverse-order deletes | Terraform walks the dependency graph backwards to destroy |
apply returns a new state | terraform apply writes the updated state file (and takes a blob lease lock first so a teammate can't apply concurrently) |
Two things the miniature deliberately omits but you must know:
- Locking. Real remote state takes a lease on the Azure Storage blob for the
duration of an apply; a second
applygetsError acquiring the state lock. Without it, two concurrent applies corrupt state. (WARMUP Ch. 6.) create_before_destroy. A replace is destroy-then-create by default, which means downtime; thecreate_before_destroylifecycle flag inverts the order to create the new resource first — but only works when names/addresses don't collide. (WARMUP Ch. 4.)
Extensions (for your own subscription / deeper study)
- Add replace ordering. Make
planemit areplaceas an explicit(address, "destroy")+(address, "create")pair, and support a per-resourcecreate_before_destroyflag that flips their order. Test that a CBD replace creates before it destroys. - Add
terraform import. A function that takes an unmanaged real resource and writes it into state (address → attributes) so the next plan isno-opinstead ofcreate— the real tool's answer to "ClickOps already made this." - Render the plan. Emit the
+ / ~ / -/+ / -symbols and a colored diff per attribute so your output reads like realterraform plan— then diff it against actual Terraform on a throwaway resource group. - Author it in CDKTF. Re-express the worked-example config in CDKTF (Python),
cdktf synthit tocdk.tf.json, and confirm the synthesized JSON feeds the same plan/apply engine — the whole point of CDKTF.
Phase 03 — Microsoft Entra ID: OAuth2 / OIDC / JWT
Difficulty: ⭐⭐⭐☆☆ (the protocol) → ⭐⭐⭐⭐⭐ (the judgment about grants, claims, and the validation order) Estimated Time: 1 week (12–18 hours) Prerequisites: Phase 00 (control plane vs data plane, identity is the perimeter) and Phase 01 (the ARM request pipeline — this phase builds the authenticate gate that pipeline starts with). Comfortable reading JSON. No live Entra tenant required for the lab.
Why This Phase Exists
Phase 01 taught the single most important sentence about the Azure control plane: every
write is one idempotent PUT through ARM, and ARM's first gate is authenticate the
caller's Entra token. This phase opens that gate and builds the machine inside it — because
identity is the perimeter, and in a cloud with no network edge to hide behind, the token
is the security boundary. Get the token wrong and nothing else you built matters.
Here is the thing almost nobody can actually do in an interview, even people who've wired up
"Login with Microsoft" a dozen times: draw the authorization-code + PKCE flow from memory,
say exactly what PKCE defends against, and then list — in order — every check a resource API
must run on the JWT it receives. They can name OAuth2. They cannot tell you why a public
SPA must use PKCE but a daemon must not, why the access token's aud is your API but the ID
token's aud is the client, what the difference between scp and roles signals about who
is calling, or which single skipped check turns your service into a confused deputy that
honors a token minted for somebody else.
Those are not academic distinctions. "We validated the signature but not the aud" is a
real breach shape: service A accepts a perfectly-signed, perfectly-current token that Entra
issued for service B, and now an attacker with legitimate access to B is acting as A.
"alg: none" is a real CVE family. "The clock skew rejected every token for ten minutes" is
a real outage. This phase makes you build the token engine — the base64url codec, the
PKCE proof, the authorization server's two grants, and above all the validator whose check
order is the security — so that OAuth2 stops being three letters you nod at and becomes a
sequence of if statements you can defend line by line. This is the keystone for P04 (RBAC,
which runs after this gate), P09 (the API gateway that runs this validation on every
request), and P12 (Managed Identity, which is just this flow with the secret removed).
What "Principal-Level" Means Here
A senior engineer wires an app to Entra with an SDK and it works. A principal understands the protocol well enough to:
- Pick the right grant on sight — authorization-code+PKCE for a user in a browser/native app, client-credentials for a daemon with no user, on-behalf-of for a middle tier passing the user's identity downstream, device-code for a screen with no keyboard — and explain why each alternative is wrong for the case.
- Read a JWT like a sentence — name what every claim means (
iss,aud,exp,nbf,scp,roles,appid/azp,oid/tid), tell an access token from an ID token by itsaud, and tell a delegated call from an app-only call byscp-vs-roles. - Recite and defend the validation order — structure/
alg→ signature →iss→aud→nbf/exp(with skew) → scope/role — and explain why each step is where it is and what breaks if you reorder or skip one (the confused deputy, thealg:nonebypass, the timing side-channel a non-constant-time compare leaks). - Explain PKCE from first principles —
code_challenge = BASE64URL(SHA256(verifier)), why the one-way hash means an attacker who intercepts the authcodestill can't redeem it, and why this replaced the implicit flow entirely for public clients. - Reason about Conditional Access as a policy engine over signals (user, device, location, risk) that gates token issuance — the layer that turns "valid credentials" into "valid credentials, from a compliant device, in an expected place, with MFA."
Concepts
- The Entra ID model — a tenant is an isolated directory (a GUID); it holds users and groups; an app registration is the global definition of an application (its client id, redirect URIs, secrets/certs, exposed scopes, app roles), and a service principal is that app's local identity in a tenant (what role assignments and consent attach to). One app registration → one service principal per tenant it's used in. Get this app-registration-vs-service-principal distinction cold; it confuses everyone.
- The v2.0 endpoints & discovery —
https://login.microsoftonline.com/{tenant}/oauth2/v2.0/{authorize,token}, and the self-describing OpenID Connect discovery document at…/{tenant}/v2.0/.well-known/openid-configuration, which publishes theissuer, thejwks_uri, and every endpoint so SDKs configure themselves. JWKS is the issuer's published set of public signing keys; the token header'skidselects which one. - OAuth2 grants — and exactly when each is correct. Authorization code (+ PKCE) for an interactive user (the only right answer for SPAs and native/mobile apps). Client credentials for a daemon/service with no user (app-only, authenticates with its own secret or cert). On-behalf-of (OBO) for a middle-tier API that must call a downstream API as the original user. Device code for input- constrained devices (TVs, CLIs, IoT). Choosing wrong is a design smell an interviewer pounces on.
- Access token vs ID token vs refresh token. The access token authorizes a call to a
resource — its
audis the API, and it carriesscp/roles; treat it as opaque and never decode it as a client. The ID token (OIDC) authenticates the user to the client — itsaudis the client, and it carriessub/name/preferred_username. The refresh token buys new access tokens without re-prompting; it never leaves the client. - OIDC: authentication vs authorization. OAuth2 is authorization ("what may this caller
do"); OIDC layers authentication on top ("who is this user") via the ID token and the
userinfo/discovery machinery. Authentication answers who; authorization answers what. A principal never conflates them — and the lab's validator separates them (signature+iss+aud+time = authentication of the token;scp/roles= authorization). - PKCE (RFC 7636). A public client can't keep a secret, so it can't prove at the token
endpoint that it started the login. PKCE fixes it without a secret: send
code_challenge = BASE64URL(SHA256(code_verifier))(method S256) to/authorize, then present the rawcode_verifierat/token; the server re-hashes and compares. An attacker who intercepts thecodecan't redeem it — they never saw the verifier. - JWT structure & the validation ORDER.
base64url(header).base64url(payload).base64url(signature). Validate, failing closed, in this order:kid→ JWKS key → signature →iss→aud→nbf/exp(with clock skew) → authorization claim.scp(space-delimited) is delegated authority (a user delegated it);rolesis app authority (the app itself was granted it). Skipaudand you've built the confused deputy. - Conditional Access (framing). A policy engine: signals (user/group, device compliance,
location/IP, sign-in risk, app) → a decision (grant / block / require MFA / require a
compliant device). It gates token issuance at
/authorize— the modern "Zero Trust" control plane on top of OAuth2.
Labs
Lab 01 — OAuth2 / OIDC / JWT Engine (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build a miniature Entra ID token engine: the base64url codec, the PKCE S256 challenge, an authorization server running the auth-code+PKCE and client-credentials grants offline, HS256 JWT signing, and a JWT validator that runs signature → iss → aud → nbf/exp → scp/roles in order, failing closed and naming the failed check |
| Concepts | base64url (no padding); PKCE one-way proof; access vs ID token (by aud); scp vs roles; the validation order; the confused deputy; alg:none; constant-time compare; clock-skew leeway; single-use codes |
| Steps | 1. b64url_encode/decode; 2. pkce_challenge (S256 + plain); 3. make_jwt/decode_jwt_unverified (HS256); 4. validate_jwt — the seven ordered checks; 5. AuthorizationServer.authorize (mint a PKCE-bound one-time code); 6. token_authorization_code (verifier must hash to the challenge → access + ID tokens); 7. token_client_credentials (secret check → app token with roles) |
| How to Test | pytest test_lab.py -v — 32 tests: PKCE S256 known-answer vector + plain, sign/verify round-trip + determinism, the validator happy path, and a named negative test for every failure (bad signature, tampered payload, alg:none, wrong iss, wrong aud, expired, not-yet-valid, missing scope, missing role), leeway boundaries, the full auth-code+PKCE flow with wrong-verifier rejection and single-use, and client-credentials with a wrong-secret rejection |
| Talking Points | "Draw auth-code + PKCE and tell me what PKCE defends against." / "What does a resource API check on a JWT, in what order, and why that order?" / "scp vs roles — which grant produces each?" / "What's the confused deputy and which check prevents it?" |
| Resume bullet | Built an OAuth2/OIDC authorization-server and JWT-validation engine (authorization-code + PKCE and client-credentials grants, constant-time signature verification, ordered iss/aud/exp/nbf and scp/roles claim checks with clock skew) modeling Microsoft Entra ID |
→ Lab folder: lab-01-oauth-oidc-engine/
Integrated-Scenario Suggestions (carried through the whole track)
The phases compound. Keep these in mind as you build — each plugs the token engine into a larger system:
- SPA → API → downstream API — a browser app uses auth-code+PKCE to call your API
(access token,
aud= your API,scpcarries the user's scopes); your API uses on-behalf-of to call Microsoft Graph as the user. Three audiences, two grants, one user identity preserved end-to-end. (→ P09) - Daemon ingestion job — a nightly service uses client-credentials (no user) to write
to a storage account; its token carries
roles, and the storage RP authorizes via RBAC on the service principal. The grant choice is the identity model. (→ P04, P10) - API gateway authorization — API Management (or your API) validates every inbound JWT —
kid→JWKS→signature→iss→aud→exp→scp/roles— then routes; an invalidaudis a401, a missing scope is a403. This lab'svalidate_jwtis that policy. (→ P09) - CI/CD without stored secrets — a pipeline presents its OIDC token to Entra and gets an Azure access token via workload-identity federation (matching issuer/subject/ audience) — the same JWT validation, applied to a federated credential, with nothing to leak. (→ P08)
- Managed Identity — a Function asks IMDS for a token and gets an Entra access token with no secret at all; downstream APIs validate it exactly like any other Entra JWT. This phase is the protocol; P12 removes the credential. (→ P12)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- Identity is the perimeter, and the JWT is the boundary. In a cloud with no network edge, the token is what stands between your service and the world — so the validator that checks it is the most security-critical code you'll write.
- The grant encodes the identity model. Auth-code+PKCE = a user is present; client-creds = an app acting as itself; OBO = a middle tier acting as the user; device-code = no browser. Choosing the grant is deciding who is calling and how they prove it.
audtells access token from ID token;scp-vs-rolestells delegated from app-only. Two distinctions that, once internalized, make every token legible at a glance.- The validation order is the security. Verify the signature before you read any claim
(it's attacker JSON until then); check
audalways (or you're a confused deputy); pin thealg(or you acceptalg:none); compare in constant time; allow clock skew. Order is not style — reorder it and you have a vulnerability. - PKCE made public clients safe without secrets, which is the same idea that makes Managed Identity (P12) and OIDC federation (P08) the modern, secretless default: prove possession, don't store a password.
Deliverables Checklist
-
Lab 01 implemented; all 32 tests pass against
solution.pyand yourlab.py - You can draw the authorization-code + PKCE flow from memory and say what PKCE defends against
- You can list the JWT validation order and explain why each step is where it is
-
You can explain access token vs ID token (by
aud) andscpvsroles(by grant) - You can explain the confused-deputy attack and the single check that prevents it
- You can pick the correct grant for a SPA, a daemon, a middle tier, and a CLI — and say why the others are wrong
- You can explain app registration vs service principal in one breath
Warmup Guide — Microsoft Entra ID: OAuth2 / OIDC / JWT
Zero-to-principal primer for Phase 03: the Entra identity model (tenant, app registration vs service principal, the v2.0 endpoints, discovery, JWKS), the OAuth2 grants and exactly when each is correct, OIDC and the authentication-vs-authorization line, PKCE from first principles, the JWT wire format and the validation order that is the security, and the Conditional Access framing. Every concept goes from what it is to why it exists to the mechanism under the hood (diagrams, tables, code) to production significance to the misconceptions that get people owned.
Table of Contents
- Chapter 1: Why Identity Is the Perimeter
- Chapter 2: The Entra ID Model — Tenant, App Registration, Service Principal
- Chapter 3: The Endpoints, Discovery, and JWKS
- Chapter 4: OAuth2 — The Grants and When Each Is Correct
- Chapter 5: Access Token vs ID Token vs Refresh Token
- Chapter 6: OIDC — Authentication vs Authorization
- Chapter 7: PKCE — The Proof Key, From First Principles
- Chapter 8: The JWT Wire Format
- Chapter 9: The Validation Order Is the Security
- Chapter 10:
scpvsroles, and the Confused Deputy - Chapter 11: Conditional Access — Signals to Decision
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Identity Is the Perimeter
From zero. For decades, security meant a network perimeter: a firewall, a DMZ, a VPN — "inside is trusted, outside is not." That model dies in the cloud. Your services run on shared infrastructure, talk to managed APIs over the public internet, and scale across regions; there is no wall to stand behind. So the industry moved the boundary from where you are to who you are: identity is the new perimeter, and every single request must carry a verifiable proof of identity that the receiver checks before doing anything.
What it is. In Azure that proof is a JSON Web Token (JWT) minted by Microsoft Entra ID (formerly Azure AD). Every ARM write (Phase 01's pipeline starts by authenticating this token), every Microsoft Graph call, and every API you protect receives a bearer token and must decide: is this real, is it for me, is it current, and does it permit this action?
Why it exists / production significance. Because the alternatives are worse. Shared API keys leak and never expire; IP allow-lists break the moment anything moves; passwords on the wire are a disaster. A short-lived, cryptographically-signed, audience-scoped token solves all three: it expires (so a leak is time-boxed), it's signed (so it can't be forged), and it names its intended audience (so it can't be replayed elsewhere — if you check). The entire rest of this phase is the machinery that issues and validates that token.
Misconception. "We're behind a VNet / private endpoint, so identity is less important." No — defense in depth means the token is still the boundary inside the network too; lateral movement after one foothold is exactly what a stolen-but-unchecked token enables. The network is a layer; identity is the perimeter.
Chapter 2: The Entra ID Model — Tenant, App Registration, Service Principal
From zero. Before any tokens, you need the nouns. Three of them trip up nearly everyone.
- A tenant is an isolated instance of the Entra directory — one organization's identity
universe, identified by a tenant ID (a GUID). Your users, groups, and apps live in a
tenant. The token issuer (
iss) is scoped to a tenant; a token from tenant A is not a token from tenant B, full stop. - Users and groups are the human identities and their collections (groups are how you assign access at scale instead of per-user).
- An app registration is the global definition of an application: its client id
(
appId, a GUID), its redirect URIs, its secrets/certificates (for confidential clients), the scopes it exposes (if it's an API) and app roles it defines, and its Application ID URI (e.g.api://contoso-api). It lives in the tenant where the app was registered (its home tenant). - A service principal is the app's local identity in a tenant — the concrete object that role assignments, consent, and Conditional Access attach to. When an app is used in a tenant, a service principal for it exists there.
Under the hood — the relationship. The cleanest analogy is class vs instance:
| Object-oriented analogy | Entra | |
|---|---|---|
| The blueprint | a class definition | the app registration (one, in the home tenant) |
| The running thing | an instance | a service principal (one per tenant the app is used in) |
| What you grant rights to | the instance | the service principal |
So a multi-tenant app has one app registration (its global definition) and N service principals (one in each tenant that consented to it). You assign an RBAC role (Phase 04) to the service principal, not the registration.
App registration "contoso-api" (home tenant: contoso)
client_id = 11111111-...
exposes scope Files.Read
defines app role Invoices.Process.All
│ (used in two tenants)
├── service principal in tenant CONTOSO ← RBAC + consent attach here
└── service principal in tenant FABRIKAM ← and here, independently
Production significance. "It worked in my tenant but 403s in the customer's" is almost always a missing service principal or consent in the customer tenant, not a code bug. "Where do I grant the daemon access to the storage account?" — to its service principal, by object id.
Misconception. "App registration and service principal are the same thing / are interchangeable." They are not. The registration is the definition; the service principal is the identity-in-a-tenant. The portal blurs them, which is why so few engineers can explain the difference — and why it's a great interview filter.
Chapter 3: The Endpoints, Discovery, and JWKS
From zero. Every tenant exposes a fixed set of OAuth2/OIDC endpoints under
https://login.microsoftonline.com/{tenant}/. The two you'll touch constantly are the v2.0
endpoints:
…/{tenant}/oauth2/v2.0/authorize— the front-channel: a browser redirect where the user authenticates and (in the auth-code flow) a one-timecodecomes back.…/{tenant}/oauth2/v2.0/token— the back-channel: a server-to-serverPOSTthat exchanges a grant (a code, a secret, an assertion) for tokens.
What discovery is. Rather than hard-code those URLs and the signing keys, a client reads the OpenID Connect discovery document:
GET https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration
→ {
"issuer": "https://login.microsoftonline.com/{tid}/v2.0",
"authorization_endpoint": ".../oauth2/v2.0/authorize",
"token_endpoint": ".../oauth2/v2.0/token",
"jwks_uri": "https://login.microsoftonline.com/{tid}/discovery/v2.0/keys",
"id_token_signing_alg_values_supported": ["RS256"],
...
}
Every Entra SDK fetches this on startup so it never hard-codes an endpoint or a key. The
issuer here is the exact string you'll later require a token's iss to equal.
What JWKS is. The jwks_uri returns a JSON Web Key Set — the issuer's public
signing keys:
{ "keys": [
{ "kid": "abc123", "kty": "RSA", "use": "sig", "n": "<base64url-modulus>", "e": "AQAB" },
{ "kid": "def456", "kty": "RSA", "use": "sig", "n": "...", "e": "AQAB" }
]}
Under the hood — how kid ties it together. A signed token's header names a kid (key
id). To verify, you (1) read the unverified header, (2) find the JWKS key whose kid matches,
(3) verify the signature with that public key. Entra rotates these keys, which is why you
fetch JWKS by kid and cache with refresh — never pin one key forever.
In this phase's lab we sign with HS256 (a shared symmetric secret) instead of Entra's RS256 (asymmetric) purely to stay offline and deterministic — no keypair, no network. The
kid→key→signature shape is identical; only the signature primitive differs. The first extension swaps HS256 for RS256+JWKS and you've modeled real Entra exactly.
Misconception. "I'll just download the public key once." Keys rotate; a validator that
caches one key forever starts rejecting every token the day Entra rolls the key. Cache by
kid, refresh on an unknown kid.
Chapter 4: OAuth2 — The Grants and When Each Is Correct
From zero. OAuth2 (RFC 6749) is an authorization framework: it defines how a client obtains an access token to call a resource on behalf of (or as) some principal, without that resource ever seeing a password. The variations are called grants (or flows), and picking the right one is the first design decision — and a favorite interview probe, because the wrong grant is a security smell.
The four that matter in Azure:
| Grant | Who's calling | Credential | Returns | Use it for |
|---|---|---|---|---|
| Authorization code + PKCE | a user, interactively | the user's login + a PKCE proof | access + ID + refresh | SPAs, native/mobile, any user-present web app |
| Client credentials | an app, as itself (no user) | the app's secret or cert | access (roles), no ID token | daemons, cron jobs, service-to-service |
| On-behalf-of (OBO) | a middle-tier API, as the user | the incoming user access token | a downstream access token | API calls a second API keeping the user's identity |
| Device code | a user on a screen with no browser | a user_code typed on another device | access + ID + refresh | TVs, CLIs, IoT |
Under the hood — the canonical one (authorization code + PKCE):
user client (SPA/native) Entra /authorize Entra /token resource API
│ │ │ │ │
│ click "Sign in" │ │ │ │
│────────────────►│ redirect with │ │ │
│ │ client_id, redirect_uri, │ │ │
│ │ scope, code_challenge ──────►│ (user authenticates,│ │
│ │ │ Conditional Access) │ │
│ │◄──── redirect back with ─────│ │ │
│ │ one-time `code` │ │ │
│ │ POST code + code_verifier ─────────────────────────►│ verify verifier │
│ │ │ │ hashes to challenge│
│ │◄──── access_token + id_token (+ refresh_token) ──────│ │
│ │ call API with Bearer access_token ──────────────────────────────────────►│
│ │ │ │ validate JWT ────►│
Why each exists / when it's wrong.
- Authorization code + PKCE is the only correct flow for a user in a browser or native
app. The older implicit flow (token straight from
/authorize) is dead — it leaked tokens in URLs and browser history. PKCE made auth-code safe even for clients that can't keep a secret (Chapter 7), so it replaced implicit entirely. - Client credentials is for no user. Using it where a user is present means you've lost the user's identity (everything becomes "the app did it"), which breaks auditing and least-privilege. Using auth-code for a daemon means there's no human to log in — it can't work.
- On-behalf-of is the only way to call a downstream API as the user from a middle tier. The wrong move — having the middle tier use client-credentials — silently elevates every downstream call to the app's permissions instead of the user's.
- Device code exists because some clients have no browser to redirect; using it elsewhere is just a worse UX.
Production significance. The grant is the identity model. When someone says "the API calls Graph," your first question is "as the user (OBO) or as the app (client-creds)?" — the answer changes the permissions, the audit trail, and the blast radius.
Chapter 5: Access Token vs ID Token vs Refresh Token
From zero. A flow can hand back up to three tokens, and conflating them is a top-three OAuth2 mistake.
| Token | Answers | aud (audience) | Carries | Who reads it |
|---|---|---|---|---|
| Access token | "may this caller do X to the resource?" | the API (api://contoso-api) | scp or roles, oid, exp… | the resource API |
| ID token | "who is this user, to the client?" | the client (its client_id) | sub, name, preferred_username… | the client app |
| Refresh token | "give me a new access token" | (opaque to the client) | nothing you parse | sent back to Entra only |
Under the hood — the aud is the tell. The same login produces an access token whose
aud is the API and an ID token whose aud is the client. That's not a detail — it's the
whole point:
- The client must never use the access token's contents to make decisions; to the client it's an opaque string to forward. Decoding and trusting an access token in the front-end is a classic bug.
- The API must never accept an ID token in place of an access token — the ID token's
audis the client, not the API, so a correct validator (Chapter 9) rejects it on theaudcheck. (This is a real attack: present the ID token to the API and hope it doesn't checkaud.)
Production significance. "Login works but the API rejects my token" is often the front-end
sending the ID token (its aud is the client) where the API wants the access token (aud
= the API). The aud mismatch is the validator doing its job.
Misconception. "A token is a token." No — three tokens, three audiences, three readers.
Read the aud and you know instantly which one you're holding and whether it's for you.
Chapter 6: OIDC — Authentication vs Authorization
From zero. OAuth2 alone is about authorization — "what may this caller do." It was
not designed to tell a client who the user is; people bolted that on insecurely for years.
OpenID Connect (OIDC) is the standard layer that fixes it: it adds the ID token (a JWT
proving the user's identity to the client), the discovery document (Chapter 3), and a
userinfo endpoint.
The one sentence to carry forever:
Authentication answers who you are. Authorization answers what you may do. OIDC does the first; OAuth2 does the second; they ride together but are different questions.
Under the hood — where the line falls in your validator. This phase's validate_jwt
literally splits along it:
AUTHENTICATION of the token (is it real, current, for me, from whom I trust):
structure / alg → signature → iss → aud → nbf/exp
AUTHORIZATION (may the caller do this specific thing):
required_scopes ⊆ scp and/or required_roles ⊆ roles
A 401 Unauthorized is an authentication failure (bad/absent/expired/wrong-audience
token). A 403 Forbidden is an authorization failure (a valid token that lacks the scope or
role). Returning the wrong code leaks information and confuses clients; the distinction maps
exactly onto the two halves above.
Production significance. Interviewers love "what's the difference between authn and authz?" — and they love it more when you say "and here's where the line falls in a JWT validator," because that proves you've implemented the distinction, not just defined it.
Misconception. "OIDC replaces OAuth2." No — OIDC is built on OAuth2; you use both at once. The ID token (OIDC) authenticates the user to the client; the access token (OAuth2) authorizes the call to the API.
Chapter 7: PKCE — The Proof Key, From First Principles
From zero. A confidential client (a server) can keep a secret, so at the token
endpoint it can prove "I'm the app that started this login" by presenting that secret. A
public client — a SPA whose code ships to the browser, a mobile app on a user's phone —
cannot keep a secret; anything you embed is extractable. So how does a public client prove,
at /token, that it (and not an attacker who intercepted the code) started the flow?
What PKCE is. Proof Key for Code Exchange (RFC 7636) solves it with a one-time, per- login proof and no stored secret:
- The client invents a high-entropy random
code_verifier(43–128 chars). - It computes
code_challenge = BASE64URL(SHA256(code_verifier))and sends only the challenge to/authorize(methodS256). - At
/token, it presents the rawcode_verifier. - The server re-computes
BASE64URL(SHA256(verifier))and checks it equals the stored challenge.
Under the hood — why it defeats interception. SHA-256 is a one-way function. Write the challenge as
$$ \text{challenge} = \mathrm{BASE64URL}\big(\mathrm{SHA256}(\text{verifier})\big) $$
An attacker who intercepts the redirect sees the challenge but not the verifier, and
cannot invert the hash to recover it. An attacker who intercepts the code (the prize in
an auth-code interception attack) still can't redeem it at /token, because they can't supply
the matching verifier. The flow is bound to whoever generated the verifier.
attacker sees: challenge ✗ (one-way)→ verifier # can't go backward
attacker steals: the code ✗ (no verifier)→ tokens # code alone is useless
only the real client knows: verifier → redeems code # because it made it
S256 is the method you want. plain (where challenge == verifier, no hash) exists only
for clients that genuinely can't SHA-256, and it's a downgrade — an interceptor of the
challenge would have the verifier — so a production server should reject plain.
Production significance. PKCE is now mandatory for public clients and recommended for all clients (OAuth 2.1 folds it in). It is the reason the implicit flow is dead. The deeper lesson — prove possession instead of storing a password — is the same idea behind Managed Identity (P12) and OIDC federation (P08): the modern secretless default.
Misconception. "PKCE replaces the client secret." For public clients there was no usable secret to replace — PKCE is what makes secret-less safe. For confidential clients you use both (PKCE and the secret). And PKCE protects the code, not the token; you still validate the token (Chapter 9).
Chapter 8: The JWT Wire Format
From zero. A JWT is three base64url segments joined by dots:
base64url(header) . base64url(payload) . base64url(signature)
eyJhbGciOiJI... . eyJpc3MiOiJod... . dBjftJeZ4CV...
- header — JSON like
{"alg":"HS256","typ":"JWT","kid":"abc123"}: the signature algorithm and the key id. - payload — the claims JSON (the data):
iss,aud,exp,scp, etc. - signature —
SIGN(key, base64url(header) + "." + base64url(payload)).
Crucial mechanical detail. The signature is computed over the encoded segments — the
exact ASCII bytes header_b64 + "." + payload_b64 — not over the raw JSON. To verify you
re-sign those same bytes and compare. A common bug is to JSON-parse, re-serialize, and
re-sign — but JSON re-serialization can change byte order or spacing and the signature won't
match. Verify the bytes that were signed.
base64url, not base64. JOSE uses URL-safe base64 (+→-, /→_) with the = padding
stripped. Decoders must re-add padding to a multiple of four before decoding. Using
standard base64 or leaving padding on is the "works locally, 401s in Entra" classic.
The claims you must know cold:
| Claim | Meaning |
|---|---|
iss | issuer — the tenant authority that minted the token |
aud | audience — who the token is for (string or list) |
sub | subject — the principal (a stable user id within the app) |
exp | expiry — epoch seconds after which it's invalid |
nbf | not-before — epoch seconds before which it's invalid |
iat | issued-at |
scp | delegated scopes, space-delimited (a user delegated these) |
roles | app roles (the app was granted these; no user) |
appid/azp | the client application that obtained the token |
oid / tid | the principal's object id / the tenant id |
Misconception. "A JWT is encrypted, so it's safe to put secrets in it." A signed JWT
(JWS) is signed, not encrypted — anyone can base64url-decode and read the payload
(decode_jwt_unverified does exactly that). Never put a secret in a JWT payload. The
signature gives integrity, not confidentiality.
Chapter 9: The Validation Order Is the Security
This is the chapter the whole lab exists for. A resource API that receives a bearer token must answer, in order, failing closed (any miss → reject):
1. structure / alg parse 3 segments; header.alg == the algorithm I expect
(reject `alg: none` and algorithm-substitution)
2. signature recompute and constant-time-compare (hmac.compare_digest)
─────────────── ↑ EVERYTHING ABOVE THIS LINE IS UNTRUSTED ATTACKER JSON ↑
3. iss == my expected issuer (the tenant I trust)
4. aud my API ∈ the token's audience(s) ← skip → CONFUSED DEPUTY
5. nbf nbf ≤ now + leeway (token already valid, modulo skew)
6. exp exp > now − leeway (token not expired, modulo skew)
7. authorization required_scopes ⊆ scp and/or required_roles ⊆ roles
Why this order — under the hood.
- Signature first, before any claim. Until the signature verifies, the payload is just
bytes an attacker controls. Reading
issorexpfrom an unverified token and acting on it is trusting attacker input. So the cryptographic gate comes before every claim check. Everything below the line in the diagram is only safe to read because the signature passed. - Pin the
alg. If you trust whateveralgthe header claims, an attacker setsalg: "none"and ships an unsigned token — and a naive verifier "verifies" an empty signature and passes it. (There's a sibling attack: an RS256 verifier tricked into treating the RSA public key as an HS256 secret.) Defense: accept only the algorithm you expect. The lab's validator rejects anything butHS256; a real one pinsRS256. - Constant-time signature compare. Comparing the signature with a normal
==that short-circuits on the first differing byte leaks, via timing, how many leading bytes matched — enough to forge a signature byte-by-byte over many requests. Usehmac.compare_digest, which compares in time independent of where the mismatch is. audis not optional. See Chapter 10 — skipping it is the confused-deputy vulnerability.nbf/expwith leeway. Clocks drift. Entra allows ±5 minutes (300 s) of skew; a validator with zero tolerance rejects legitimate tokens whenever an NTP server hiccups. The boundary is the off-by-one interviewers probe: with leeway $L$ and current time $t$, a token is expired iff $exp \le t - L$ and not-yet-valid iff $nbf > t + L$.- Authorization last. A token can be perfectly authentic and still not permit this
action. Scope/role checks are authorization (a
403), not authentication (a401), so they come after the token is proven real.
Mechanism, in code (the heart of the lab):
# 2. signature — constant-time, after pinning alg to HS256
signing_input = f"{seg0}.{seg1}".encode("ascii")
expected = hmac.new(key, signing_input, hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url_decode(seg2)):
raise JWTValidationError("signature: HMAC verification failed")
# ── below here the payload is trusted ──
# 4. aud — string OR list on both sides; membership, not equality
if token_auds.isdisjoint(wanted_auds):
raise JWTValidationError("aud: ...") # confused-deputy guard
# 6. exp — strict, with leeway
if exp is not None and exp <= now - leeway:
raise JWTValidationError("exp: token expired")
Production significance. Most real-world JWT breaches are a missing or reordered check:
no aud (confused deputy), no alg pin (alg:none), a non-constant-time compare (timing),
or zero skew (self-inflicted outage). The order is not style — it's the security.
Chapter 10: scp vs roles, and the Confused Deputy
From zero — the two authorization claims. After a token is proven authentic, how it authorizes depends on which grant minted it:
scp(scope) — a space-delimited string like"Files.Read User.Read". It means delegated permission: a user is present and delegated these scopes to the client. Produced by the authorization-code flow.roles— a JSON array like["Invoices.Process.All"]. It means application permission: no user — the app itself was granted these roles. Produced by the client-credentials flow.
So scp vs roles is how a validator tells "a user delegated this" from "an app is
acting as itself" — which changes what you log, what you audit, and how much you trust the
call. The lab's two grants produce exactly these two shapes (and the tests assert the
client-credentials token has roles and no scp).
The confused deputy — what it is. A deputy is a program with more authority than its caller (your API can read the database; the caller can't directly). A confused deputy is tricked into misusing its authority on behalf of someone who shouldn't have it. With JWTs the attack is concrete:
Entra issues a valid token aud = api://SERVICE-B to a caller legitimately using B.
The caller replays that exact token to your SERVICE-A.
If A verifies the signature, iss, and exp — but NOT aud — A accepts it.
→ A token minted for B now acts on A. A is the confused deputy.
The token is genuinely signed by Entra and not expired — every check passes except the
one that asks "was this token minted for ME?" That one check — aud contains my API — is
the entire defense. It is why Chapter 9 puts aud in the required path and the cheat sheet
says never trust a token you didn't validate aud on.
Production significance. Microservice meshes are full of services that share an issuer and
a signing key; the only thing keeping service A from honoring service B's tokens is the
aud check. Skip it once and any service can impersonate any other.
Misconception. "If the signature is valid the token is safe." Validity proves it came from Entra unmodified — it says nothing about who it was for. Authenticity without audience is the confused deputy.
Chapter 11: Conditional Access — Signals to Decision
From zero. Everything so far validates a token once it exists. Conditional Access (CA)
is the policy engine that decides whether to issue one — the "Zero Trust" control plane that
runs at /authorize before the token is minted.
Under the hood — signals to a decision. A CA policy is, in effect, IF signals THEN control:
| Signal (the "if") | Example |
|---|---|
| user / group | "members of Finance" |
| application | "accessing the Finance API" |
| location / IP | "from outside the corporate network" |
| device state | "non-compliant or unmanaged device" |
| sign-in risk | "Entra ID Protection flags this sign-in as risky" |
| Control (the "then") | Effect |
|---|---|
| block | deny the sign-in outright |
| grant + require MFA | issue only after a second factor |
| require compliant device | issue only from a managed/compliant device |
| require app-protection policy | issue only to a managed app |
sign-in attempt
│ collect signals: who, which app, where, what device, how risky
▼
evaluate CA policies (all matching policies combine; block wins)
│
├── all satisfied ────────────► issue token (the flows in Ch. 4)
├── needs MFA ───────────────► challenge, then issue
└── blocked ─────────────────► no token
Production significance. CA is how "valid password" becomes "valid password, from a compliant laptop, in an expected country, with MFA, for a non-risky sign-in." It's the layer that contains stolen credentials: the password alone no longer gets a token. You don't build CA in this lab, but a principal frames token issuance as conditional, not automatic.
Misconception. "MFA and Conditional Access are the same." MFA is one possible control; CA is the engine that decides when to require MFA (or block, or demand a compliant device) based on signals.
Lab Walkthrough Guidance
Lab 01 — OAuth2 / OIDC / JWT Engine, suggested order:
b64url_encode/b64url_decode(Ch. 8) — URL-safe base64, padding stripped on encode and re-added on decode. Test the round-trip and that no=survives.pkce_challenge(verifier, method)(Ch. 7) —S256=b64url(sha256(verifier)),plainreturns the verifier, unknown method raises. The test recomputes the expected challenge withhashlib.sha256(a known-answer vector — it equals the RFC 7636 Appendix B valueE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cMfor the standard's verifier).make_jwt/decode_jwt_unverified(Ch. 8) — sign HS256 over the encoded segments (sorted-key JSON so it's deterministic); decode without verifying. Round-trip and determinism tests.validate_jwt(Ch. 9–10) — the seven ordered checks, failing closed and naming the failed check. This is where the negative tests live: bad signature, tampered payload,alg:none, wrongiss, wrongaud, expired, not-yet-valid, missing scope, missing role, and the leeway boundaries.AuthorizationServer.authorize(Ch. 4, 7) — validate client/redirect/scope, store a PKCE-bound one-time code, return it.token_authorization_code(Ch. 4, 5, 7) — pop the code (single-use, even on failure), re-derive the challenge from the verifier and constant-time-compare, then sign the access token (scp, user,aud= API) and the ID token (aud= client). Test wrong-verifier rejection and single-use replay.token_client_credentials(Ch. 4, 10) — constant-time secret check, then a token withrolesand noscp, no ID token. Test the wrong-secret rejection.
Run it red, make it green: pytest test_lab.py -v, then LAB_MODULE=solution pytest test_lab.py -v, then python solution.py for the worked flow.
Success Criteria
You are ready for Phase 04 when you can, from memory:
- Explain app registration vs service principal (blueprint vs instance-in-a-tenant) and say which one you grant RBAC to.
- Draw the authorization-code + PKCE flow and state precisely what PKCE defends against and why the hash makes it work.
- Pick the correct grant for a SPA, a daemon, a middle tier, and a CLI — and say why each alternative is wrong.
- Tell an access token from an ID token by its
aud, and a delegated call from an app-only call byscp-vs-roles. - Recite the JWT validation order and justify each position — why signature precedes every
claim, why
audis mandatory, whyalgis pinned, why the compare is constant-time, why there's clock-skew leeway. - Define the confused deputy and name the single check that prevents it.
- Frame Conditional Access as
signals → decisionand place it at token issuance.
Interview Q&A
Q: Walk me through the authorization-code + PKCE flow. What does PKCE defend against?
The client redirects the user to /authorize with its client_id, redirect_uri, requested
scope, and a code_challenge = BASE64URL(SHA256(code_verifier)). The user authenticates
(Conditional Access runs here), and Entra redirects back with a one-time code. The client
then POSTs that code plus the raw code_verifier to /token; Entra re-hashes the
verifier, checks it equals the stored challenge, and returns the access + ID (+ refresh)
tokens. PKCE defends against authorization-code interception: an attacker who steals the
code can't redeem it because they never saw the verifier and can't invert the SHA-256 of the
challenge. It's what makes auth-code safe for public clients that can't keep a secret — which
is why the implicit flow is dead.
Q: Exactly what does a resource API check on an incoming JWT, and in what order?
Failing closed, in order: (1) structure and that alg is the algorithm I expect — reject
alg:none; (2) the signature, constant-time, against the issuer's key picked by kid —
and nothing below here is trustworthy until this passes; (3) iss equals the tenant I trust;
(4) aud contains my API — skip this and I'm a confused deputy; (5) nbf <= now + leeway;
(6) exp > now - leeway, with ~300 s skew; (7) authorization — the required scp (delegated)
or roles (app) is present. The order matters: I never read a claim I haven't verified the
signature on, and authorization (a 403) only happens after the token is proven authentic (or
it's a 401).
Q: What's the difference between scp and roles?
scp is a space-delimited string of delegated scopes — a user is present and delegated
those permissions to the app; it comes from the authorization-code flow. roles is an array
of app roles — no user, the application itself was granted them; it comes from the
client-credentials flow. So the validator reads scp vs roles to know whether a human
delegated this action or an app is acting as itself — which changes auditing and trust.
Q: What's the confused-deputy attack and which single check prevents it?
Entra mints a valid, signed, unexpired token whose aud is service B. An attacker replays it
to service A. If A checks the signature, iss, and exp but not aud, A accepts a token
that was never meant for it — A is the confused deputy, acting on B's authorization. The single
defense is the aud check: require that my API is in the token's audience. Authenticity
proves Entra issued it; only aud proves it was issued for me.
Q: Access token vs ID token — what's each for and who's the audience?
The access token authorizes a call to a resource; its aud is the API, it carries
scp/roles, and only the API should read it (it's opaque to the client). The ID token
(OIDC) authenticates the user to the client; its aud is the client, it carries
sub/name/preferred_username, and the client reads it to know who logged in. A correct API
rejects an ID token on the aud check, because the ID token isn't for the API.
Q: Why HS256 in the lab but RS256 in real Entra — does it change the validation?
HS256 is symmetric (one shared secret signs and verifies); we use it to stay offline and
deterministic with no keypair. RS256 is asymmetric — Entra signs with a private key you never
see and you verify with the public key from the tenant's JWKS, selected by the token's
kid. The only thing that changes is the signature primitive and key management (fetch + cache
JWKS by kid, handle rotation). The order, the claims (iss/aud/exp/nbf/scp/roles),
and the confused-deputy and alg:none defenses are identical.
Q: App registration vs service principal? The app registration is the global definition of an app (client id, redirect URIs, secrets, exposed scopes, app roles) — one, in its home tenant. The service principal is the app's local identity in a tenant — the object that RBAC role assignments, admin consent, and Conditional Access attach to; there's one per tenant the app is used in. Blueprint vs instance. You grant access to the service principal, not the registration.
References
- RFC 6749 — The OAuth 2.0 Authorization Framework (the grants: authorization code, client credentials, refresh) — https://www.rfc-editor.org/rfc/rfc6749
- RFC 7636 — Proof Key for Code Exchange (PKCE) (
code_verifier/code_challenge,S256, the Appendix B test vector) — https://www.rfc-editor.org/rfc/rfc7636 - RFC 7519 — JSON Web Token (JWT) (claims:
iss/aud/exp/nbf/sub) — https://www.rfc-editor.org/rfc/rfc7519 - RFC 7515 — JSON Web Signature (JWS) (the base64url-segments wire format and signing input) — https://www.rfc-editor.org/rfc/rfc7515
- OpenID Connect Core 1.0 — the ID token and authentication layer — https://openid.net/specs/openid-connect-core-1_0.html
- Microsoft identity platform — the v2.0 endpoints, the supported flows, access-token and ID-token claims, and validation guidance — https://learn.microsoft.com/entra/identity-platform/ (see Authentication flows and app scenarios, Access tokens, ID tokens, and Validating tokens).
- Conditional Access — What is Conditional Access? — https://learn.microsoft.com/entra/identity/conditional-access/overview
- The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 03: Entra ID, OAuth2 / OIDC / JWT
Read this if: you've clicked "Login with Microsoft" and wired up an SDK, but couldn't draw the auth-code + PKCE flow or list — in order — what an API checks on the JWT it receives. This is the compressed tour the WARMUP derives slowly. Skim it, read the WARMUP, then build the engine.
0. The 30-second mental model
Identity is the perimeter; the JWT is the boundary. Entra ID mints a short-lived, signed,
audience-scoped token; your service validates it on every request before doing anything. A
login is: client → /authorize (user signs in, get a one-time code) → /token (swap code
- PKCE proof for tokens) → call the API with
Bearer <access_token>→ the API validates the JWT. The whole security of it is one ordered checklist: signature first, theniss, thenaud, then time, then scope/role — fail closed on any miss.
One sentence to tattoo: a valid signature proves the token is real; only the aud check
proves it was minted for YOU — skip aud and you're a confused deputy.
1. Pick the grant in one breath
user in a browser / native app → authorization code + PKCE (access + ID + refresh)
daemon, cron, service-to-service → client credentials (access w/ `roles`, no user)
middle-tier API calling an API → on-behalf-of (OBO) (downstream token AS the user)
TV / CLI / IoT (no browser) → device code (type a code on your phone)
The grant is the identity model. "API calls Graph" → ask: as the user (OBO) or as the app (client-creds)? Different permissions, different audit trail.
2. The token claims to memorize
| Claim | Meaning | Trap |
|---|---|---|
iss | issuer (the tenant) | must equal your expected issuer exactly |
aud | who it's for (string OR list) | skip the check → confused deputy |
exp / nbf | valid window (epoch s) | check with ±300 s skew, strict at the edge |
scp | delegated scopes, space-delimited | a user is present |
roles | app roles, array | no user — app acting as itself |
appid/azp | the client app | who got the token |
oid / tid | object id / tenant id | the principal and its tenant |
kid (header) | which JWKS key signed it | pick the public key by this |
alg (header) | the signature algorithm | pin it — reject alg:none |
Access token: aud = the API, carries scp/roles. ID token: aud = the client,
proves who the user is. Refresh token: opaque, goes back to Entra only.
3. The validation order (fail closed on any miss)
1. parse header → pick key by `kid` from JWKS; alg == what you expect (no `alg:none`)
2. verify signature (constant-time compare — hmac.compare_digest)
─────────────────────── everything above is untrusted attacker JSON ───────────────────────
3. iss == expected issuer (the tenant)
4. aud contains your API ← the confused-deputy guard; never skip
5. nbf ≤ now ≤ exp (apply ±clock-skew, default 300 s)
6. authorize: scp (delegated) or roles (app) contains the required value
401 = authentication failed (1–5: bad/absent/expired/wrong-audience). 403 = authorization
failed (6: valid token, missing scope/role). Don't swap the codes.
4. PKCE in one picture
verifier ──SHA256──► challenge client sends only `challenge` to /authorize
(secret to client) client sends raw `verifier` to /token
challenge = BASE64URL(SHA256(verifier)) # method S256 (the only one to use)
Attacker steals the code but never the verifier, and can't invert the hash → can't redeem
it. PKCE protects the code; you still validate the token. plain (no hash) is a downgrade
— reject it.
5. The numbers & names to tattoo on your arm
| Thing | Value |
|---|---|
| Clock-skew leeway | ±300 s (5 min) — Entra's own allowance |
| PKCE challenge | BASE64URL(SHA256(verifier)), method S256 |
| PKCE verifier length | 43–128 chars, high entropy |
| Entra signing alg | RS256 (asymmetric; verify w/ JWKS public key by kid) |
| base64url | +→-, /→_, no = padding |
| Discovery doc | …/{tenant}/v2.0/.well-known/openid-configuration |
| JWKS endpoint | …/{tenant}/discovery/v2.0/keys |
| v2.0 issuer | https://login.microsoftonline.com/{tid}/v2.0 |
| Access-token lifetime | ~60–90 min (then use the refresh token) |
scp vs roles | delegated (user) vs app (daemon) |
| Implicit flow | dead — PKCE replaced it |
6. az / curl one-liners you'll actually type
# Read the discovery document (endpoints + issuer + jwks_uri) for a tenant
curl -s https://login.microsoftonline.com/<tenant>/v2.0/.well-known/openid-configuration | jq .
# Fetch the tenant's public signing keys (JWKS)
curl -s https://login.microsoftonline.com/<tenant>/discovery/v2.0/keys | jq '.keys[].kid'
# Get an access token as yourself (then paste it on https://jwt.ms to decode)
az account get-access-token --resource api://<your-api-app-id-uri> --query accessToken -o tsv
# Client-credentials (daemon) token by hand — note grant_type and .default scope
curl -s -X POST https://login.microsoftonline.com/<tenant>/oauth2/v2.0/token \
-d grant_type=client_credentials \
-d client_id=<app-id> -d client_secret=<secret> \
-d scope=api://<your-api>/.default | jq '.access_token'
# Register an app + service principal; add an app role / API permission
az ad app create --display-name my-api
az ad sp create --id <appId> # the service principal (grant RBAC to THIS)
az ad app permission add --id <clientAppId> --api <resourceAppId> --api-permissions <scopeId>=Scope
# Inspect a token's claims locally (jq the middle segment)
echo "$TOKEN" | cut -d. -f2 | tr '_-' '/+' | base64 -d 2>/dev/null | jq .
7. War story shapes you'll relive
- "Valid token, wrong service." → a token minted for service B accepted by service A; A
never checked
aud. The confused deputy. Add theaudcheck; it's the whole defense. - "Login works, the API 401s." → the front-end is sending the ID token (
aud= the client) where the API wants the access token (aud= the API). The validator is right. - "Every token rejected for 10 minutes." → zero clock-skew tolerance + an NTP hiccup, or a
rotated JWKS key the validator cached forever. Add ±300 s leeway; cache JWKS by
kidand refresh on an unknownkid. - "Someone forged an admin token." →
alg:noneaccepted (no algorithm pin) or a non-constant-time signature compare leaked the bytes. Pin the alg; usehmac.compare_digest. - "It works in our tenant, 403s in the customer's." → no service principal / no admin consent in the customer tenant. The app registration isn't the identity; the SP is.
- "The daemon can't act as the user." → it used client-credentials (app
roles), so every downstream call is "the app," not the user. It needed on-behalf-of.
8. Vocabulary that signals you've held the pager
- Confused deputy — a valid token for B accepted by A because A skipped
aud. alg:noneattack — stripping the signature by claiming "no algorithm"; pin the alg.- PKCE —
challenge = BASE64URL(SHA256(verifier)); secret-less proof for public clients. scpvsroles— delegated (user present) vs app (daemon, no user).- App registration vs service principal — blueprint vs instance-in-a-tenant; grant to the SP.
- JWKS /
kid— the issuer's public keys; the header'skidselects which one. - On-behalf-of (OBO) — a middle tier calling downstream as the user.
- Discovery —
/.well-known/openid-configuration; how SDKs self-configure. - Bearer token — "whoever holds it can use it" → short-lived, audience-scoped, over TLS.
- Authn vs authz — who you are (
401) vs what you may do (403).
9. Beginner mistakes that mark you in interviews
- Saying "OAuth" when you mean OIDC (or thinking OIDC replaces OAuth — it's built on it).
- Picking client-credentials where a user is present (you lost the user's identity).
- Using the implicit flow (it's dead) instead of auth-code + PKCE for a SPA.
- Decoding/trusting the access token in the front-end (it's opaque to the client).
- Validating the signature but not
aud(the confused deputy) — or not pinningalg. - Comparing the signature with
==(timing side channel) instead ofhmac.compare_digest. - Zero clock-skew tolerance, or caching one JWKS key forever (self-inflicted outage).
- Confusing app registration with service principal — and not knowing which gets the RBAC.
- Calling a signed JWT "encrypted" and putting a secret in the payload (it's readable).
10. How this phase pays off later
- The validation order is exactly what the API gateway runs on every request (P09).
- Identity is the perimeter is the first gate of the ARM request pipeline (P01) and the input to RBAC evaluation (P04, which runs after this authenticates the caller).
- PKCE's "prove possession, don't store a secret" is the same idea behind OIDC workload-identity federation (P08, secretless CI) and Managed Identity (P12, the token from IMDS with no credential).
scp/roles+audis how every secured Azure resource decides who may touch it.
Now read the WARMUP slowly, then build the engine. After this, no token is a black box — and "is this token safe to trust?" becomes a checklist you can recite under pressure.
👨🏻 Brother Talk — Phase 03, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. Almost everyone who's "done auth" has wired an app to Entra with an SDK, watched the
login pop up, and moved on. What almost nobody can do — and what instantly separates a senior
from a principal in a security or platform interview — is draw the token flow from memory and
then list, in order, every check a service runs on the JWT it receives, and say what breaks if
you skip or reorder one. That gap — the inside of the token machine — is this whole phase.
Once you can narrate it, OAuth2 stops being three letters you nod at and becomes a sequence of
if statements you can defend line by line. And those if statements are, no exaggeration, the
most security-critical code you will ever write. Get them wrong and the firewall, the VNet, the
encryption — none of it matters, because the attacker walked in the front door holding a valid-
looking badge.
Here's the thing that took me too long to internalize: the SDK hides the part that matters.
acquireToken() and [Authorize] are beautiful until the day they're not — until a token is
getting rejected for a reason you can't see, or worse, accepted for a reason you didn't think
about. The engineers who get owned are the ones who treated auth as a library call. The ones who
go principal treated it as a protocol they could implement by hand. That's why we build the
validator in this lab from hashlib and hmac — not because you'll ship HS256 (you won't,
real Entra is RS256), but because once you've written the seven ordered checks, you own them.
They're not magic anymore. You can debug them at 2 a.m. and reason about them in a design review.
Now let me give you the things that actually bite people.
The aud check is the one. Love it, never skip it. I want you to overweight this, because
the industry under-weights it and it's where the real breaches live. A token can be perfectly
signed by Entra, perfectly current, from exactly the tenant you trust — and still be a weapon
against you, because it was minted for a different service and someone replayed it at yours.
The signature proves it's real. Only aud proves it's for you. That's the confused deputy,
and it is shockingly common in microservice meshes where everyone shares an issuer and a key.
When you can say in an interview "I verify the signature and I require my API in the aud,
because authenticity isn't audience" — that one sentence tells them you've actually thought
about token security instead of trusting a middleware default. Tattoo it: valid is not the same
as for-me.
Respect the order like it's load-bearing, because it is. Signature before you read any
claim — because until the signature verifies, that payload is just bytes an attacker handed you,
and reading exp or iss from it and acting on it is trusting the attacker. Pin the algorithm,
or someone sets alg:none and ships you an unsigned token your naive verifier happily "verifies."
Compare the signature in constant time, or you leak it byte-by-byte over a thousand requests.
Allow clock skew, or you take yourself down the next time an NTP server sneezes. None of these is
style. Each one is a real attack or a real outage with a name. The order is the security, and
the principal move is being able to justify every position in it, not just recite the list.
Understand authn vs authz so deeply it's boring. Who you are versus what you may do. A 401
versus a 403. The first half of your validator (signature, iss, aud, time) authenticates
the token; the second half (scp/roles) authorizes the action. People who blur this return
the wrong status codes, leak whether a resource exists, and build systems where "logged in" gets
confused with "allowed." When an interviewer asks the tired "what's the difference between
authentication and authorization" — most candidates recite a textbook line. You say "and here's
exactly where the line falls inside a JWT validator," and point at the two halves. That's the
difference between knowing the definition and having built the distinction.
The grant choice is a judgment call, and they're testing your judgment. When someone says "the service calls Graph," the junior says "use client credentials" because that's the snippet they remember. The principal says "as the app or as the user? Because if a user kicked this off and we use client-creds, we just elevated every downstream call to the app's permissions and erased the user from the audit trail — we want on-behalf-of." Same with SPAs: if you reach for the implicit flow, you've told them you stopped learning around 2018. Auth-code + PKCE, every time, for anything with a user and a browser. The grant is the identity model — picking it is the actual engineering, and picking wrong is the smell they're sniffing for.
The secretless thing is the future, and this phase is its foundation. PKCE's whole trick is prove you possess the verifier, don't store a password. Sit with that, because it's the same idea that runs the rest of your career: Managed Identity (P12) is a token from IMDS with no secret at all; OIDC workload-identity federation (P08) is your CI pipeline trading its own OIDC token for an Azure token with nothing stored to leak. The arc of cloud security is "stop storing secrets, start proving possession," and PKCE is where you first see the idea cleanly. The engineer who internalizes that stops asking "where do I put the client secret" and starts asking "why do I have one at all" — and that question is worth a level.
The honest truth about this phase: the code is not hard. HMAC is a library call, base64url
is twenty minutes, the flow is a handful of HTTP redirects. What makes this principal-level is
that it's unforgiving — security code that's 95% right is 0% secure, and the missing 5% is
always a check you skipped or mis-ordered. So don't rush the validator. For every line, ask:
what attack does this stop, and what happens if it's not here? The bad signature, the wrong
aud, the alg:none, the expired token, the missing scope — every negative test in the lab is a
real breach or a real outage someone lived through. Connect each one to its incident and the
knowledge becomes permanent instead of memorized.
And here's the career angle, brother to brother. Identity is the perimeter, which means the
people who truly understand it are trusted with the perimeter. Every other phase in this track —
RBAC, networking, secrets, the API gateway, the whole platform — sits behind the gate you build
here. If you own the token, you own the conversation about who gets in, and that's the
conversation principals get pulled into. The people who plateau treat auth as a solved library
problem; the people who go principal can open the box, find the missing aud check in a code
review, and explain in one calm sentence why it would've been a breach. Be that person. It starts
with the validator in this lab — write it by hand, break it on purpose, and watch it fail closed.
Go build the engine. Forge a token with the wrong aud and watch your validator reject it. Then
come find me in Phase 04 — that's where the token you just validated meets RBAC, and "who are
you" becomes "what, exactly, are you allowed to touch."
— your brother 👨🏻
Lab 01 — OAuth2 / OIDC / JWT Engine
Phase: 03 — Microsoft Entra ID: OAuth2 / OIDC / JWT | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Every authenticated call into Azure — and into the APIs you build on it — carries a JWT that Microsoft Entra ID minted, and the only thing standing between your service and a forged or replayed token is a validator that runs its checks in the right order and fails closed. This lab builds that machine: the
base64urlcodec every token field uses, the PKCE proof that lets a secret-less public client log a user in safely, an authorization server that runs the auth-code+PKCE and client-credentials grants offline, and a JWT validator that checks signature →iss→aud→ time → scope/role in exactly the order that prevents the confused-deputy attack. Strip away TLS and the RS256 keypair, and what's left is HMAC, SHA-256, and a careful sequence ofifstatements — the part interviewers probe and breaches turn on.
What you build
b64url_encode/b64url_decode— URL-safe base64 without padding (RFC 7515). Every JWT segment and the PKCEcode_challengeare encoded this way; using standard base64 or leaving padding on is the classic "works in my test, 401s in Entra" bug.pkce_challenge(verifier, method)—S256=BASE64URL(SHA256(verifier)), plus theplaindowngrade. This is the one-way function that lets a public client prove, at the token endpoint, that it's the app that started the login — without ever holding a secret.make_jwt/decode_jwt_unverified— sign a JWT with HS256 overbase64url(header).base64url(payload)(deterministic), and split/decode one without trusting it (to read thekidbefore you verify).validate_jwt(...)— the heart of the lab: verify the signature with a constant-time HMAC compare, theniss, thenaud(string or list), thennbf/expwith leeway, thenrequired_scopes⊆scpand/orrequired_roles⊆roles— raising aValueErrorthat names which check failed.AuthorizationServer— Entra's/authorizeand/token, offline:authorize(...)mints a one-time PKCE-bound code;token_authorization_code(...)exchanges code + verifier for access + ID tokens (only if the verifier hashes to the stored challenge);token_client_credentials(...)issues an app-only token carryingroles(no user, no ID token) after a constant-time secret check.
Key concepts
| Concept | What to understand |
|---|---|
| base64url, no padding | the JOSE alphabet (-/_, no =); every token field uses it |
PKCE S256 | challenge = BASE64URL(SHA256(verifier)); defeats auth-code interception for public clients |
| access vs ID token | access token = aud is the API, carries scp/roles (authorization); ID token = aud is the client, proves who the user is (authentication) |
scp vs roles | scp = space-delimited delegated scopes (a user is present); roles = app roles (daemon, no user) |
| validation order | structure/alg → signature → iss → aud → nbf/exp → scope/role; you never read a claim you haven't verified |
| confused deputy | skip aud and a token minted for service B is accepted by your service A — the canonical token-replay bug |
alg: none | reject any algorithm but the one you signed with, or an attacker strips the signature |
| leeway / clock skew | ±300 s absorbs NTP drift between issuer and validator (Entra's own allowance) |
| single-use code | the auth code is popped on redemption (success or failure) so an intercepted code can't be replayed |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py runs a full PKCE flow, a validation, and a client-credentials flow |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 32 tests pass against your implementation.
- You can recite the validation order and explain why signature must come before any
claim read (an unverified payload is attacker-controlled JSON), and why
audmust be checked at all (the confused deputy). - You can explain why
test_pkce_s256_known_answer_vectorrecomputes the challenge withhashlib.sha256in the test — and why the solution's example printsE9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM(it's the RFC 7636 Appendix B test vector, a free correctness check that your PKCE matches the standard). - You can explain why
test_validate_rejects_alg_none_attackexists and what it would mean for a validator to acceptalg: none. - You can explain why
test_authcode_is_single_usematters: PKCE stops an attacker who intercepts the code, and single-use stops one who replays it. - You can state, without looking, the difference between
scpandrolesand which grant produces each.
How this maps to real Azure (Microsoft Entra ID)
| The lab | Real Entra ID |
|---|---|
AuthorizationServer | the tenant's v2.0 endpoints: …/{tenant}/oauth2/v2.0/authorize and …/oauth2/v2.0/token |
RegisteredClient | an app registration (client id, redirect URIs, a secret/cert for confidential clients, exposed scopes, app roles) and its service principal in the tenant |
authorize + PKCE code | the front-channel redirect that authenticates the user (where Conditional Access runs) and returns a one-time code |
token_authorization_code | the back-channel POST /token with grant_type=authorization_code + code_verifier; returns access + ID (+ refresh) tokens |
token_client_credentials | grant_type=client_credentials with a secret/cert; returns an app-only token with roles |
| HS256 signing | Entra signs RS256 (asymmetric); you verify with the tenant's public key from JWKS, picked by the token's kid |
validate_jwt | what every protected API (and API Management) does on each request, against the keys at the tenant's /.well-known/openid-configuration → jwks_uri |
expected_iss / expected_aud | the tenant issuer (https://login.microsoftonline.com/{tid}/v2.0) and your API's Application ID URI / client id |
What the miniature leaves out (and why it's fine): real Entra signs RS256 with a
private key you never see and rotates its JWKS, so production validators fetch and
cache the JWKS by kid; it runs Conditional Access (device, location, risk, MFA) at
/authorize; it issues refresh tokens and supports the on-behalf-of and device-code
grants; and the whole thing is over TLS. None of that changes the validation order or the
claims — which is exactly the part interviewers probe and incidents turn on. We use HS256
(symmetric) and an injected clock purely to stay offline and deterministic; swap the
signature primitive for RSA-verify-against-JWKS and the logic is the real thing.
Extensions (build these in your own tenant)
- RS256 + JWKS: replace HS256 with an RSA keypair; publish a JWKS document
(
{"keys":[{"kid":..., "n":..., "e":...}]}); havevalidate_jwtselect the key by the token'skidand verify with the public key. Now you've modeled real Entra exactly. - Discovery: serve a
/.well-known/openid-configurationJSON withissuer,jwks_uri,authorization_endpoint,token_endpoint— and have the client discover endpoints instead of hard-coding them (what every Entra SDK does on startup). - On-behalf-of (OBO): add a grant where a middle-tier API exchanges an incoming user access token (audience = the API) for a downstream token (audience = a second API), preserving the user identity — the flow that powers API-calls-API with the user's context.
- Device code: add the
device_authorizationgrant (return auser_code+verification_uri, poll/tokenuntil the user approves) — the flow for TVs, CLIs, and IoT with no browser. - Refresh tokens: issue and redeem a refresh token; enforce single-use rotation and a longer expiry than the access token.
- Wire it to a real tenant:
az ad app create, grab a token withaz account get-access-token, decode it on jwt.ms, and check yourvalidate_jwtaccepts it (with RS256/JWKS) and rejects it with the wrongaud.
Interview / resume
- Talking points: "Walk me through the authorization-code + PKCE flow and what PKCE
defends against." / "Exactly what does a resource API check on an incoming JWT, and in
what order?" / "What's the difference between
scpandroles, and which grant produces each?" / "What's the confused-deputy attack and which single check prevents it?" / "Access token vs ID token — what's each for and who's theaud?" - Resume bullet: Built an OAuth2/OIDC authorization-server and JWT-validation engine
(authorization-code + PKCE and client-credentials grants, constant-time signature
verification, ordered
iss/aud/exp/nbfandscp/rolesclaim checks with clock skew) modeling Microsoft Entra ID token issuance and resource-API validation.
Phase 04 — RBAC & Azure Policy Evaluation
Difficulty: ⭐⭐⭐☆☆ (the matchers) → ⭐⭐⭐⭐⭐ (the judgment about least privilege, deny, and effect choice) Estimated Time: 1 week (12–18 hours) Prerequisites: Phase 00 (control plane vs data plane, the resource-ID shape and its scope levels), Phase 03 (Entra ID — the token that arrives carries the principal whose access we evaluate here). No live Azure subscription required for the lab.
Why This Phase Exists
Phase 03 ended with a validated JWT: ARM now knows who is calling (the oid/sub
claim) and that the token is genuine. This phase answers the very next question — the one
the entire security posture of an Azure estate turns on: is this caller allowed to do
this, to this resource?
There are two gates, in series, and almost nobody can describe both precisely:
- Azure RBAC — authorization. Given the principal, the action, and the resource's
scope, gather every role assignment that applies, compute the effective permission set,
and let any deny assignment win. This is the gate behind
AuthorizationFailed. - Azure Policy — governance. Even if RBAC says yes, Policy can
denythe write (orappend/modify/auditit) based on the shape of the resource — its SKU, region, encryption setting, tags. This is the gate behindRequestDisallowedByPolicy.
Here is the thing that separates a senior from a principal in an interview: a senior can
assign the Owner role; a principal can explain why that Owner still can't read a
blob (control plane vs data plane), why a Contributor can't grant access to anyone
(NotActions on Microsoft.Authorization/*), why a deny assignment from a Deployment
Stack overrides a subscription-Owner grant, and why a policy with effect: deny
blocks a deployment that RBAC happily authorized. These are not trivia — they are the four
shapes of the support ticket "I have permission but it still fails."
So this phase makes you build both evaluators — the wildcard Actions/NotActions
matcher, scope containment, the deny-wins decision, and the Policy condition-tree
evaluator. Once you've written the algorithm, "effective permissions" stops being a portal
blade you squint at and becomes a function you can run in your head during an incident.
This is the keystone for Phase 05 (landing zones are RBAC + Policy inherited down a
management-group tree at scale) and underpins every later phase that touches identity.
What "Principal-Level" Means Here
A senior assigns roles and writes policies. A principal understands the evaluation machinery well enough to:
- Predict an authorization decision from the assignments without the portal — by
computing
union(Actions) − union(NotActions), checking scope containment, and applying deny-wins, in that order. - Debug the four "I have permission but it fails" tickets: (a) control-plane grant but
data-plane action (
Ownerreading a blob), (b) aNotActionssubtraction (Contributorgranting access), (c) a deny assignment overriding the grant, (d) RBAC allows but Policy denies the resource shape. - Design least privilege: pick the narrowest built-in role that covers the action, reach for a custom role only when no built-in fits, and prefer scope narrowing (assign at the RG, not the subscription) over broader roles.
- Choose the right Policy effect and know when it runs:
deny/append/modifyat the request,audit/auditIfNotExists/deployIfNotExistson the compliance scan — and whydeployIfNotExistsneeds a managed identity. - Reason about blast radius of an assignment: a role at a management group reaches every subscription, RG, and resource beneath it; a deny at an RG stops there.
Concepts
- The RBAC model — a role assignment is the triple
(principal, role definition, scope). RBAC is allow-based (there is no "deny by role"), additive (assignments union together), and inherited down the hierarchymanagement group → subscription → resource group → resource. You grant by adding an assignment and revoke by removing one (or by a deny assignment). - Role-definition internals —
Actions(allowed control-plane operations),NotActions(subtracted from this role's ownActions),DataActions/NotDataActions(the same pair for the data plane), andAssignableScopes(where the role may be assigned). Effective perms =union(Actions) − union(NotActions). Wildcards:*matches any run of characters including/(soMicrosoft.Storage/*/readspans segments). - Control plane vs data plane —
Actionsmanage the resource (create/configure a storage account);DataActionsgovern the data inside it (read a blob). They are separate sets, which is whyOwner(Actions=["*"]) cannot read a blob — it has noDataActions; you addStorage Blob Data Reader. The same split exists for Key Vault, Service Bus, Cosmos DB, and more. - Deny assignments — an explicit block on actions at a scope that overrides role
assignments. Deny always wins, and it is evaluated first. You cannot create deny
assignments with a role; they come from Blueprints, Deployment Stacks
(
denySettings), and managed applications — which is exactly why they're the system's last word for protecting platform-managed resources. - Scope hierarchy & inheritance — a scope is an ARM resource ID (or a prefix). An
assignment at a higher scope applies to all descendants. A resource is "in scope of" an
assignment iff its ID is the scope or sits below it by whole path segments — the
reason
…/resourceGroups/rgcontains…/rg/providers/…but not…/rg2. - Azure Policy — a
policyRuleofif(a condition tree) andthen.effect. Leaf conditions test a field/alias (equals/notEquals/in/exists/like); logical nodes combine them (allOf/anyOf/not). Effects:disabled,audit,auditIfNotExists,append,modify,deployIfNotExists,deny— anddenydominates.deny/append/modifyrun at create/update;audit*anddeployIfNotExistsrun on the compliance scan. - Aliases — Policy can't read raw JSON paths; it reads aliases that map a friendly
name to the resource property at a given API version (e.g.
Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly). The lab models the dotted lookup; the extension wires real aliases.
Labs
Lab 01 — RBAC & Azure Policy Evaluation Engine (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build both authorization machines: a wildcard action_matches, a RoleDefinition with role_permits (union(Actions) − union(NotActions) and the data-plane pair), segment-aware scope_contains, the full can_perform decision (deny wins, then default-deny over in-scope role assignments), and an evaluate_policy that walks a nested allOf/anyOf/not condition tree with dotted-alias field lookups and returns the effect (plus a compliance_summary roll-up) |
| Concepts | (principal, role, scope) triple; NotActions as subtraction; control-vs-data-plane permission sets; scope-hierarchy inheritance and the rg-vs-rg2 trap; deny-assignment override and evaluation order; Policy condition trees, field operators, effects, and the exists absent-vs-falsey distinction |
| Steps | 1. action_matches (*→.*, escape literals, case-insensitive, anchored); 2. RoleDefinition/role_permits with the data-plane pair; 3. scope_contains (segment-aware prefix); 4. RoleAssignment/DenyAssignment/can_perform (deny-wins → default-deny); 5. evaluate_policy recursive condition tree + dotted lookup; 6. compliance_summary |
| How to Test | pytest test_lab.py -v — 43 tests covering wildcard boundaries (*/read, mid-pattern *, exact, literal-dot, case), NotActions subtraction, the Owner-can't-read-a-blob data-plane split, scope containment incl. the rg/rg2 trap, deny-wins / out-of-scope deny / wrong-principal deny, default-deny + unknown-role, and Policy deny/compliant/nested-allOf-anyOf-not/exists/in/like/dotted-alias + the bad-shape guards |
| Talking Points | "Why can't an Owner read a blob?" / "Walk me through both authorization gates for one PUT." / "Why does a deny assignment beat a subscription-Owner grant?" / "Order the Policy effects and tell me which fire at request time." / "How does a role at a management group reach a single blob?" |
| Resume bullet | Built an offline Azure authorization engine modeling RBAC (wildcard Actions/NotActions matching, control-vs-data-plane permission sets, scope-hierarchy inheritance, deny-assignment override) and an Azure Policy evaluator (nested condition trees, alias lookups, effect resolution, compliance roll-up) |
→ Lab folder: lab-01-rbac-policy-engine/
Integrated-Scenario Suggestions (carried through the whole track)
These compound into Phase 05 (landing zones) and the Phase 15 capstone. Keep them in mind as you build — each plugs the evaluator into a larger governance story:
- Least-privilege CI/CD identity — a deployment service principal that is
Contributoron one RG (not subscriptionOwner), plus the narrowest data-plane roles for the secrets/blobs it touches. The exercise is removing every action it never uses and proving the deploy still works. (→ P08, P12) - Break-glass and deny protection — a Deployment-Stack
denySettingsdeny assignment that blocksdeleteon production resources even forOwner, with a documented break-glass account that is not in the deny scope. (→ P05) - Policy-as-code guardrails — an initiative (policy set) that denies non-HTTPS storage, denies public network access, appends required tags, and audits resources missing a cost-center tag — assigned at a management group so every subscription inherits it. (→ P05)
- Data-plane vs control-plane separation of duties — platform admins get
Ownerto manage a Key Vault but not read its secrets; app identities getKey Vault Secrets User(a data role) but cannot change the vault's firewall. (→ P12) - Effective-access audit — given a principal and a resource, compute the effective
permissions across all inherited assignments and deny assignments — the offline version
of the portal's "Check access" /
az role assignment list --include-inherited. (→ P13)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- Authorization is two gates in series. RBAC (
may this identity?) then Policy (is this resource shape allowed?). The four "I have permission but it fails" tickets each map to one specific mechanism — name it and you've solved it. - A role assignment is
(principal, role, scope)— additive, inherited down the hierarchy, and allow-based. You revoke by removing or by a deny assignment, never by a "deny role" (there is none). - Effective perms =
union(Actions) − union(NotActions), and the data plane is a separate set (DataActions).Owner≠Storage Blob Data Reader. - Deny always wins and is evaluated first. A deny assignment from a stack/blueprint
overrides even subscription
Owner— that's its whole purpose. - Pick the narrowest role at the tightest scope. Least privilege is the default; a custom role is a last resort, and broad-scope assignments are a blast-radius decision.
- Choose the Policy effect deliberately —
deny/append/modifyshape the request;audit*/deployIfNotExistsreport and remediate on the scan;denydominates them all.
Deliverables Checklist
-
Lab 01 implemented; all 43 tests pass against
solution.pyand yourlab.py - You can state the RBAC evaluation order (deny first, then allow, default deny) from memory and explain why deny is evaluated first
-
You can explain why
Ownercan't read a blob and which role fixes it -
You can name the four built-in roles (
Owner/Contributor/Reader/User Access Administrator) and the one operationContributorlacks - You can list the seven Policy effects, order them by precedence, and say which run at request time vs the compliance scan
- Given assignments + a resource ID, you can compute the decision on paper
Warmup Guide — RBAC & Azure Policy Evaluation
Zero-to-principal primer for Phase 04: the two authorization gates every Azure write passes through. We start from "what is a permission" and end at the exact evaluation order ARM runs — role assignments,
NotActionssubtraction, the control-plane / data-plane split, deny assignments, scope inheritance, and the Azure Policy condition engine and its effects. By the end you can answer "why does this fail even though I'mOwner?" from first principles, and you will have built both evaluators.
Table of Contents
- Chapter 1: The Two Gates — Authorization vs Governance
- Chapter 2: Actions and the Wildcard Matcher
- Chapter 3: Role Definitions — Actions, NotActions, and the Plane Split
- Chapter 4: Scope, Hierarchy, and Inheritance
- Chapter 5: Deny Assignments and the Evaluation Order
- Chapter 6: Azure Policy — The Governance Gate
- Chapter 7: Effects and Their Precedence
- Chapter 8: Least Privilege as an Engineering Discipline
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Two Gates — Authorization vs Governance
From zero. When a client calls Azure to create or change a resource, the request is a
single idempotent PUT (or PATCH/DELETE) to a resource ID — you built the parser for
that ID in Phase 00. Before any resource provider acts on it, Azure Resource Manager
(ARM) runs the request through a pipeline:
client ──JWT──▶ Entra (authenticate: is the token genuine? — Phase 03)
│
▼
ARM ──▶ RBAC (authorize: may THIS principal do THIS action HERE?)
│ ──▶ Azure Policy (govern: is the resulting RESOURCE allowed to exist?)
│
▼
resource provider (Microsoft.Storage, …) performs the idempotent PUT
What it is. Two distinct gates, in series, both deny-biased:
- RBAC (Role-Based Access Control) is authorization: it decides whether the
identity is permitted to perform the action at the scope. Failure looks like
AuthorizationFailed(HTTP 403). - Azure Policy is governance: even when RBAC says yes, Policy can block or mutate the
write based on the shape of the resource — its region, SKU, tags, encryption flag.
A blocking failure looks like
RequestDisallowedByPolicy(HTTP 403, different code).
Why it exists. They answer different questions and are owned by different people. RBAC
is "who is on the team and what may they touch" (identity/security). Policy is "what is the
organization allowed to build" (governance/compliance). Conflating them is the root of most
"I have permission but it still fails" tickets: you check RBAC, see Owner, and miss that
Policy denied the non-compliant resource.
Production significance. The single most useful debugging move in an Azure incident is
to read the error code: AuthorizationFailed sends you to RBAC (assignments, scope,
NotActions); RequestDisallowedByPolicy sends you to Policy (which definition, which
effect, which field). They are never the same investigation.
Common misconception. "Policy is just RBAC for resource properties." No — Policy never
looks at who is calling; it looks at what is being created. A principal with full
rights is still stopped by a deny policy, and a principal with no rights never reaches
Policy because RBAC stopped them first.
Chapter 2: Actions and the Wildcard Matcher
From zero. Every operation in Azure has a name — a slash-delimited string:
Microsoft.Storage/storageAccounts/read
Microsoft.Storage/storageAccounts/write
Microsoft.Storage/storageAccounts/listKeys/action
Microsoft.Compute/virtualMachines/read
The shape is {ResourceProvider}/{resourceType}/{operation} (sometimes deeper, with
nested types and an /action suffix for POST-style operations).
What it is. A role grants patterns over these strings, and the one wildcard is *.
The critical semantic: * matches any run of characters, including the / separator,
so it can span one or more path segments and match within a segment:
| Pattern | Matches | Doesn't match |
|---|---|---|
* | everything | — |
*/read | any …/read | …/write |
Microsoft.Storage/*/read | Microsoft.Storage/storageAccounts/read, …/blobServices/read | Microsoft.Compute/…/read |
Microsoft.Storage/storageAccounts/* | every storage-account op | Microsoft.Compute/… |
| exact string | only itself | anything else |
Under the hood. You translate the pattern to a regex: * → .*, and escape every
other character so the literal . in Microsoft.Storage is not a regex "any char".
Anchor both ends and match case-insensitively (Azure treats action names as
case-insensitive):
def _compile(pattern: str) -> re.Pattern:
out = ["(?i)^"]
for ch in pattern:
out.append(".*" if ch == "*" else re.escape(ch))
out.append("$")
return re.compile("".join(out))
def action_matches(pattern: str, action: str) -> bool:
return _compile(pattern).fullmatch(action) is not None
The escaping is the bug that bites everyone: without it, Microsoft.Storage/... would
match MicrosoftXStorage/... because . is "any character" in a raw regex. The lab tests
exactly this (test_literal_dot_not_treated_as_regex).
Production significance. This is the matcher ARM runs millions of times a second. When
you read a custom role definition and ask "does this grant listKeys?", you are running
this function by hand. Reader is literally Actions: ["*/read"].
Common misconception. "* only matches one segment." It matches zero or more
characters, slashes included — Microsoft.Storage/*/read will match
Microsoft.Storage/storageAccounts/blobServices/read. (Azure's own model is "* is a
wildcard"; segment counting is your mental shorthand, not a real boundary.)
Chapter 3: Role Definitions — Actions, NotActions, and the Plane Split
From zero. A role definition is a named bundle of permission patterns. The four built-ins to know cold:
| Role | Actions | Note |
|---|---|---|
Owner | ["*"] | full control plane + can manage access |
Contributor | ["*"] minus Microsoft.Authorization/*/Write, …/Delete, …/elevateAccess | manage everything except grant access |
Reader | ["*/read"] | read everything, change nothing |
User Access Administrator | ["*/read", "Microsoft.Authorization/*"] | manage access, little else |
What it is. A role has four permission lists:
Actions— control-plane operations the role allows.NotActions— operations subtracted from this role's ownActions.DataActions— data-plane operations the role allows.NotDataActions— operations subtracted fromDataActions.
The effective permission is set arithmetic, per plane:
$$\text{effective} = \Big(\bigcup \text{Actions}\Big) \setminus \Big(\bigcup \text{NotActions}\Big)$$
def role_permits(role, action, is_data=False):
allow, deny = (role.data_actions, role.not_data_actions) if is_data \
else (role.actions, role.not_actions)
return any(action_matches(p, action) for p in allow) \
and not any(action_matches(p, action) for p in deny)
Under the hood — NotActions is subtraction, not deny. This trips up everyone.
Contributor has Actions: ["*"] and NotActions: ["Microsoft.Authorization/*/Write"].
That NotActions removes the grant from this role only. If the same principal also
has User Access Administrator (which does grant Microsoft.Authorization/*), the union
of assignments restores it. To truly block something regardless of other grants you need a
deny assignment (Chapter 5). The mental model:
NotActions → "this role doesn't grant X" (another role still can)
deny assignment → "nobody gets X here, period" (overrides every grant)
Under the hood — the control-plane / data-plane split. Actions manage the
resource; DataActions govern the data inside it. They are separate sets. So:
Owner: Actions=["*"], DataActions=[] → can create/configure a storage account
→ CANNOT read a blob (no DataActions!)
To read a blob you add a data role: Storage Blob Data Reader
(DataActions: ["Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read"]).
This is the famous "Owner can't read a blob," and it is by design: separating who
manages a store from who reads its data is a real security boundary (an admin who can
rotate keys need not be able to read every customer's file). The same split exists for Key
Vault (Key Vault Secrets User), Service Bus, Cosmos DB, and Event Hubs.
Production significance. When someone says "I'm Owner but I get 403 reading the
blob," you know immediately: control-plane grant, data-plane action, missing data role. The
fix is one assignment, not "make me more Owner."
Common misconception. "Data access is implied by Owner." It is not — and Microsoft
deliberately made it not, because key-management and data-read are different trust levels.
Some portals hide this by auto-adding data roles when you click into a blob, which makes
the SDK/service-principal case (no portal magic) more confusing, not less.
Chapter 4: Scope, Hierarchy, and Inheritance
From zero. A scope is where an assignment applies — an ARM resource ID, or a prefix of one. The hierarchy, top to bottom:
(tenant root)
└─ management group /providers/Microsoft.Management/managementGroups/{mg}
└─ subscription /subscriptions/{sub}
└─ resource group /subscriptions/{sub}/resourceGroups/{rg}
└─ resource …/resourceGroups/{rg}/providers/{ns}/{type}/{name}
What it is. An assignment at a scope applies to that scope and everything below it.
Assign Reader at the subscription and the principal can read every RG and every resource
in it. This is inheritance, and combined with additivity (Chapter 3) it means a
principal's effective access at a resource is the union of every assignment from the
resource all the way up to the root.
Under the hood — containment is segment-aware. "Is resource R below scope S?" is a
prefix match on whole path segments, not a raw string startswith. The trap:
scope = /subscriptions/s/resourceGroups/rg
resource1 = /subscriptions/s/resourceGroups/rg/providers/Microsoft.Storage/... ✓ contained
resource2 = /subscriptions/s/resourceGroups/rg2/providers/Microsoft.Storage/... ✗ NOT contained
"…/rg2/…".startswith("…/rg") is True — and wrong. The fix is to require the scope plus
a trailing / to prefix the resource (or exact equality, or the root /):
def scope_contains(scope, rid):
scope, rid = scope.rstrip("/").lower(), rid.rstrip("/").lower()
if scope in ("", "/"): # tenant root contains everything
return True
return rid == scope or rid.startswith(scope + "/")
ARM IDs are case-insensitive and a trailing slash is insignificant, so normalize both. The
lab's test_scope_contains_rg_vs_rg2_trap is precisely this boundary.
Production significance. Scope is your blast-radius dial. Owner at a management
group is access to every subscription beneath it — a much bigger grant than Owner on
one RG, for the identical role. Tightening scope is almost always a better least-privilege
move than inventing a narrower role. And az role assignment list --include-inherited
shows you exactly the up-the-tree union this chapter describes.
Common misconception. "I removed the assignment on the resource, so they lost access." Not if they still have an inherited assignment at the RG, subscription, or MG. Effective access is the union up the tree; you must find every contributing scope.
Chapter 5: Deny Assignments and the Evaluation Order
From zero. RBAC is allow-based: there is no "deny role." So how do you guarantee
nobody — not even an Owner — can delete a platform-managed resource? With a deny
assignment.
What it is. A deny assignment is an explicit block on a set of actions, for a set
of principals, at a scope. It has Actions and NotActions (here NotActions are
exclusions from the deny — "block everything except read"). Crucially, you cannot
create one with a role. They are emitted by Azure Blueprints, Deployment Stacks
(denySettings), and managed applications — the platform-control mechanisms — which is
why they are the system's last word.
Under the hood — the evaluation order. ARM computes the RBAC decision like this:
def can_perform(principal, action, resource):
# 1. DENY WINS — checked FIRST.
for d in deny_assignments_for(principal):
if scope_contains(d.scope, resource) and d.blocks(action):
return False # ← short-circuit, no allow can save it
# 2. ALLOW — some in-scope role assignment must permit the action.
for a in role_assignments_for(principal):
if scope_contains(a.scope, resource) and role_defs[a.role].permits(action):
return True
# 3. DEFAULT DENY.
return False
Three rules fall out, and they are the whole chapter:
- Deny always wins. A deny in scope ends the evaluation
False, regardless of any grant — even subscriptionOwner. - Default deny. No matching allow →
False. Least privilege is the floor, not an opt-in. - Scope still applies to deny. A deny at
rgdoes not block an action on a resource inrg2— same containment rule as allows.
$$\text{allowed} = \big(\exists,\text{in-scope allow}\big)\ \wedge\ \neg\big(\exists,\text{in-scope deny}\big)$$
Production significance. This is how you build guardrails that survive privileged
users: a Deployment Stack with denySettings: denyDelete means even the break-glass
Owner cannot delete the locked resources out-of-band — drift protection at the control
plane. (Compare resource locks — CanNotDelete/ReadOnly — which are simpler but a
different mechanism that even Owner can remove; deny assignments are stronger.)
Common misconception. "I'll just add a NotActions to deny them." NotActions only
limits that role; the principal's other roles still grant it. Real, unconditional denial
is a deny assignment — and you provision it through a stack/blueprint, not a CLI role
command.
Chapter 6: Azure Policy — The Governance Gate
From zero. RBAC said yes, this identity may write a storage account here. Azure
Policy now asks: is this particular storage account allowed to exist? — e.g. "no
storage account may permit non-HTTPS traffic," "every resource must carry a cost-center
tag," "VMs only in approved regions."
What it is. A policy definition's policyRule has two parts:
{
"if": { "field": "...", "equals": "..." }, // a condition tree
"then": { "effect": "deny" } // what to do on a match
}
If the if condition matches the resource, the effect fires; otherwise the resource is
compliant with respect to this policy.
Under the hood — the condition tree. Leaves test a field/alias; logical nodes combine them:
| Construct | Meaning |
|---|---|
{"field": F, "equals": V} | field F equals V (case-insensitive strings) |
{"field": F, "notEquals": V} | field F ≠ V |
{"field": F, "in": [V1, V2]} | field F is one of the list |
{"field": F, "exists": true} | field F is present (vs absent) |
{"field": F, "like": "prefix/*"} | glob match (* any chars, ? one char) |
{"allOf": [c1, c2]} | logical AND |
{"anyOf": [c1, c2]} | logical OR |
{"not": c} | logical NOT |
You evaluate it as a recursive tree-walk (the lab's _eval_condition). The field is a
dotted path/alias resolved into the resource dict — properties.supportsHttpsTrafficOnly
walks resource["properties"]["supportsHttpsTrafficOnly"].
Under the hood — exists needs an absent-vs-falsey distinction. This is the subtle
one. exists: true must be True for a field that is present but false — "present"
and "truthy" are different questions. So a missing key must resolve to a sentinel, not
to None/False, or exists can't tell them apart:
_MISSING = object()
def _resolve(resource, path):
cur = resource
for part in path.split("."):
if isinstance(cur, dict) and part in cur:
cur = cur[part]
else:
return _MISSING # absent, distinct from a real False
return cur
# exists: (actual is not _MISSING) == want
The lab's test_policy_exists_distinguishes_absent_from_falsey is exactly this trap: a
storage account with supportsHttpsTrafficOnly: false has the field, so
exists: true matches it.
Under the hood — aliases. Real Policy doesn't read raw JSON; it reads aliases — a
curated map from a friendly name to the property's API path at a given version, e.g.
Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly →
properties.supportsHttpsTrafficOnly. Array aliases use [*] to mean "for any element."
The lab models the dotted lookup; the extension wires real alias names.
Production significance. This is policy-as-code — the mechanism behind a compliant landing zone (Phase 05). Denying non-HTTPS storage, requiring private endpoints, enforcing allowed regions, appending tags: all are condition trees plus an effect, assigned at a management group so every subscription inherits them.
Common misconception. "Policy runs after the resource is created." deny/append/
modify run at the request, before the resource exists — that's how deny can stop
the create. Only audit* and deployIfNotExists run on the periodic compliance scan
(Chapter 7).
Chapter 7: Effects and Their Precedence
From zero. The then.effect is what Policy does on a match. The seven that matter:
| Effect | When it runs | What it does |
|---|---|---|
disabled | — | turns the policy off (no-op) |
audit | scan | logs non-compliance; does not block |
auditIfNotExists | scan | non-compliant if a related resource is missing |
append | request | adds fields to the request (e.g. a default tag) |
modify | request | changes/adds/removes properties or tags (needs a managed identity) |
deployIfNotExists | scan | deploys a remediating resource if one is missing (managed identity) |
deny | request | blocks the create/update |
Under the hood — precedence. When multiple policies (or an initiative) evaluate the same request, the strongest effect wins. The cheat-sheet ordering, low → high:
disabled < audit / auditIfNotExists < append / modify < deployIfNotExists < deny
So if one policy would audit and another would deny the same write, the request is
denied. deny is the most restrictive and dominates; disabled is the weakest.
Under the hood — request-time vs scan-time. This split is the principal-level nuance:
REQUEST TIME (before the resource exists): deny, append, modify
SCAN TIME (over existing resources): audit, auditIfNotExists, deployIfNotExists
deny can stop a create because it runs first, on the payload. audit cannot stop
anything — it only marks the resource non-compliant in the compliance dashboard later.
deployIfNotExists runs a remediation deployment after the fact, which is why it needs a
managed identity with rights to deploy the missing piece (e.g. a diagnostic setting).
Production significance. Choosing the effect is a real design decision: deny is the
guardrail that stops the mistake, but it can break legitimate deploys and frustrate teams;
audit is the observe-first mode you roll out before flipping to deny; deployIfNotExists
auto-fixes (e.g. "every resource gets a Log Analytics diagnostic setting") so humans don't
have to. A mature landing zone uses all three deliberately.
Common misconception. "audit protects me." It does not block anything — it only
reports. If the requirement is "this must never be created," the effect is deny. audit
is for visibility and gradual rollout, not enforcement.
Chapter 8: Least Privilege as an Engineering Discipline
From zero. "Least privilege" means grant the minimum access required, no more — and in Azure that decomposes into two independent dials you now understand: the role (which actions) and the scope (which resources).
The discipline, in priority order:
- Narrow the scope before you narrow the role.
Contributoron one RG beatsOwneron the subscription for a CI identity. Same role, far smaller blast radius. Scope is the cheaper, safer lever. - Prefer the narrowest built-in role. Need to read blobs?
Storage Blob Data Reader, notOwner. Microsoft maintains hundreds of granular built-ins for exactly this. - Custom role only as a last resort. When no built-in fits, author a custom role with
the exact
Actions/DataActionsand a tightAssignableScopes. It's more to maintain, so justify it. - Separate control and data planes by person. Platform admins manage the Key Vault
(
Owner); they should not read its secrets. App identities read secrets (Key Vault Secrets User); they should not change the vault firewall. - Use deny assignments for guardrails that must survive privilege. Protect
platform-managed resources from even
Ownervia stackdenySettings.
Why it's an engineering problem, not a checkbox. The standing question in a review is "what is the minimum this identity needs to do its job, and at the tightest scope?" — and the way you prove the answer is to remove access and watch the deploy still pass (the integrated scenario in the phase README). Over-provisioning is the default failure mode because it's easy and nothing breaks immediately; the breach happens later, through the identity you over-granted.
Production significance. Every audit, every breach post-mortem, and every well-run
landing zone is downstream of this. The identity that got phished and had subscription
Owner is the headline; the identity that had Contributor on one RG is a footnote.
Common misconception. "Least privilege slows teams down." Done well it does the opposite: self-service with policy guardrails lets teams move fast inside a safe box, which is the whole thesis of landing zones (Phase 05).
Lab Walkthrough Guidance
Lab 01 — RBAC & Azure Policy Evaluation Engine, suggested order:
action_matches(pattern, action)(Ch. 2) — translate*→.*,re.escapeevery other char, anchor, case-insensitivefullmatch. Test the boundaries first:*/read, mid-pattern*, exact, the literal-dot trap, case-insensitivity.RoleDefinition+role_permits(Ch. 3) —union(allow) − union(deny)per plane; coerce list args to tuples in__post_init__. The two key tests:NotActionssubtraction, andOwner(Actions=["*"], noDataActions) failing a data action.scope_contains(Ch. 4) — normalize (lower, strip trailing/), then equality orscope + "/"prefix; root/contains all. Nail therg-vs-rg2test.RoleAssignment/DenyAssignment/can_perform(Ch. 5) — deny loop first (short-circuitFalse), then the allow loop, then defaultFalse. Raise on an unknown role name. Test deny-wins, out-of-scope deny, wrong-principal deny, default-deny.evaluate_policy(Ch. 6) — recursive_eval_condition(handleallOf/anyOf/notthen the leaf operators), a dotted_resolve_fieldwith a_MISSINGsentinel, and thethen.effectreturn. Validate the shape and effect. Thencompliance_summarytallies effects across resources (Ch. 7).
Run red (pytest test_lab.py -v), implement top-to-bottom, run green. Then run
LAB_MODULE=solution pytest test_lab.py -v and python solution.py for the three demos.
Success Criteria
You are ready for Phase 05 when you can, from memory:
- Draw the two authorization gates (RBAC then Policy) and name the error code each emits.
- State the RBAC formula
union(Actions) − union(NotActions)and explain whyNotActionsis subtraction, not a deny. - Explain the control-plane / data-plane split and why
Ownercan't read a blob (and what role fixes it). - Recite the RBAC evaluation order — deny first, then allow, default deny — and why deny is checked first.
- Explain the
rg-vs-rg2containment trap and how segment-aware matching avoids it. - Name the four core roles and the operation
Contributorlacks. - List the seven Policy effects, order them by precedence, and say which run at request time vs the compliance scan.
- Hand-evaluate a nested
allOf/anyOf/notpolicy against a resource JSON, including anexistson a present-but-falsefield.
Interview Q&A
Q: I'm Owner on the subscription but I get a 403 reading a blob. Why, and what do you
do?
Owner grants the control plane (Actions: ["*"]) — it can create, configure, and
delete the storage account — but it has no DataActions, and reading a blob is a
data-plane action
(Microsoft.Storage/storageAccounts/blobServices/containers/blobs/read). The two planes
are separate permission sets by design, so an admin who manages the account isn't
automatically allowed to read every customer's data. The fix is one assignment:
Storage Blob Data Reader (or …Data Contributor) at the account/container scope. The
portal sometimes hides this by auto-adding the data role, which is why service principals
and SDK calls hit it more often.
Q: How do you guarantee that nobody, not even an Owner, can delete a production
resource?
Not with a role — RBAC is allow-based, there is no "deny role," and a NotActions only
limits the role it's on (another grant restores it). You use a deny assignment, which
overrides every role assignment and is evaluated first. You can't author deny
assignments with the CLI directly; they come from a Deployment Stack (denySettings: denyDelete), a Blueprint, or a managed app. That's stronger than a resource lock, which
even Owner can remove. Document a break-glass account that sits outside the deny scope.
Q: Walk me through the RBAC evaluation order.
For (principal, action, resource): (1) Deny wins — if any deny assignment for this
principal is in scope of the resource and matches the action, return False immediately,
no allow can override it. (2) Allow — return True iff some in-scope role assignment's
role definition permits the action, where "permits" is matched-by-some-Action AND not-matched-by-any-NotAction, on the data plane if it's a DataAction. (3) Default
deny — no matching allow → False. Scope containment (segment-aware prefix up the MG →
Sub → RG → resource hierarchy) gates both the deny and the allow checks.
Q: What's the difference between NotActions and a deny assignment?
NotActions subtracts from a single role's own grant — it means "this role doesn't
give you X," but another assignment can still grant X (assignments are additive). A deny
assignment is an unconditional block at a scope that overrides all grants and is
evaluated before them. NotActions shapes a role; a deny assignment overrides the whole
decision.
Q: A deployment fails with RequestDisallowedByPolicy even though the service principal
is Contributor. What happened?
RBAC and Policy are two separate gates. Contributor cleared RBAC — the identity may
create the resource — but Azure Policy has a deny effect that matched the resource's
shape (wrong region, missing required tag, supportsHttpsTrafficOnly: false, public
network access, a disallowed SKU). The error code is the tell: AuthorizationFailed is
RBAC; RequestDisallowedByPolicy is Policy. You find the assignment, read its if
condition to see which field tripped it, and either fix the resource to comply or (with
governance approval) adjust/except the policy.
Q: Order the Policy effects and tell me which run at request time.
Precedence low→high: disabled < audit/auditIfNotExists < append/modify <
deployIfNotExists < deny; the strongest wins, so deny dominates. Request-time
(before the resource exists): deny, append, modify. Scan-time (over existing
resources): audit, auditIfNotExists, deployIfNotExists. That's why audit can't
block a create — it only marks non-compliance later — and why deployIfNotExists needs a
managed identity to run its remediation deployment.
Q: An assignment exists at a management group. How does it reach a single blob's
container?
By inheritance. A scope applies to itself and everything below it in MG → Sub → RG → resource. A role at the MG is in scope of every descendant resource ID — containment is a
segment-aware prefix match — so the assignment authorizes control-plane actions on that
container resource. Reading the blob's data, though, still needs a DataAction from a
data role; control-plane inheritance doesn't grant data-plane access.
References
- Azure RBAC overview — the allow-based, additive, inherited model.
- How Azure RBAC determines access — the deny-then-allow evaluation order.
- Azure built-in roles
—
Owner,Contributor,Reader,User Access Administrator, and the data roles. - Understand role definitions
—
Actions,NotActions,DataActions,NotDataActions,AssignableScopes. - Deny assignments — how deny overrides and who creates them.
- Azure RBAC scope — the MG → Sub → RG → resource hierarchy and inheritance.
- Azure Policy overview and policy definition structure.
- Policy effects — the seven effects, precedence, and request-vs-scan timing.
- Policy aliases — how a field name maps to a resource property at an API version.
- The track's own CHEATSHEET.md (RBAC evaluation + effect precedence) and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 04: RBAC & Azure Policy
Read this if: you can click "Add role assignment" in the portal but "why does this service principal get a 403 it shouldn't" still makes you open a ticket. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the evaluator.
0. The 30-second mental model
Every Azure write passes two gates in series, both deny-biased:
who? → RBAC (may THIS identity do THIS action at THIS scope?) → AuthorizationFailed
what? → Policy (is the resulting RESOURCE allowed to exist?) → RequestDisallowedByPolicy
RBAC: a role assignment is (principal, role, scope); effective perms =
union(Actions) − union(NotActions); deny assignments win; default deny. Policy: an
if condition tree over the resource's fields + a then.effect; deny dominates. One
sentence to tattoo: authorization is two deny-biased gates over a scope hierarchy, and
the control plane and data plane are different permission sets.
1. The four roles you must know cold
| Role | Actions | The gotcha |
|---|---|---|
Owner | * | no DataActions → can't read a blob; can grant access |
Contributor | * minus Microsoft.Authorization/* | manage everything except grant access |
Reader | */read | sees all, changes nothing |
User Access Administrator | */read + Microsoft.Authorization/* | manages access, little else |
Then the data roles that fix the blob problem: Storage Blob Data Reader /
…Data Contributor / …Data Owner, and for secrets Key Vault Secrets User.
2. The numbers and rules to tattoo on your arm
| Thing | Value |
|---|---|
| RBAC evaluation order | deny first → allow → default deny |
| Effective perms | union(Actions) − union(NotActions) (per plane) |
NotActions | subtracts from this role only (not a deny) |
Wildcard * | matches any chars including / (spans segments) |
| Scope hierarchy | MG → Sub → RG → resource; inherited down |
| Role assignments per subscription | 4,000 (hard limit — a real ceiling at scale) |
| Custom roles per tenant | 5,000 |
| Management-group tree depth | 6 levels (excl. root + subscription) |
| Policy effect precedence | disabled < audit* < append/modify < deployIfNotExists < deny |
| Request-time effects | deny, append, modify |
| Scan-time effects | audit, auditIfNotExists, deployIfNotExists |
3. The az one-liners
# WHO has access here (incl. inherited up the tree)?
az role assignment list --scope <resourceId> --include-inherited -o table
# What can a role actually do?
az role definition list --name "Contributor" --query "[0].permissions" -o json
az role definition list --name "Storage Blob Data Reader" \
--query "[0].permissions[0].dataActions" # ← the DataActions
# Grant least privilege: narrow role at the tightest scope (RG, not subscription)
az role assignment create --assignee <objId> --role "Storage Blob Data Reader" \
--scope <storageAccountId>
# Discover the action strings a provider exposes (the patterns your role matches)
az provider operation show --namespace Microsoft.Storage -o json
# Policy: assign a built-in "deny non-HTTPS storage" at an RG
az policy assignment create --name deny-insecure-storage \
--policy <builtinDefinitionId> --scope <rgId>
# Policy: what's non-compliant right now?
az policy state summarize --resource-group <rg> -o table
# Custom role (last resort): author exact Actions + tight assignableScopes
az role definition create --role-definition ./my-custom-role.json
4. War story shapes you'll relive
- "I'm
Ownerbut I can't read the blob!" → control plane vs data plane.Ownerhas noDataActions. AddStorage Blob Data Reader. The #1 RBAC ticket. - "My service principal is
Contributorbut the deploy saysRequestDisallowedByPolicy." → RBAC passed; Policy denied the resource shape (region/tag/SKU/HTTPS). Read the policy'sifto find the field. Different gate, different error code. - "I removed their assignment and they still have access." → inherited assignment at the
RG / subscription / MG. Effective access is the union up the tree;
--include-inherited. - "An
Ownerdeleted a production resource the stack was protecting!" → they shouldn't have, if there'd been a deny assignment (denySettings). A resource lock isn't enough —Ownercan remove a lock; a deny assignment overrides evenOwner. - "Contributor can't grant my teammate access." → working as designed:
ContributorhasNotActions: Microsoft.Authorization/*/Write. GrantUser Access Administrator(orOwner). - "My
auditpolicy isn't blocking anything." →auditnever blocks; it reports on the scan. If you need to stop the create, the effect isdeny.
5. The two-second debugging fork
403 error code?
├─ AuthorizationFailed → RBAC. Check: assignment exists? right scope?
│ NotActions subtracting it? deny assignment?
│ data-plane action needing a DataAction?
└─ RequestDisallowedByPolicy → Policy. Check: which assignment? which definition's
`if` matched? which field is non-compliant?
6. Vocabulary that signals you've held the pager
- Role assignment — the
(principal, role, scope)triple; the atomic RBAC fact. NotActions— subtraction from a role's own grant, not a deny.- DataActions — the data-plane permission set; separate from
Actions. - Deny assignment — the unconditional override; deny always wins; stack/blueprint-only.
- Scope inheritance — access flows down MG → Sub → RG → resource.
- Effective permissions — the union up the tree, minus
NotActions, minus deny. - Effect — what a policy does on a match;
denydominates. - Alias — the name a policy uses for a resource property (vs a raw JSON path).
- Initiative / policy set — a bundle of policy definitions assigned as one unit.
deployIfNotExists— auto-remediation effect; needs a managed identity.
7. Beginner mistakes that mark you in interviews
- Granting
Owner/Contributorat the subscription when an RG-scoped role would do (blast radius), or inventing a custom role when a built-in already fits. - Thinking
Ownerimplies data access (the blob trap) — or thinkingNotActionsis a deny. - Forgetting deny is evaluated first and always wins — describing RBAC as "just add up the allows."
- Doing scope containment with a raw
startswithand getting therg-vs-rg2bug. - Confusing the two gates: blaming RBAC for a
RequestDisallowedByPolicy(it's Policy). - Using
auditwhere the requirement is "must never be created" (that'sdeny), or reaching for a resource lock where a deny assignment is required. - Not knowing which effects run at request time vs the compliance scan.
8. How this phase pays off later
- Phase 05 (Landing Zones) is this, at scale: RBAC + Policy inherited down a management-group tree, an initiative per compliance domain, the ALZ guardrails.
- Phase 12 (Key Vault / Managed Identity) lives on the control-vs-data-plane split you
learned here (
Key Vault Secrets Useris aDataActionrole). - Phase 08 (CI/CD) is least-privilege deployment identities — narrow role, tight scope, OIDC federation instead of a secret.
- Every later phase that touches identity defaults to deny, and you now know exactly why and how that default is enforced.
Now read the WARMUP slowly, then build the evaluator. After that, every "why did this 403?" becomes a function you run in your head.
👨🏻 Brother Talk — Phase 04, Off the Record
No slides, no compliance-framework jargon. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. Of all the phases in this track, this is the one where I've watched the most careers quietly stall — not because RBAC is hard, but because everyone thinks they know it. They can add a role assignment in the portal, so they check the box and move on. And then six months into a real Azure job, a service principal gets a 403 it shouldn't, a deploy fails with an error code they've never read closely, an auditor asks "who can read this customer data," and they realize they never actually understood the machine. They understood the button.
Here's the secret that took me too long to learn: the people who get senior security
roles are not the ones who memorized the most built-in roles. They're the ones who can
stand at a whiteboard and run the evaluation in their head. When the room is panicking
about "can the breached identity reach production," you don't reach for the portal. You
say: "It had Contributor on this RG — that's control plane, so it could redeploy, but it
had no DataActions, so it couldn't read the secrets, and there's a deny assignment from
the stack blocking delete, so the blast radius is this, not the whole subscription."
That sentence — running the algorithm out loud, with the scope and the planes and the deny
all in their right places — is the entire difference between "knows Azure" and "owns
Azure security." That's what this phase is for. That's why you're going to build the
evaluator and not just read about it.
So let me give you the mindset shifts, brother to brother.
The control-plane / data-plane split is not trivia. It's the whole game. Everyone hits "Owner can't read a blob" and treats it as an annoying gotcha. It's not a gotcha — it's the entire security model trying to teach you something: managing a thing and reading its data are different trust levels, on purpose. Once that clicks, you stop over-granting. You stop making the platform admin a data owner. You start seeing that the person who can rotate the storage keys does not need to read every customer's file, and you design for that. Internalize this one idea and you're already thinking like a security architect instead of an operator.
Default to deny, and mean it. The whole system is deny-biased — no allow, no access; deny wins over everything. Your designs should be the same. The instinct of a junior is "grant it, then tighten later." Later never comes. The over-grant sits there until it's the hole in the breach report. The principal instinct is the reverse: grant the minimum at the tightest scope, then widen only when something actually breaks and you can see exactly what it needed. It feels slower. It is the thing that saves your name from the post-mortem.
Narrow the scope before you narrow the role. This is the practical trick nobody tells
you. Everyone agonizes over which role to pick. But the bigger lever is almost always
scope: Contributor on one resource group is a footnote; Contributor on the
subscription is a liability — same role. When someone asks for access, your first question
isn't "what role" — it's "at what scope, and can it be smaller?" Tighten the scope and most
"I need a custom role" requests evaporate.
Deny assignments are your nuclear option — know they exist. Most engineers go their
whole career not knowing you can't protect a resource from an Owner with a role or even
a lock. When you casually mention "we'll put a deny assignment via the deployment stack's
denySettings so even break-glass Owner can't delete it," people look at you differently.
It signals you understand the model at the level where the guardrails that survive
privileged users live. That's a level above "I assign roles."
Policy is where you stop being a babysitter. Here's the career unlock in Azure Policy:
every rule you write is a 3 a.m. conversation you'll never have to have. "No public storage
accounts" as a deny policy means you never again review a PR for it, never again get paged
for it, never again explain it to a new hire. You encode the judgment once and the
platform enforces it forever, for hundreds of engineers. That's leverage. That's the
difference between a senior who reviews everything and a principal who built the system so it
doesn't need reviewing. Start with audit to see what would break, then flip to deny when
you're sure — that rollout discipline is itself a senior signal.
Read the error code first. Always. This is the smallest habit with the biggest payoff.
AuthorizationFailed and RequestDisallowedByPolicy are completely different
investigations — different gate, different team, different fix. Half the wasted hours I've
seen in Azure incidents come from someone debugging RBAC when Policy denied them, or
re-checking a policy when RBAC was the wall. The error code tells you which gate you're at.
Learn to read it before you touch anything.
The honest truth about this phase's difficulty: it's not deep, it's precise. There's no hard algorithm here — the whole evaluator fits in a page. What's hard is holding all the pieces in the right order under pressure: deny first, then allow, default deny; the right plane; the right scope; the subtraction not the deny. The lab forces that precision into your fingers. Build it, break it, and the next time someone asks you a 403 question in an interview you won't reach for a memory — you'll reach for a function you wrote.
One more thing, and it's the most important. Security is the one area where "it works" is not the bar. A pipeline that works might be over-provisioned and you'd never know until the breach. So the question you bring to every access decision is not "does it work" but "what's the least this needs, and what's the blast radius if this identity is compromised?" The engineers who ask that question by reflex are the ones companies trust with the keys. It's learnable. It starts with the evaluator in this phase.
Go build it. Then come find me in Phase 05 — that's where we take everything here and watch it inherit down a management-group tree across a hundred subscriptions, which is where governance stops being a feature and becomes a platform.
— your brother 👨🏻
Lab 01 — RBAC & Azure Policy Evaluation Engine
Phase: 04 — RBAC & Azure Policy Evaluation Difficulty: ⭐⭐⭐☆☆ (the matcher is fiddly; the evaluation order is the principal bit) Time: 4–5 hours
Every Azure write passes through two authorization gates before a resource provider ever sees it: RBAC decides may this identity do this action on this scope? and Azure Policy decides is the resulting resource allowed to exist? Both are deny-biased, both evaluate against a scope hierarchy, and the most expensive bugs in a regulated Azure estate live in the evaluation order of each. This lab makes you build both machines — the wildcard
Actions/NotActionsmatcher, scope containment, the deny-assignment override, and the Policy condition-tree evaluator — so "why can't this service principal read the blob?" stops being a portal mystery and becomes an algorithm you can run in your head.
What you build
action_matches(pattern, action)— the heart of RBAC: case-insensitive matching of an action against a wildcard pattern where*spans one-or-more path segments (and within a segment).Microsoft.Storage/*/readmatchesMicrosoft.Storage/storageAccounts/read;*/readmatches any read;*matches everything. The literal.inMicrosoft.Storagemust not behave like a regex..RoleDefinition+role_permits(role, action, is_data=False)— effective perms =union(Actions) − union(NotActions), with a separateDataActions/NotDataActionspair for the data plane. This is the mechanism behind "anOwnercannot read a blob."scope_contains(assignment_scope, resource_id)— segment-aware ARM-id containment that knows…/resourceGroups/rgcontains…/rg/providers/…but not…/rg2.RoleAssignment,DenyAssignment,can_perform(...)— the full decision: deny assignments win, then default-deny unless some in-scope role assignment permits the action on the requested plane.evaluate_policy(resource, policy)+compliance_summary(...)— walk a policy'sifcondition tree (field/equals/notEquals/in/exists/likeunderallOf/anyOf/not) with dotted-alias field lookups, and return thethen.effecton a match (else"compliant"); roll many resources up into an effect-count.
Key concepts
| Concept | What to understand |
|---|---|
| Role assignment = (principal, role, scope) | the atomic RBAC fact; all three must line up |
union(Actions) − union(NotActions) | NotActions subtract from this role; they are not a deny |
| Control plane vs data plane | Actions manage the resource; DataActions govern data inside it — different sets |
| Owner ≠ Blob Data Reader | Owner has Actions=["*"] but no DataActions → can't read a blob |
| Additive + inherited | assignments add up and flow down MG → Sub → RG → resource |
| Deny always wins | a deny assignment overrides every role assignment; evaluated first |
| Default deny | no matching allow → False; least privilege is the floor |
* is multi-segment | Microsoft.Storage/*/read and */read both rely on * crossing / |
The rg vs rg2 trap | scope containment is by segments, not raw startswith |
| Policy effects | deny blocks the write; audit/append/modify/*IfNotExists do other things |
| Condition tree | allOf/anyOf/not + leaf operators over aliases (dotted resource paths) |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers — your implementation |
solution.py | complete reference; python solution.py runs three worked demos |
test_lab.py | the proof — run it red, make it green (43 tests) |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the three worked demos
Success criteria
- All 43 tests pass against your implementation (and
LAB_MODULE=solution). - You can explain why
action_matches("Microsoft.Storage/*/read", "Microsoft.Compute/…")is False butaction_matches("*/read", …)is True — i.e. what the*is and isn't allowed to swallow. - You can explain why
test_owner_actions_star_but_no_data_actionsproves the control-plane/data-plane split, and what role you add to actually read the blob. - You can explain why
scope_contains("/subscriptions/s/resourceGroups/rg", ".../rg2/…")must be False even though"…/rg2/…".startswith("…/rg")is True. - You can explain why
test_deny_assignment_wins_over_rolereturnsFalseeven though the principal isOwnerat the subscription — the order of evaluation. - You can explain why
exists: Truemust beTruefor a present-but-Falsefield, and how the missing-key sentinel makes that work. - Given a resource JSON and a policy rule, you can hand-evaluate the effect in 60 seconds.
How this maps to real Azure
| The miniature | The production mechanism | Where to verify it |
|---|---|---|
action_matches | Azure's Actions/NotActions are exactly these slash-delimited operation strings with * wildcards; the matcher ARM runs is the same shape. | az provider operation show --namespace Microsoft.Storage |
RoleDefinition / role_permits | A built-in or custom role definition: Actions, NotActions, DataActions, NotDataActions, AssignableScopes. Owner=["*"], Reader=["*/read"], Contributor=* minus Microsoft.Authorization/*. | az role definition list --name Owner |
RoleAssignment | The (principalId, roleDefinitionId, scope) triple created by az role assignment create; additive and inherited down the hierarchy. | az role assignment list --scope <id> --include-inherited |
scope_contains | An assignment at MG/Sub/RG applies to all descendants; the scope string is an ARM resource ID prefix. | az role assignment list --scope <rgId> shows inherited rows |
DenyAssignment / deny-wins | Deny assignments (created by Blueprints, Deployment Stacks denySettings, managed apps) override role assignments unconditionally — you cannot author them with a role. | az role assignment list --include-inherited (deny shown by REST Microsoft.Authorization/denyAssignments) |
| Control vs data plane | Owner manages the storage account but needs Storage Blob Data Reader/…Owner to touch blob data; same split for Key Vault data actions, Service Bus, etc. | az role definition list --name "Storage Blob Data Reader" — note DataActions |
evaluate_policy | Azure Policy's policyRule (if/then), condition operators, aliases (Microsoft.Storage/storageAccounts/supportsHttpsTrafficOnly), and effects (deny/audit/append/modify/auditIfNotExists/deployIfNotExists/disabled). | az policy definition show -n <builtin> |
compliance_summary | The Policy compliance state roll-up: counts of compliant/non-compliant resources per assignment from the periodic scan. | az policy state summarize |
Limits of the miniature (say these in the interview): real RBAC keys principals by
object ID, has ABAC conditions on a role assignment (e.g. tag/path constraints on blob
actions) we don't model, supports principal types (user/group/SP/MI) and group nesting,
and resolves role definition IDs not names. Real Policy uses aliases (a curated map
from a property to its API path, with [*] array aliases) rather than raw dotted JSON
paths, evaluates count/value/field in templates, has initiatives (policy sets)
and effect parameters, and runs deny/modify/append at request time but
audit*/deployIfNotExists on the async compliance scan (with a managed identity for
remediation). The evaluation order and the deny-bias, though, are faithful.
Extensions (build these on your own subscription)
- Add ABAC conditions: extend
RoleAssignmentwith an optional condition expression (@Resource[...]predicate) and short-circuitcan_performwhen it fails — the modern way to scopeStorage Blob Data *to a path prefix. - Add group membership: let a principal belong to groups and let assignments target a group; resolve transitively (mind nesting and the "deny still wins" rule).
- Add policy aliases: a
{alias: json_path}map soevaluate_policyaccepts the real aliasMicrosoft.Storage/storageAccounts/supportsHttpsTrafficOnlyand resolves it toproperties.supportsHttpsTrafficOnly. Then add anappend/modifyeffect that returns the mutated resource, not just the effect string. - Add an initiative (
policySet): evaluate a list of policies, and define effect precedence so adenyin any member dominates anauditin another. - Wire it to real Azure: create a custom role with
az role definition create, assign it, and confirm a denied action fails withAuthorizationFailed; then write the matchingdeny-effect policy and watch the deployment get blocked at create time.
Interview / resume
- Talking points: "Why can an
Ownernot read a blob, and what do you grant instead?" / "A service principal isContributorat the subscription but a deploy fails withRequestDisallowedByPolicy— walk me through both authorization gates." / "Explain why a deny assignment can't be created by a role, and when you'd use one." / "Order the Policy effects and tell me which fire at request time vs the compliance scan." / "How does an RBAC assignment at a management group reach a single blob?" - Resume bullet: Built an offline Azure authorization engine modeling RBAC
(
Actions/NotActionswildcard matching, control-vs-data-plane permission sets, scope-hierarchy inheritance, and deny-assignment override) and an Azure Policy evaluator (nestedallOf/anyOf/notcondition trees, alias field lookups, and effect resolution with compliance roll-up).
Phase 05 — Azure Landing Zones & Management-Group Scale
Difficulty: ⭐⭐⭐☆☆ (the mechanism) → ⭐⭐⭐⭐⭐ (the org-scale judgment) Estimated Time: 1 week (12–18 hours) Prerequisites: Phase 03 (Entra ID — the identity the tree authorizes) and Phase 04 (RBAC + Azure Policy — the single-assignment evaluator this phase composes across a tree). Read those first; this phase assumes them.
Why This Phase Exists
Phase 04 taught you to evaluate one assignment: given a role assignment or a policy at a scope, does this request pass? That is the senior skill. The principal skill is the layer above it: when you have hundreds of subscriptions and thousands of engineers, where do assignments live, how do they reach a given resource, and how do you stamp a new subscription with the right governance the moment it is born? That is what a landing zone is, and it is the difference between an Azure estate that scales and one that becomes an ungoverned sprawl of snowflake subscriptions nobody can audit.
The mechanism is deceptively simple and the consequences are enormous: a management group is a container above subscriptions, management groups form a tree with one Tenant Root Group at the top, and policy and RBAC assigned at any node inherit downward to every subscription and resource beneath it. So the question "who can touch this resource / which policies apply to it?" is never answerable from the resource alone — you walk up the tree. Get the tree right and governance is automatic; get it wrong and you are hand-assigning policy to 300 subscriptions and missing 40 of them.
Microsoft codified the right tree as the Cloud Adoption Framework (CAF) and its reference architecture, the Azure Landing Zone (ALZ). This phase makes you build the resolver that walks that tree: effective-assignment inheritance, subscription vending (placing a new subscription under the correct node so it inherits the baseline), and an estate-wide compliance score. The JD calls it out directly: "Configure and manage Azure Landing Zones … Azure Policies, RBAC … at scale."
Concepts
-
Cloud Adoption Framework (CAF) — Microsoft's end-to-end guidance for governed cloud adoption (strategy → plan → ready → adopt → govern → manage). The "Ready" methodology is where landing zones live; the "Govern" methodology is where policy-as-code and compliance scoring live.
-
Azure Landing Zone (ALZ) — CAF's reference architecture: a management-group hierarchy, a policy-as-code baseline, RBAC, hub-and-spoke networking, centralized logging, and a subscription-vending process. The canonical hierarchy:
Tenant Root Group ├── Platform │ ├── Identity (domain controllers, Entra Connect) │ ├── Management (Log Analytics, automation, backup) │ └── Connectivity (hub VNet, firewall, ExpressRoute/VPN, DNS) ├── Landing Zones │ ├── Corp (private workloads, no direct internet) │ └── Online (internet-facing workloads) ├── Sandbox (loose guardrails for experimentation) └── Decommissioned (deny-most, for subscriptions being retired) -
Management groups — a tree above subscriptions, max depth 6 levels of MGs under the Tenant Root Group. A subscription belongs to exactly one management group. Policy and RBAC assigned at an MG inherit to every descendant MG, subscription, and resource.
-
Effective governance at a scope =
(local assignments) ∪ (everything inherited from ancestors) − notScopes − exemptions. This is the single formula this phase exists to make muscle memory; it composes Phase 04's evaluator across the tree. -
Subscription vending — the automated, policy-compliant allocation of a new subscription into the right MG with budgets, RBAC, and networking pre-wired, so a new team gets a governed subscription in minutes, not a ticket queue in weeks.
-
Policy-as-code — policy/initiative definitions and assignments live in Git, are reviewed in PRs, versioned, and deployed via pipelines — so governance is auditable and a rollback is a
git revert, not an archaeology project. Initiatives (policy sets) bundle related policies; guardrails are expressed asdeny(block) vsaudit(flag, do not block). -
Compliance scoring — the estate-wide percentage of resources that pass the assigned initiatives, with a per-policy breakdown so you know which guardrail is failing where.
Labs
Lab 01 — Landing-Zone Resolver (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the governance engine of an Azure Landing Zone: a validated management-group tree (one root, no cycles, depth ≤ 6), the effective-assignment resolver (local ∪ inherited − notScopes − exemptions, deterministic), subscription vending (placement under the correct MG), and an estate compliance scorer with a per-policy breakdown |
| Concepts | The MG tree above subscriptions; downward inheritance; the effective-assignment formula; notScopes vs exemptions; subscription vending; initiatives and compliance scoring |
| Steps | 1. build_tree (validate one root / no dangling parents / no cycles / depth ≤ 6); 2. ancestors (leaf→root chain); 3. effective_assignments (union ancestors, apply notScopes + exemptions, deterministic order); 4. place_subscription (vending placement); 5. compliance_score (any-fail, by_policy, score_pct) |
| How to Test | pytest test_lab.py -v — covers tree build + cycle + depth>6 rejection, ancestor order, effective-assignment unions/exclusions/determinism, valid/invalid vending, and compliance math incl. 100% and partial cases |
| Talking Points | "Walk me up the tree for this subscription." "Why is depth capped at 6?" "notScopes vs an exemption?" "How does a new subscription get its baseline?" "What's your estate's compliance score and which policy drags it down?" |
| Resume bullet | Built a management-group inheritance and compliance resolver modeling Azure Landing Zone governance — effective policy/RBAC at any scope with notScopes/exemption handling and estate-wide compliance scoring |
→ Lab folder: lab-01-landing-zone-resolver/
Integrated-Scenario Suggestions
These carry the phase into the rest of the track — each is a real platform-team problem the landing-zone layer underpins:
- Greenfield estate bring-up — design the MG hierarchy for a regulated org (PCI/HIPAA tiers as MGs vs initiatives?), assign the baseline at the right scopes, and prove a new subscription lands compliant without manual steps. (Uses every function in the lab.)
- Subscription vending at scale — a self-service "request a subscription" flow: a team files a request, a pipeline picks the MG (Corp vs Online vs Sandbox), creates the subscription, places it, applies the budget + RBAC + networking, and reports back — in minutes. (Builds on P01 ARM/Bicep, P02 Terraform, P08 CI/CD.)
- Policy-as-code migration — take a portal-clicked pile of ad-hoc assignments, export
them, refactor into versioned initiatives in Git, gate changes with PR review and a
what-if-style compliance diff, and roll out via pipeline. (Builds on P04 Policy, P08.) - The "we're failing audit" fire drill — given a compliance score of 71%, find the
policy and the scope dragging it down, decide
denyvsauditvs exemption for each gap, and write the remediation plan (DINE policies, time-boxed exemptions). (Builds on P04 effects, P13 monitoring.) - Decommissioning safely — move a retiring subscription to the
DecommissionedMG so it inherits a deny-most baseline, verify nothing critical still depends on it, and prove the blast radius is contained. (Builds on P14 reliability/operations.)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Where This Phase Sits in the Track
This phase is the scale layer that turns Phase 04's single-assignment evaluator into estate-wide governance, and it is the substrate every later phase deploys into:
| Connects to | How |
|---|---|
| P03 — Entra ID | the Tenant Root Group is the Entra tenant; the identities the tree authorizes come from here |
| P04 — RBAC & Policy | P04 evaluates one assignment; P05 decides which assignments are in scope by walking the tree — they are a matched pair |
| P06 — Networking | the Connectivity MG holds the hub VNet, firewall, and DNS that Corp/Online inherit routing and resolution from |
| P08 — CI/CD | subscription vending and policy-as-code are pipelines — this is the governance content those pipelines deploy |
| P13 — Observability | the Management MG's central Log Analytics workspace is what compliance and audit read from |
| P15 — Capstone | the landing zone is the governed environment the whole secure, event-driven platform is built inside |
The mental model to carry forward: assign once, high up; inherit everywhere below; carve out explicitly and auditably. Every later phase that creates a resource is creating it somewhere in this tree, and therefore inheriting something — and a principal always knows what.
Key Takeaways
- A management group is a container above subscriptions; MGs form a tree (one Tenant Root Group, depth ≤ 6) and a subscription lives in exactly one MG.
- Governance inherits downward: a policy/RBAC assignment at an MG applies to every descendant. You can never answer "what applies here?" from a resource alone — walk up.
- The one formula: effective(scope) = local ∪ inherited − notScopes − exemptions, and the result is deterministic (most-local first).
- Subscription vending is placement: hang a new subscription under the right MG and the baseline (policy, RBAC, budget, networking) is inherited automatically. That is the whole promise of a landing zone — governance by construction, not by ticket.
- Policy-as-code + compliance scoring make governance reviewable and measurable: an initiative in Git, a PR review, a pipeline deploy, and a number you can put on a slide.
Deliverables Checklist
-
Lab 01 implemented; all tests pass against
solution.pyand yourlab.py - You can draw the canonical ALZ management-group hierarchy from memory and say what each node is for
- You can compute the effective assignments for a Corp subscription by hand (walk the chain, union, subtract notScopes/exemptions)
- You can explain why depth is capped at 6 and what breaks at depth 7
- You can describe subscription vending end-to-end and why it beats a ticket queue
- You can read a compliance score and name the policy + scope to fix first
Warmup Guide — Azure Landing Zones & Management-Group Scale
Zero-to-principal primer for Phase 05. Phase 04 gave you the evaluator for a single assignment. This phase is about scale: when you have hundreds of subscriptions and thousands of engineers, governance is no longer a question you answer per-resource — it is a tree you walk. We start from "what even is a management group" and end with the arithmetic of effective inheritance, subscription vending, and a compliance score you can defend in an audit. Every later phase deploys into the landing zone you reason about here.
Table of Contents
- Chapter 1: The Problem — Governance at Estate Scale
- Chapter 2: The Cloud Adoption Framework and the Azure Landing Zone
- Chapter 3: Management Groups — The Tree Above Subscriptions
- Chapter 4: Effective Inheritance — The One Formula
- Chapter 5: Subscription Vending — Governance by Construction
- Chapter 6: Policy-as-Code, Initiatives, and Compliance Scoring
- Chapter 7: The Canonical ALZ Hierarchy, Node by Node
- Chapter 8: Failure Modes and the 2 a.m. Diagnoses
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Problem — Governance at Estate Scale
From zero. A single subscription is easy to govern: you assign a few policies and roles, and you are done. Now make it real. A bank has 300 subscriptions across dev/test/prod, multiple business units, two regulators, and 4,000 engineers who all want to ship. Three things must be true at once:
- Every subscription enforces the non-negotiables (allowed regions, required tags, no public IPs on internal workloads, mandatory diagnostic logging).
- A new subscription is governed the moment it exists — not after a manual checklist a human forgets to run.
- You can prove to an auditor, with a number, what fraction of the estate is compliant and exactly which control is failing where.
You cannot do this by hand-assigning policy to 300 subscriptions. The math alone defeats you: one change to a baseline policy is 300 edits, and you will miss some. What you need is a way to assign governance once, high up, and have it flow down to everything beneath — and a structure that puts "everything beneath" into sensible, regulator-shaped buckets.
That structure is the management-group hierarchy, and the discipline that designs it well is the Cloud Adoption Framework. This phase is where "I can configure a policy" becomes "I can govern an estate."
Chapter 2: The Cloud Adoption Framework and the Azure Landing Zone
What CAF is. The Cloud Adoption Framework (CAF) is Microsoft's published, opinionated methodology for adopting Azure in a governed way. It is not a product; it is a set of recommendations, reference architectures, and tooling organised into methodologies — Strategy, Plan, Ready, Adopt, Govern, Manage, Secure. Two of them matter most here:
- Ready — prepare the environment. This is where landing zones live.
- Govern — keep it compliant as it grows. This is where policy-as-code and compliance scoring live.
What a landing zone is. An Azure Landing Zone (ALZ) is the reference implementation of "Ready": a pre-built, governed environment a workload can be dropped into and immediately inherit identity, networking, policy, RBAC, and logging. Concretely it is six things wired together:
| Pillar | What it provides | Track phase |
|---|---|---|
| Management-group hierarchy | the inheritance tree (this phase) | P05 |
| Policy-as-code baseline | the guardrails every subscription inherits | P04/P05 |
| RBAC model | who can do what, assigned at the right scope | P04 |
| Hub networking | shared firewall, DNS, ExpressRoute/VPN | P06 |
| Centralized logging | one Log Analytics workspace for the estate | P13 |
| Subscription vending | the automated "new subscription" pipeline | P05 |
Why it exists. Without it, every team builds its own snowflake: its own networking, its own (missing) logging, its own (absent) policy. The landing zone is the org saying "here is the paved road — build on it and security/compliance/networking are handled; leave it and you own all of that yourself." The whole point is self-service with guardrails — the only answer that scales to thousands of engineers.
The two flavors you will hear named. ALZ Bicep (Microsoft's accelerator deployed with
Bicep/ARM) and ALZ Terraform (the Azure/caf-enterprise-scale / AVM modules). Both deploy
the same conceptual hierarchy — the thing this lab models. Pick the IaC you already use; the
governance model is identical.
Chapter 3: Management Groups — The Tree Above Subscriptions
From zero. You already know the scope ladder from Phase 04:
management group → subscription → resource group → resource
A management group (MG) is the container above a subscription. MGs nest into a tree with exactly one root — the Tenant Root Group, created automatically per Entra tenant, whose id is the tenant id. Every subscription hangs under exactly one MG. So the full address space is a forest collapsed into one tree:
Tenant Root Group (the one node with no parent)
├── Platform
│ ├── Identity
│ ├── Management
│ └── Connectivity
├── Landing Zones
│ ├── Corp ──────────── sub-payments, sub-hr, … (subscriptions hang here)
│ └── Online ────────── sub-shop, sub-api, …
├── Sandbox
└── Decommissioned
The hard limits (memorise — interviewers probe the off-by-one):
| Limit | Value | Why it matters |
|---|---|---|
| Management groups per tenant | 10,000 | you will never hit it |
| Hierarchy depth | 6 levels of MGs under the root | the cap your build_tree enforces |
| Levels including root + subscription | up to 8 total | root (1) + 6 MGs + the subscription (1) |
| MGs a subscription can belong to | exactly 1 | vending is placement, not membership |
| Direct children of a node | no fixed small cap | breadth is fine; depth is the constraint |
Why depth is capped at 6. Inheritance is computed by walking from a scope up to the root,
unioning assignments at every hop. A bounded depth keeps that walk cheap and keeps humans able
to reason about it — six "why is this policy here?" hops is already a lot. Your build_tree
rejects depth 7 for the same reason ARM does: it is an invariant, not a suggestion.
Under the hood — the walk. When ARM authorizes a request or computes effective policy at a resource, it does not look only at the resource. It assembles the scope chain:
resource → resource group → subscription → MG → parent MG → … → Tenant Root Group
and unions every assignment found at every link. The lab's ancestors(scope, parent_map)
is this walk, returned leaf-first so index 0 is the most-local scope:
ancestors("sub-payments", tree)
# ['sub-payments', 'corp', 'landing-zones', 'root']
This is also the order you debug in: start at the broken resource, walk up until you find the assignment that explains the behavior. You can never answer "who can touch this / what applies here?" from the leaf alone.
Chapter 4: Effective Inheritance — The One Formula
This is the chapter the whole phase exists for. Write it on the whiteboard until it is reflex:
$$ \text{effective}(s) ;=; \underbrace{A(s)}{\text{local}} ;\cup; \bigcup{a ,\in, \text{ancestors}(s)} A(a) ;-; \text{notScopes} ;-; \text{exemptions} $$
In words: the assignments in effect at a scope are the union of everything assigned at that
scope and at every ancestor up to the root — minus the scopes an assignment explicitly carves
out (notScopes), minus any assignment a documented exemption suppresses.
Why union (not override). Assignments are additive. There is no "override a parent
policy from a child" — a child can only add assignments, never silently delete an inherited
one. (The one way to "remove" governance from below is a notScopes carve-out or an
exemption, both of which are explicit and auditable — that is the design intent.) For RBAC
the union is over Actions; for Policy it is over the set of assigned definitions. Phase 04
owns evaluating each assignment; Phase 05 owns which assignments are in the union.
notScopes vs exemptions (the distinction interviewers love).
notScopes | Exemption | |
|---|---|---|
| Lives on | the assignment (an array of excluded scopes) | a separate Microsoft.Authorization/policyExemptions resource at a scope |
| Excludes | a scope from this one assignment | an assignment (or specific policies in it) at this scope |
| Typical use | "this allowed-locations policy applies to all of root except Sandbox" | "this subscription is temporarily exempt from require-encryption while it migrates" |
| Lifecycle | structural, long-lived | usually time-boxed, with an expiry and a justification |
In the lab, Assignment.not_scopes is the first; the exemptions set passed to
effective_assignments is the second. Both remove from the union, but they answer different
governance questions — and a principal who conflates them gets caught in a design review.
Determinism — why ordering is part of the contract. The resolver sorts results by ancestor depth (most-local first), then assignment name:
effective_assignments("sub-payments", assignments, tree)
# [on-sub (depth 0), corp-baseline (1), allowed-locations (2, name<), require-tags (2)]
Most-local-first is the natural precedence order (it matches how you read a chain and how deny/precedence rules tend to surface the nearest assignment first), and a stable tiebreak by name makes the output byte-identical across runs — which is what lets you diff two estates, snapshot a baseline, and write a property test. Non-deterministic governance output is un-auditable; determinism is a feature, not a nicety.
Worked example. A Corp subscription sub-payments under corp:
root : require-tags, allowed-locations(notScopes=[sandbox])
landing-zones : deny-public-ip
corp : corp-no-internet
online : online-waf-required ← NOT on sub-payments' chain
platform : platform-admins (rbac) ← NOT on the chain
effective("sub-payments") walks [sub-payments, corp, landing-zones, root], so it picks up
corp-no-internet, deny-public-ip, allowed-locations, require-tags — and not
online-waf-required or platform-admins, because online and platform are siblings up
the tree, not ancestors. That last point is where people slip: inheritance is down, never
sideways.
Chapter 5: Subscription Vending — Governance by Construction
From zero. A team needs a new subscription. The bad way: file a ticket, a platform human manually creates a subscription, then runs a 30-step checklist (place it in the MG, assign RBAC, set a budget, peer the VNet, wire DNS, turn on logging) — and forgets step 17 on Tuesdays. The result is snowflake subscriptions and audit findings.
Subscription vending is the automated version: a pipeline takes a request (team, cost center, environment, Corp-vs-Online, budget) and produces a fully governed subscription. The load-bearing step is placement — hanging the new subscription under the correct management group:
parent_map = place_subscription("sub-shop", target_mg="online", parent_map=tree)
# sub-shop now inherits the entire 'online → landing-zones → root' baseline, automatically
The instant the subscription is a child of online, it inherits Online's policy, RBAC, and
networking baseline by construction — no checklist, no forgotten step. That is the whole
promise of a landing zone: governance is a property of where you are in the tree, not a
sequence of manual actions.
What real vending wires up (beyond placement):
| Step | Mechanism | Phase |
|---|---|---|
| Place under the right MG | this lab's place_subscription | P05 |
| Inherit policy/RBAC baseline | effective inheritance (Ch. 4) | P04/P05 |
| Set a budget + cost alerts | Cost Management budget | P14/FinOps |
| Peer to the hub VNet | VNet peering to Connectivity | P06 |
| Wire DNS / firewall routes | Private DNS, UDRs to the firewall | P06 |
| Bootstrap RBAC for the team | role assignment at the subscription | P04 |
The lab models the placement — the one step that makes all the others inherit. Note the
invariant the lab enforces: a subscription belongs to exactly one MG, so vending the same
subscription twice is an error (place_subscription raises if it is already placed).
Chapter 6: Policy-as-Code, Initiatives, and Compliance Scoring
Initiatives (policy sets). Assigning fifty individual policies to root is unmanageable. An
initiative (Azure: policy set definition) bundles related policy definitions into one
unit you assign once — e.g. the "Azure Security Benchmark" initiative is ~200 policies behind
a single assignment. In the lab, initiative is just a list[dict] of policy definitions and
compliance_score runs them all.
Guardrails: deny vs audit. Every policy declares an effect. Two dominate landing
zones:
deny— block the non-compliant write at the control plane. A hard guardrail: you literally cannot create a public IP on a Corp subscription.audit— allow the write but flag it non-compliant. A soft guardrail: it surfaces in the compliance score without breaking deployments.
The principal pattern is "audit first, then deny." You roll a new control out as audit,
watch the compliance score to size the blast radius (how much would deny have broken?), fix
the violations, then flip to deny. Shipping a deny straight to prod is how you take down
every team's pipeline at 9 a.m. In the lab, both deny and audit count a resource as
non-compliant — because audit failing still means the resource violates the control, even
though it was not blocked. That is faithful to Azure's compliance dashboard.
FAILING_EFFECTS = frozenset({"deny", "audit"}) # both mean "this resource failed the policy"
Compliance scoring — the number on the slide. Run every policy against every resource; a resource is non-compliant if any policy flags it. The score:
$$ \text{score_pct} ;=; 100 \times \frac{\text{compliant resources}}{\text{total resources}} $$
and a by_policy breakdown counts how many resources each policy flagged — so you do not
just learn "we're 71%," you learn "the https-only policy is flagging 40 of the 80
violations, all in Online — fix that first." An empty estate scores 100% by convention
(nothing failed). This is exactly what the Azure Policy Compliance blade computes, rolled
up per management group.
Policy-as-code, the discipline. All of the above lives in Git: definitions,
initiatives, and assignments as JSON/Bicep/Terraform, changed via pull request, reviewed
by humans, deployed by a pipeline. Benefits that matter in an audit: every change has an
author, a reviewer, and a timestamp; a bad rollout is a git revert; and you can run a
what-if / compliance-diff in CI before a deny reaches prod. Governance becomes software,
with software's safety nets — which is the entire thesis of this curriculum applied to the
control plane.
Chapter 7: The Canonical ALZ Hierarchy, Node by Node
You must be able to draw this from memory and say why each node exists. It is a near-certain
interview question and the spine of the lab's canonical_alz_tree().
Tenant Root Group
├── Platform — shared services the whole org depends on; locked-down, central team
│ ├── Identity — domain controllers, Entra Connect; the identity fabric
│ ├── Management — Log Analytics, Automation, backup; the management plane
│ └── Connectivity — hub VNet, Azure Firewall, ExpressRoute/VPN, Private DNS
├── Landing Zones — where actual application workloads live
│ ├── Corp — private workloads, NO direct internet (reach via the hub)
│ └── Online — internet-facing workloads (public endpoints, WAF, Front Door)
├── Sandbox — experimentation; loose guardrails, isolated from prod networking
└── Decommissioned — subscriptions being retired; deny-most baseline, no new deployments
Why split Platform from Landing Zones? Different audiences and different rates of change. Platform is owned by a small central team, changes rarely, and is heavily locked down — a mistake there has org-wide blast radius. Landing Zones are owned by application teams, change constantly, and need self-service within guardrails. Putting them under different MGs lets you assign different policy baselines to each with a single assignment per branch.
Why Corp vs Online? They have opposite networking policies. Corp denies public IPs
and forces traffic through the hub firewall; Online requires a WAF on its public
endpoints. One assignment at corp and one at online — instead of per-subscription rules —
is the entire payoff of having the sub-tree.
Why a Decommissioned MG? Retiring a subscription is dangerous (something always still depends on it). Moving it under a deny-most MG first freezes new deployments and shrinks the blast radius while you verify nothing breaks — governance as a safety procedure.
Sandbox is the pressure valve: give engineers a place to experiment without fighting the
prod guardrails, but isolate it so a sandbox mistake cannot reach production networks or data.
Note that allowed-locations in the lab's worked example carries notScopes=[sandbox] — a
real pattern, loosening one control for the sandbox while keeping it everywhere else.
Chapter 8: Failure Modes and the 2 a.m. Diagnoses
The failures this layer produces, and the one-line diagnosis each maps to:
- "My deployment is denied and I can't see why." A
denypolicy is inherited from an ancestor MG, not assigned at your subscription. Walk the chain (ancestors) and check every scope — the assignment is almost always two hops up. The resource never tells you; the tree does. - "I added a policy to the subscription but a child resource group ignores it." It does
not ignore it — RGs inherit from the subscription. If it really is not applying, check
for a
notScopescarve-out or an exemption at that RG suppressing it. - "We moved a subscription and governance didn't change for ten minutes." Management-group moves and assignment changes are eventually consistent (cache up to a few minutes). Not a bug — wait, or check the Activity Log for the move's completion.
- "Compliance dropped to 60% overnight." Someone flipped an
auditinitiative todeny, or added a new policy — and the existing estate was always non-compliant; the score just started counting it. Read theby_policybreakdown to find which policy, then which scope. - "A team can't create anything in their new subscription." It got vended under the wrong MG (Decommissioned or an over-locked Platform child). Re-place it under the right Landing-Zones node. Placement is the bug surface of vending.
The unifying skill: every one of these is answered by walking the scope chain and applying the one formula. That is why the lab is the lab.
Lab Walkthrough Guidance
Lab 01 — Landing-Zone Resolver, suggested order (matches the file top-to-bottom):
build_tree(parent_map)— validate one root, no dangling parents, no cycles, and depth ≤ 6 levels under root. Test the boundary explicitly: depth 6 allowed, depth 7 rejected (Ch. 3). The cycle check is a bounded walk-to-root; the dangling-parent check is "every referenced parent is a key."ancestors(mg_id, parent_map)— walk to the root, leaf-first.ancestors("corp", …)is["corp", "landing-zones", "root"];ancestors("root", …)is["root"](Ch. 3).effective_assignments(scope, assignments, hierarchy, exemptions)— select assignments whosescopeis on the ancestor chain, drop those excluded bynot_scopesfor this scope or by an exemption, and sort by(depth, name)for determinism (Ch. 4). Test: union over ancestors, off-chain assignments excluded,notScopesexclusion, exemption exclusion, and identical output across two runs.place_subscription(sub_id, target_mg, parent_map)— return a new map; validate the MG exists and the subscription is not already placed (Ch. 5).compliance_score(resources, initiative, evaluator)— any-fail per resource, aby_policycount, andscore_pct(Ch. 6). Test 100%, a partial case with theby_policybreakdown, and the empty-estate = 100% convention.
Run it red first (pytest test_lab.py -v against lab.py), then green. Then run
LAB_MODULE=solution pytest test_lab.py -v and python solution.py to see the worked
example print the canonical tree, the effective inheritance for a Corp subscription, a vend,
and a compliance score.
Success Criteria
You are ready for Phase 06 when you can, from memory:
- Draw the canonical ALZ management-group hierarchy and say what each node is for.
- State the management-group limits — depth ≤ 6 under root, exactly one MG per subscription.
- Write the effective-inheritance formula and explain why it is a union (additive), not an override.
- Distinguish a
notScopescarve-out from an exemption, with an example of each. - Explain subscription vending and why placement is the step that makes the baseline inherit.
- Read a compliance score with a
by_policybreakdown and name the policy + scope to fix first — and explain "audit first, then deny." - Diagnose "my deployment is denied and I can't see why" by walking the scope chain.
Interview Q&A
Q: What is a management group, and how does it differ from a resource group? A resource group is below a subscription — a container for resources that share a lifecycle, and the smallest scope you typically assign at the bottom. A management group is above a subscription — a container for subscriptions (and other MGs), forming a tree under the Tenant Root Group. The key behavioral difference is direction of inheritance: an assignment at an MG flows down through every subscription, RG, and resource beneath it, which is exactly what lets you govern hundreds of subscriptions with one assignment. RGs do not nest; MGs do (up to 6 levels). A subscription has exactly one parent MG and exactly one position in the tree.
Q: A resource is being denied a deployment and the developer swears there's no deny
policy on their subscription. Walk me through the diagnosis.
The deny is almost certainly inherited from an ancestor, not assigned at the
subscription — that is the single most common landing-zone confusion. I assemble the scope
chain: resource → RG → subscription → MG → parent MG → … → Tenant Root Group, and I check for
an assignment at every link, because the resource itself tells you nothing about inherited
governance. In an ALZ, a Corp subscription's "no public IP" deny typically lives two hops up,
at the corp or landing-zones MG. If I find it and there's a legitimate reason to allow this
one case, the fix is not to delete the policy (that breaks everyone) — it is a notScopes
carve-out for this scope or a time-boxed exemption with a justification. That distinction —
inherited governance, surgical exclusion — is the whole skill.
Q: Difference between notScopes and a policy exemption?
notScopes lives on the assignment and excludes a scope from that one assignment —
"this allowed-locations policy applies to all of root except the Sandbox MG." It is
structural and long-lived. An exemption is a separate resource at a scope that suppresses
an assignment (or specific policies within an initiative) for that scope — "this
subscription is exempt from require-encryption until 2025-09-01 while it migrates." It is
typically time-boxed, carries a justification and a category (Waiver vs Mitigated), and is
the auditable way to say "we know, and here's why, and here's when it ends." Both remove from
the effective set; they answer different governance questions and an auditor cares which you
used.
Q: Why is the management-group hierarchy depth capped, and at what?
At 6 levels of MGs under the Tenant Root Group (so up to 8 scopes total including root and
the subscription). It is capped because effective access and effective policy are computed by
walking the chain to the root and unioning at every hop — a bounded depth keeps that walk
cheap and, more importantly, keeps humans able to reason about it. Six "why is this here?"
hops is already near the limit of what's debuggable. It is a hard invariant: trying to create
a 7th level fails. In the lab, build_tree enforces exactly this, and the boundary test
(depth 6 allowed, 7 rejected) is the off-by-one a principal carries.
Q: Walk me through subscription vending and why it's better than a ticket.
Vending is the automated, policy-compliant allocation of a new subscription. A team submits a
request (cost center, environment, Corp-vs-Online, budget); a pipeline creates the
subscription and — the load-bearing step — places it under the correct management group.
The instant it's a child of, say, online, it inherits Online's entire policy/RBAC/networking
baseline by construction. The pipeline then wires the budget, peers the hub VNet, bootstraps
team RBAC, and turns on logging. The win over a ticket is twofold: speed (minutes, not a
two-week queue) and correctness (no human forgets step 17 — governance is a property of
where you are in the tree, not a checklist). The failure surface is placement: vend under the
wrong MG and the team either can't deploy (over-locked) or is dangerously under-governed.
Q: How do you roll out a new mandatory control to a 300-subscription estate without breaking
everyone?
Audit first, then deny. I assign the policy at the right MG (so it inherits everywhere) but
with effect audit, not deny. The compliance score and by_policy breakdown immediately
tell me the blast radius: how many resources would a deny have blocked, and in which
scopes. I work that list down — remediate, grant time-boxed exemptions where justified, give
teams a deadline — and only when the violation count is near zero do I flip the assignment to
deny. Shipping deny straight to prod is how you take down every pipeline at 9 a.m. The
whole thing is policy-as-code in Git, so the change is reviewed in a PR, the rollout is a
pipeline, and a regression is a git revert.
Q: Your estate's compliance score is 71%. What do you do?
First, I don't panic at the single number — I read the by_policy breakdown and the
per-MG rollup, because "71%" is meaningless until I know which policy and which scope. Say
it's the https-only policy flagging 90% of the gap, concentrated in the online MG. Now it's
a specific, answerable problem: is https-only a deny or audit (am I blocking or just
flagging)? Are these legitimate exceptions (then time-boxed exemptions) or real misconfig (then
a remediation — a deployIfNotExists policy can auto-fix many)? I fix the highest-count policy
first because it moves the number most, document each exemption, and re-score. The compliance
dashboard rolls up the MG tree, so I can show the score improving per scope — which is exactly
what an auditor and an exec want to see.
References
- Microsoft Learn — Cloud Adoption Framework: What is an Azure landing zone?
(
learn.microsoft.com/azure/cloud-adoption-framework/ready/landing-zone/) - Microsoft Learn — Azure landing zone conceptual architecture and the management-group
hierarchy (
.../ready/landing-zone/design-area/resource-org-management-groups) - Microsoft Learn — Organize your resources with management groups
(
learn.microsoft.com/azure/governance/management-groups/overview) — limits, depth, root - Microsoft Learn — Azure Policy: Overview, Initiative (policy set) definitions, Exemption structure, and Get policy compliance data
- Microsoft Learn — Subscription vending (
.../ready/landing-zone/design-area/subscription-vending) and thebicep-lz-vending/ Terraformlz-vendingmodules - Microsoft — Enterprise-Scale / ALZ reference implementations:
Azure/ALZ-BicepandAzure/caf-enterprise-scale(Terraform) on GitHub - The track's own CHEATSHEET.md (Management-group inheritance section) and GLOSSARY.md (Governance at Scale — Landing Zones)
- Phase 04 — RBAC & Azure Policy Evaluation (the single-assignment evaluator this phase composes across the tree)
🛸 Hitchhiker's Guide — Phase 05: Landing Zones & Management-Group Scale
Read this if: you can assign one policy to one subscription, but "govern 300 subscriptions for a regulated bank" sounds like a different job. It is. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP.
0. The 30-second mental model
A management group is a container above subscriptions; MGs form a tree with one Tenant Root Group on top, and policy + RBAC assigned at any node inherit downward to every subscription and resource beneath it. So "what governs this resource?" is never answerable from the resource — you walk up the tree and union everything you find. A landing zone is the right tree (CAF's reference: Platform / Landing Zones / Sandbox / Decommissioned) plus the automation (subscription vending) that drops a new subscription under the right node so it inherits the baseline by construction. One sentence to tattoo: governance is a property of where you are in the tree, not a checklist you run — assign once high up, inherit everywhere below.
1. The MG hierarchy to memorize (draw it cold in the interview)
Tenant Root Group
├── Platform (shared services; locked down; central team)
│ ├── Identity (domain controllers, Entra Connect)
│ ├── Management (Log Analytics, Automation, backup)
│ └── Connectivity (hub VNet, Azure Firewall, ExpressRoute/VPN, DNS)
├── Landing Zones (the actual workloads)
│ ├── Corp (private; DENY public IP; reach via hub)
│ └── Online (internet-facing; WAF required)
├── Sandbox (experimentation; loose guardrails; isolated)
└── Decommissioned (deny-most; for subscriptions being retired)
Platform vs Landing Zones = different audience + change rate. Corp vs Online = opposite networking policy, one assignment each. Decommissioned = a safety airlock for retirement.
2. The one formula (the whole phase)
effective(scope) = (assignments at this scope)
∪ (everything inherited from ancestors)
− notScopes (scope carved out of one assignment)
− exemptions (an assignment suppressed at a scope, usually time-boxed)
It's a union (additive) — a child can add governance, never silently delete an inherited
one. The only removals are explicit + auditable: notScopes and exemptions. Inheritance is
down, never sideways (a sibling MG is not an ancestor).
3. The numbers to tattoo on your arm
| Thing | Number |
|---|---|
| Max MG depth under root | 6 levels (depth 7 is rejected) |
| Total scope levels (root + MGs + sub) | up to 8 |
| MGs a subscription belongs to | exactly 1 |
| MGs per tenant | 10,000 (you'll never hit it) |
| Compliance score | 100 × compliant / total (empty estate = 100%) |
| Non-compliant if | any policy returns deny or audit failure |
| Rollout order | audit first, then deny |
4. The az account management-group one-liners
# Build the tree
az account management-group create --name platform --display-name "Platform" --parent root
az account management-group create --name connectivity --parent platform
# Subscription vending = placement under the right MG
az account management-group subscription add --name corp --subscription <sub-id>
# Walk / inspect
az account management-group show --name corp --expand --recurse # children + subs
az account management-group subscription show-sub-under-mg --name corp
# Move a subscription (e.g. into Decommissioned to retire it safely)
az account management-group subscription add --name decommissioned --subscription <sub-id>
# Policy at an MG, with a carve-out
az policy assignment create --name require-tags --scope <root-mg-id> \
--policy-set-definition <initiative-id> --not-scopes <sandbox-mg-id>
# Exemption (time-boxed)
az policy exemption create --name migrating-waiver --policy-assignment <assignment-id> \
--exemption-category Waiver --expires-on 2025-09-01 --scope <sub-scope>
# The compliance score
az policy state summarize --management-group <mg-id>
Terraform / Bicep equivalent: the Azure/caf-enterprise-scale (Terraform) or Azure/ALZ-Bicep
modules deploy this whole tree + baseline; lz-vending / bicep-lz-vending do the vending.
5. War story shapes you'll relive
- "My deployment is denied and there's no deny on my subscription!" → it's inherited from
an ancestor MG, two hops up. Walk the chain; the resource never tells you. Fix is a
notScopescarve-out or a time-boxed exemption — never delete the policy (that breaks everyone). - "Compliance dropped to 60% overnight." → someone flipped an
auditinitiative todenyor added a policy; the estate was always non-compliant, the score just started counting. Readby_policy→ find the policy → find the scope. - "We moved a subscription and nothing changed for 10 minutes." → MG moves are eventually consistent (cache up to a few minutes). Not a bug. Wait / check Activity Log.
- "New team can't create anything." → vended under the wrong MG (Decommissioned, or an over-locked Platform child). Placement is the bug surface of vending.
- "I assigned a policy to the sub but the RG ignores it." → it doesn't; RGs inherit. Check
for a
notScopesor exemption suppressing it at the RG.
6. Vocabulary that signals you've run an estate
- Tenant Root Group — the one node with no parent; its id is the tenant id.
- Landing zone — a governed environment a workload drops into and inherits everything.
- Subscription vending — automated placement + baseline for a new subscription.
- Initiative / policy set — a bundle of policies assigned as one unit.
notScopesvs exemption — scope-out-of-an-assignment vs assignment-suppressed-at-a-scope.- Audit-first-then-deny — the safe rollout for a new mandatory control.
- Self-service with guardrails — the only governance model that scales to thousands of engineers; the whole thesis of ALZ.
- Effective assignments — local ∪ inherited − exclusions; what actually applies here.
7. Beginner mistakes that mark you in interviews
- Trying to answer "what governs this resource?" from the resource — instead of walking up.
- Thinking a child can override/delete an inherited policy. It can't; only
notScopes/ exemptions remove, and they're explicit. - Confusing a sibling MG with an ancestor — inheritance is down, never sideways.
- Conflating
notScopesand exemptions (scope-vs-assignment, structural-vs-time-boxed). - Shipping a
denystraight to prod instead of audit-first. - Treating vending as a ticket-and-checklist instead of automated placement → the snowflake subscription problem the whole phase prevents.
- Quoting the depth limit wrong (it's 6 under root, and 7 is rejected).
8. How this phase pays off later
- P06 (Networking) — Connectivity MG is the hub; Corp/Online inherit hub routing + DNS.
- P04 (RBAC/Policy) — this phase composes P04's single-assignment evaluator across the tree; they're a pair.
- P08 (CI/CD) — subscription vending and policy-as-code are pipelines; this is where they run.
- P13 (Observability) — the Management MG's central Log Analytics is what compliance and audit read from.
- P15 (Capstone) — the landing zone is the substrate the whole secure platform sits in.
Now read the WARMUP slowly, then build the resolver. You'll use its tree-walk + one-formula mental model every time someone asks "wait, why is this allowed/denied here?"
👨🏻 Brother Talk — Phase 05, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. This phase looks boring on paper. "Management groups." A tree. Inheritance. Your eyes glaze, you think I'll skim it, it's just org charts. Stop. This is one of the highest-leverage phases in the whole track, and I'll tell you why nobody respects it until they've been burned.
Governance is invisible until it isn't. When the landing zone is designed well, nobody notices it — three hundred teams ship into a governed estate and it just works, and the platform team gets zero credit because nothing is on fire. But the day a regulator walks in, or a team accidentally exposes a Corp database to the internet, or someone has to prove what controls applied to which subscription on which date — that's when the person who built the tree right is the most valuable engineer in the building, and the person who let it sprawl is updating their résumé. You want to be the first one. This phase is how.
Here's the mindset shift, brother to brother.
Stop thinking "policy" and start thinking "blast radius of one assignment." A junior assigns a policy to a subscription and feels productive. A principal asks: where's the highest node I can assign this so it covers everything that needs it and nothing that doesn't? That's the entire game. Assign it at root and you've governed the whole estate with one line — and if you're wrong, you've broken the whole estate with one line. The power and the danger are the same lever. The skill is knowing exactly how far down the tree your assignment reaches, which is just the one formula you're about to build: local ∪ inherited − exclusions. Once that's reflex, you stop being scared of scope and start using it.
"Audit first, then deny" is a career-saving habit, not a best practice. I cannot overstate
this. The day you ship a deny policy straight to a 300-subscription estate because "it's
obviously correct," you take down every pipeline in the company at 9 a.m. and your name is on
the incident. The principal move is dead simple: ship it as audit, watch the compliance score
tell you exactly how much you would have broken, fix that quietly, then flip to deny.
You'll look slower than the cowboy who shipped deny on Friday. You'll also still be employed
on Monday. Slow is smooth, smooth is fast — and in governance, fast-and-broken is unrecoverable
because you broke everyone at once.
The notScopes-vs-exemption thing is a tell. It sounds like trivia. It's not — it's how
interviewers and auditors find out if you've actually run an estate or just read the docs.
Anyone can say "inheritance flows down." Only someone who's lived it knows that the real work
is the carve-outs: this one subscription needs an exception while it migrates, that whole
sandbox is exempt from the location policy forever, this team got a time-boxed waiver with a
justification because the alternative was blocking a launch. Governance in the real world is 20%
the baseline and 80% the exceptions, documented. The principal isn't the one who says "no" —
it's the one who says "yes, here's the time-boxed exemption, here's why, here's when it expires,
and here's the ticket to fix the root cause." That's the difference between a gate and a guard
rail.
Subscription vending is where you stop being a bottleneck. Here's the trap a lot of platform teams fall into: they make themselves the gatekeeper of every new subscription, every policy exception, every network change — and they feel important because everyone has to come to them. Then they are the bottleneck the org hired them to remove, and they burn out approving tickets instead of building. Vending is the antidote. You build the paved road once — request comes in, pipeline places the subscription under the right MG, baseline inherits, budget and network and RBAC get wired — and now teams self-serve a governed subscription in minutes without you. You scaled yourself out of the critical path. That is what principal-level platform work looks like: you're not the person who does the thing, you're the person who built the thing that does the thing, safely, a thousand times, while you sleep.
The honest truth about this phase's difficulty: the mechanism is easy — it's a tree walk and a union, you'll have the lab green in an afternoon. The hard part is the judgment of tree design: how many MGs, where to draw the Corp/Online line, what goes at root vs at a leaf, how to shape it so a regulator's "show me PCI scope" is one node and not a spreadsheet archaeology project. That judgment doesn't come from the lab — it comes from having designed a hierarchy, watched it survive two years of org changes, and seen where it bent. You won't have that yet. What you can have, today, is the mechanism so deeply internalized that when you do design one, you're reasoning about tradeoffs instead of fumbling with how inheritance works. Get the mechanism free, so your brain is spent on the judgment.
One more thing, the important one. A landing zone is an act of trust at scale. You are building the thing that lets people you'll never meet ship safely without asking permission. That's a profound responsibility and a quiet kind of power — you're encoding the org's "how we do things safely" into a structure that enforces itself. Most engineers optimize a function. You're optimizing how a thousand other engineers make decisions. When you get this right, you're not just senior, you're load-bearing — the platform other careers are built on. That's the principal you're becoming.
Go build the resolver. The one formula — local ∪ inherited − exclusions — is the whole thing. Make it reflex. Then come find me in Phase 06, where we make the network underneath all this click — the routing and the firewalls and the DNS that the Connectivity MG you just drew is actually for.
— your brother 👨🏻
Lab 01 — Landing-Zone Resolver
Phase: 05 — Azure Landing Zones & Management-Group Scale | Difficulty: ⭐⭐⭐☆☆ | Time: 3–5 hours
Phase 04 answered "given an assignment, does this request pass?" This lab answers the question that comes first: "which assignments even apply here?" The answer is a tree walk — a subscription inherits every policy and role assignment from its management-group chain up to the Tenant Root Group, minus the explicit carve-outs. You build that resolver, the management-group tree it walks, the subscription-vending placement that puts a new subscription under the right node, and the compliance scorer that grades the whole estate. This is the engine behind an Azure Landing Zone.
What you build
build_tree(parent_map)— validate a management-group hierarchy (one root, no dangling parents, no cycles, depth ≤ 6 levels of MGs under root) — the structural invariants ARM enforces.ancestors(mg_id, parent_map)— the scope chain leaf→root; the order you reason in during an incident ("start at the resource, walk up to find who can touch it").Assignment+effective_assignments(scope, assignments, hierarchy, exemptions)— the governance law:local ∪ inherited − notScopes − exemptions, returned in a deterministic order (most-local first, then name).place_subscription(sub_id, target_mg, parent_map)— subscription vending: attach a subscription under an MG so it inherits that MG's baseline; a subscription belongs to exactly one MG.compliance_score(resources, initiative, evaluator)— score an estate against an initiative (policy set); a resource is non-compliant if any policy returns a failing effect. Returns{total, compliant, non_compliant, by_policy, score_pct}. Ships a smallbuiltin_evaluatortoo.canonical_alz_tree()— the reference ALZ hierarchy you must be able to draw from memory.
Key concepts
| Concept | What to understand |
|---|---|
| MG tree above subscriptions | management groups form a tree above subscriptions; one Tenant Root Group at the top |
| Depth ≤ 6 | Azure caps the hierarchy at 6 levels of MGs under root; depth 7 must be rejected |
| One MG per subscription | a subscription belongs to exactly one management group — vending is placement |
| Inheritance is downward | a policy/RBAC assignment at an MG applies to every descendant scope |
| Effective = ∪ − exclusions | local ∪ inherited − notScopes − exemptions; the whiteboard formula |
notScopes vs exemption | notScopes excludes a scope from one assignment; an exemption suppresses an assignment (time-boxed) |
| Compliance = any-fail | a resource is non-compliant if any policy flags it; audit counts as non-compliant even though it does not block |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All tests pass against your implementation.
- You can explain why
test_build_tree_rejects_depth_sevenmatters — Azure caps MG depth at 6 levels under root, and the off-by-one (6 allowed, 7 rejected) is exactly the kind of limit a principal carries. - You can explain why
effective_assignmentsreturns results most-local-first and why that ordering is deterministic (sort by ancestor depth, then name). - You can articulate the difference between a
notScopesexclusion (a scope is carved out of one assignment) and an exemption (an assignment is suppressed entirely, usually time-boxed) — interviewers probe this. - You can draw the canonical ALZ management-group hierarchy from memory and say what each node is for.
How this maps to real Azure
| Lab construct | Real Azure |
|---|---|
parent_map / build_tree | the management-group hierarchy under your Tenant Root Group; az account management-group create --parent … |
ancestors(...) | the scope chain ARM walks to compute effective access (MG → … → Tenant Root) |
Assignment.scope | the scope of a policy/role assignment (an MG id, /subscriptions/{id}, /resourceGroups/{rg}, or a resource id) |
not_scopes | the notScopes array on a policy assignment (and excludedScopes on initiatives) |
exemptions | Microsoft.Authorization/policyExemptions — documented, often time-boxed carve-outs |
effective_assignments(...) | what az policy state / the Defender for Cloud / "Policy → Compliance" blade compute when they show effective assignments at a scope |
place_subscription(...) | az account management-group subscription add — the placement step of subscription vending |
initiative / compliance_score(...) | a policy initiative (policy set) and the estate-wide compliance percentage on the Policy compliance dashboard |
What the miniature omits (and where it lives): the real policy condition language
(field, count, anyOf, aliases) is Phase 04's job — here the evaluator is a callback
so the resolver stays about scope, not condition. Real inheritance also interacts with
deny assignments (P04) and RBAC Actions/NotActions wildcard matching (P04); this
lab assumes those are resolved and focuses on which assignments are in scope. Management
groups also cache for up to a few minutes after a move (eventual consistency), which the
deterministic lab does not model.
The az one-liners this stands in for:
az account management-group create --name platform --parent root
az account management-group subscription add --name corp --subscription <sub-id>
az policy assignment create --name require-tags --scope <mg-id> \
--policy-set-definition <initiative-id> --not-scopes <sandbox-mg-id>
az policy state summarize --management-group <mg-id> # the compliance score
Extensions
- Add deny assignments to
effective_assignmentsand make a deny at any ancestor win over an allow (mirrors P04's "deny always wins") — then test that an MG-level deny masks a subscription-level allow. - Add
effective_diff(scope_a, scope_b, ...)returning the assignments that differ between two scopes — the "why is Corp locked down but Online isn't?" query. - Make
compliance_scoreaccept a per-scope estate (resources tagged with their scope) and roll the score up the MG tree, so each MG shows its own and its descendants' score — the real Policy compliance dashboard. - Add policy-as-code validation: parse an initiative from JSON, assert every policy has a
declared
effectin the allowed set, and reject a PR that introduces adenywithout an approval label.
Phase 06 — Azure Networking & the Software-Defined Network
Difficulty: ⭐⭐⭐⭐☆ (the mechanism is exact; the judgment is hard) Estimated Time: 1–1.5 weeks (14–20 hours) Prerequisites: Phase 00 (control plane vs data plane), and ideally Phases 03–05 (identity and governance) — because in a regulated estate, the network is the second perimeter after identity, and a landing zone hands you the hub-and-spoke you deploy into.
Why This Phase Exists
Every other phase assumes packets reach the right place and the wrong packets do not. This phase is where that assumption gets built. In Azure there is no physical network you can trace with a cable — the VNet, the subnet, the firewall, the route, the load balancer are all software running on the hosts, enforced by the virtual filtering platform (VFP) on every NIC. That is the gift and the curse: the network is programmable, versioned, and policy-driven (the gift), and it is invisible unless you understand the algorithms that decide each packet's fate (the curse).
The JD is explicit that this is a principal-level expectation:
"Networking is software-defined. You can evaluate an NSG rule set in priority order, compute effective routes with longest-prefix matching across system/UDR/BGP routes, and explain why a Private Endpoint changes DNS resolution and how that breaks (and fixes) connectivity."
Those three sentences are three engines, and this phase makes you build them. Not because you will reimplement Azure, but because the engineer who has implemented NSG priority evaluation never again guesses why a packet was dropped — they read the effective-rules dump the way they read code. The single most common production network incident in Azure is a broken Private Endpoint DNS resolution, and the single most common interview trap is priority ordering / longest-prefix match. Both are in this lab.
The mental model: the four questions every packet answers
A packet leaving a VM in Azure passes through four decisions, in order. Hold this picture — it is the spine of the whole phase:
┌─────────────────────────────────────────────────────────────┐
VM NIC → │ 1. NSG (outbound) 2. Route table 3. NSG (inbound, │ → destination
│ allow/deny? where's next hop? at the dest) │
│ priority order longest-prefix allow/deny? │
└─────────────────────────────────────────────────────────────┘
▲ ▲
and BEFORE any of it: 0. DNS — what IP is the destination?
(the Private Endpoint override lives here — and breaks here)
Numbered the way you debug them: (0) DNS resolves the name to an IP — and a Private Endpoint silently rewrites that IP; (1) the source NSG decides if the flow may leave; (2) the route table picks the next hop by longest-prefix match (this is where a firewall UDR intercepts everything); (3) the destination NSG decides if the flow may arrive. Miss any one and the packet vanishes with no error. The lab builds engines for 0, 1, and 2 (DNS, NSG, routes) plus the address math underneath all of it.
Concepts
- The VNet / subnet model — a software-defined L3 network: a private CIDR (RFC 1918) scoped to one region and one subscription, carved into subnets. There is no router; the host enforces routing and filtering in software. Azure reserves 5 IPs per subnet and the smallest usable subnet is /29.
- Network Security Groups (NSGs) — stateful, priority-ordered (100..4096) allow/deny
rules evaluated lowest-number-first, first-match-wins, with default rules (allow
VirtualNetwork↔VirtualNetwork+AzureLoadBalancerinbound, deny the rest). Stateful means return traffic is implicit — you never write a return rule. - Effective routes & UDR — the per-NIC route table is the union of system routes,
your UDRs, and BGP-learned routes. The next hop is chosen by longest-prefix
match; on a tie, UDR > BGP > system. A
0.0.0.0/0UDR to aVirtualApplianceforce-tunnels all egress through a firewall/NVA. - Private Endpoint / Private Link — a private IP for a PaaS resource that rewrites
DNS: the public FQDN (e.g.
acct.blob.core.windows.net) resolves to the private IP via a Private DNS zone (privatelink.blob.core.windows.net) linked to the VNet. The #1 failure is broken DNS (the zone isn't linked → still resolves public). Service endpoints are the older, coarser alternative (keep traffic on the backbone, but the resource keeps its public IP). - Topology — hub-and-spoke with VNet peering, an Azure Firewall in the hub, and the load-balancing trinity: Front Door (global L7), Application Gateway (regional L7 + WAF), Load Balancer (regional L4). Where each sits is an exam and an architecture question.
Labs
Lab 01 — Network Engine (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the four decision engines of Azure's SDN: subnet/CIDR math (5 reserved IPs, /29 minimum, split), the NSG priority/first-match/default-rule evaluator, the effective-route longest-prefix engine with UDR>BGP>system tie-break, and the Private Endpoint DNS-override resolver |
| Concepts | reserved IPs and the /29 floor; stateful priority-ordered first-match NSG evaluation; longest-prefix routing and route origin tie-break; the Private DNS override and its #1 failure mode |
| Steps | 1. CIDR math (subnet_usable_hosts, cidr_contains, split_subnet); 2. NsgRule + evaluate_nsg with defaults; 3. Route + effective_next_hop; 4. PrivateZone + resolve_fqdn |
| How to Test | pytest test_lab.py -v — 25 tests: the −5 and /29 boundary, containment both ways, NSG priority/first-match/default allow+deny, longest-prefix /32 vs /0 and the UDR>system tie, and private-vs-public DNS with/without a link |
| Talking Points | "Walk me through NSG evaluation order." "A packet is dropped and every rule looks fine — where do you look?" "Why does my storage account still resolve to a public IP after I created the Private Endpoint?" "Force all egress through a firewall — what exactly do you change?" |
| Resume bullet | Built a network-policy decision engine (NSG priority evaluation, longest-prefix effective routing, Private Endpoint DNS override) that reproduces the real allow/deny, force-tunnel, and broken-DNS failure modes |
→ Lab folder: lab-01-network-engine/
Integrated-Scenario Suggestions (carried through the whole track)
The network is the substrate the rest of the platform deploys into. Keep these in mind — each maps a JD theme onto the engines you build here:
- Hub-and-spoke landing-zone network — a hub VNet with Azure Firewall and a VPN/ER
gateway, peered to app/data spokes; a UDR in every spoke forcing
0.0.0.0/0to the firewall; NSGs per subnet tier (web/app/data). Youreffective_next_hopandevaluate_nsgare the verifier for "does this packet take the path I think?" - Private-only PaaS — storage, Key Vault, SQL, and Service Bus exposed only via
Private Endpoints, public access disabled, with a centralised Private DNS zone resolver
linked to every spoke. Your
resolve_fqdnreproduces the make-or-break DNS step. - Zero-trust micro-segmentation — deny-by-default NSGs, Application Security Groups per workload role, just-in-time access; the platform team owns the rule baseline, app teams request exceptions. Your priority/first-match engine is the policy the baseline encodes.
- Ingress trinity — Front Door (global routing + WAF + caching) → Application Gateway (regional L7 + WAF + path routing) → internal Load Balancer (L4) → AKS/VMSS. The framing question: which layer terminates TLS, which does WAF, which does L4?
- Hybrid connectivity — ExpressRoute + VPN with BGP route propagation into the hub, and the route-table arithmetic (BGP-learned vs UDR override) that decides whether on-prem traffic transits the firewall. Your route-origin tie-break is exactly this decision.
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation: the numbers,
the
azone-liners, the war stories; read first - WARMUP.md — the full professor primer; read slowly, with the subnet math
- BROTHER-TALK.md — the candid version: why DNS is always the answer
Key Takeaways
- The Azure network is software-defined: NSGs, routes, firewalls, and load balancers are algorithms running on the hosts. Learn the algorithm and the network stops being a black box.
- NSG = priority order, first match wins, stateful, deny-by-default. A low-number Deny shadows a high-number Allow. Return traffic is implicit.
- Routing = longest-prefix match, UDR > BGP > system. A
/32UDR pins one host; a/0UDR force-tunnels the world through your firewall. - Private Endpoint = a private IP + a DNS rewrite. When Private Link "doesn't work," it is almost always the DNS zone not linked to the VNet — and the symptom is a public IP where you expected a private one.
- Topology is a tradeoff, not a default: hub-and-spoke centralises the firewall and the bill; Front Door vs App Gateway vs Load Balancer is L7-global vs L7-regional vs L4.
Deliverables Checklist
-
Lab 01 implemented; all 25 tests pass against
solution.pyand yourlab.py - You can compute usable hosts for any prefix and state Azure's /29 floor from memory
- You can hand-evaluate an NSG rule set in priority order and name the deciding rule
- You can read an effective route table and predict the next hop by longest-prefix match
-
You can diagnose a "Private Endpoint not working" report as a DNS-link problem and say
exactly which
az network private-dns ...link is missing - You can draw the hub-and-spoke + firewall + Private DNS topology from memory and place Front Door / App Gateway / Load Balancer at the right layer
Warmup Guide — Azure Networking & the Software-Defined Network
Zero-to-principal primer for Phase 06. There is no cable to trace in Azure: the VNet, the subnet, the firewall, the route, and the load balancer are software running on every host. This guide builds the network from the empty address space up — the subnet math, the four decisions a packet passes through (DNS, source NSG, route, destination NSG), and the one mechanism that breaks more Azure deployments than any other: Private Endpoint DNS. We start from "what even is a virtual network" and end with the arithmetic you do on a whiteboard before any resource exists, plus the failure signatures you will diagnose at 2 a.m.
Table of Contents
- Chapter 1: The Software-Defined Network — There Is No Cable
- Chapter 2: VNets, Subnets, and the Address Plan
- Chapter 3: Network Security Groups — Priority, First-Match, Stateful
- Chapter 4: Effective Routes and User-Defined Routes
- Chapter 5: Private Endpoints, Private Link, and the DNS Override
- Chapter 6: Topology — Hub-and-Spoke, Peering, and the Firewall
- Chapter 7: Load Balancing — Front Door vs App Gateway vs Load Balancer
- Chapter 8: Failure Modes and the 2 a.m. Diagnoses
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Software-Defined Network — There Is No Cable
From zero. In a data centre, a network is physical: NICs, cables, switches, routers, firewalls — you can trace a packet by following copper. In Azure, none of that exists for you. When you create a "virtual network," Azure does not allocate a switch. It writes a policy that the virtual filtering platform (VFP) — a programmable packet processor running on every physical host — enforces in software, per virtual NIC.
What it is. The SDN is a set of control-plane objects (VNet, subnet, NSG, route table,
Private Endpoint, firewall, load balancer) that compile down to data-plane rules the host
applies to each packet. The control plane is ARM (Phase 01): you PUT an NSG and ARM stores
it; the host then programs the VFP with the resulting allow/deny table.
Why it exists. Physical networks do not scale to a multi-tenant cloud: you cannot run a cable per customer, and you cannot let one tenant's broadcast reach another's. Software definition gives isolation (each VNet is its own L3 address space, invisible to others), programmability (network config is IaC, versioned and reviewed), and policy at scale (a rule applies to thousands of NICs at once).
Under the hood — the packet's four decisions. A packet from a VM is processed by the host in this order. This ordering is the single most useful thing in this whole guide:
sender VM receiver VM
───────── ───────────
(0) DNS resolve dest name → IP ← Private Endpoint rewrites this
(1) outbound NSG on sender NIC ← allow/deny, priority order, stateful
(2) route table on sender NIC ← longest-prefix → next hop (maybe a firewall)
… traverse fabric / firewall / peering …
(3) inbound NSG on receiver NIC ← allow/deny, priority order, stateful
A dropped packet is always one of these four, and they fail silently — no ICMP, no log unless you turned on flow logging. The principal skill is knowing which of the four to check, in order. The lab builds engines for (0), (1), and (2).
Production significance. Because the network is software, "it works on my VM" tells you
nothing — the effective rules and routes for a given NIC are what matter, and they are the
sum of everything inherited from the subnet, the NIC, and the platform. Azure exposes the
computed result (az network nic show-effective-route-table,
... show-effective-nsg-rules), and reading those dumps is the job.
Common misconceptions.
- "A VNet spans regions." No — a VNet is regional (and per-subscription). You connect VNets across regions with peering or a gateway.
- "NSGs and firewalls are the same." No — an NSG is an L3/L4 stateful ACL on a NIC/subnet; a firewall (Azure Firewall) is a stateful L3–L7 appliance with FQDN/app rules, NAT, and threat intel that traffic is routed to. You use both.
Chapter 2: VNets, Subnets, and the Address Plan
From zero. A Virtual Network (VNet) is a private IP address space you own inside one
Azure region — almost always from the RFC 1918 private ranges (10.0.0.0/8,
172.16.0.0/12, 192.168.0.0/16). You carve it into subnets, and resources (VM NICs,
Private Endpoints, gateways) get IPs from a subnet.
What CIDR is. A CIDR block 10.0.0.0/16 means "the first 16 bits are the network, the
last 16 are host." The prefix length ( p ) fixes the split:
$$ \text{total addresses} = 2^{(32 - p)} $$
So /16 is ( 2^{16} = 65{,}536 ) addresses, /24 is ( 2^8 = 256 ), /29 is
( 2^3 = 8 ).
Why Azure reserves five. In every subnet, Azure takes five addresses off the top:
| Address | Use |
|---|---|
.0 (network) | the network identifier — never assignable |
.1 | the default gateway (the VFP's router for this subnet) |
.2, .3 | mapped to Azure DNS / platform services |
last (.255 of the range) | broadcast — reserved, though Azure does not use broadcast |
So the usable host count is:
$$ \text{usable}(p) = 2^{(32 - p)} - 5 $$
This is why a /29 (8 addresses) has only 3 usable — and why Azure rejects anything
smaller than /29: a /30 has 4 total addresses, minus 5 is negative. The /29 floor
is a number to memorise.
| Prefix | Total | Usable | Typical use |
|---|---|---|---|
/29 | 8 | 3 | smallest possible; tiny PE/utility subnet |
/28 | 16 | 11 | small subnet |
/27 | 32 | 27 | GatewaySubnet minimum (Microsoft recommends /27) |
/26 | 64 | 59 | app tier |
/24 | 256 | 251 | the comfortable default |
/16 | 65,536 | 65,531 | a whole VNet |
Under the hood — splitting. Designing an address plan is splitting a block into
children. A /24 splits into four /26s (+64 each) or sixteen /28s. The arithmetic:
splitting from prefix ( p ) to ( q > p ) yields ( 2^{(q - p)} ) children. The lab's
split_subnet is exactly this, and ipaddress.ip_network(...).subnets(new_prefix=q) does
it for you.
Production significance. Address planning is a one-way door. You can grow a VNet's
space but resizing a subnet with resources in it is painful, and overlapping CIDRs cannot
peer — two VNets with 10.0.0.0/16 each can never be connected. So the platform team owns
a non-overlapping IP plan for the whole estate (often allocated per region/spoke from a big
super-net), and app teams get a subnet, not a free choice.
Common misconceptions.
- "I'll use a /30 for a point-to-point subnet." Azure forbids it — /29 is the floor.
- "Reserved IPs are just
.0and broadcast like on-prem." No — Azure takes five, including.1/.2/.3. Off-by-three will wreck a tight address plan.
Chapter 3: Network Security Groups — Priority, First-Match, Stateful
From zero. A Network Security Group (NSG) is a stateful packet filter — an ACL — you attach to a subnet and/or a NIC. It holds security rules, each one an allow or deny for a 5-tuple (protocol, source, source port, destination, destination port) in one direction (inbound or outbound).
What "priority" means. Every rule has a priority from 100 to 4096. Lower number = higher priority = evaluated first. Azure evaluates rules in ascending priority order and stops at the first match — first match wins. This is the rule that trips people up:
priority 100 Deny from 203.0.113.0/24 → * (evaluated FIRST)
priority 200 Allow from Internet → :443 (evaluated SECOND)
A packet from 203.0.113.9:443 hits rule 100 first → denied, even though rule 200
would have allowed it. A low-number Deny shadows a high-number Allow. The lab's
evaluate_nsg sorts by priority ascending and returns the first match — this is the
mechanism.
Why stateful matters. NSGs are stateful (connection-tracking): if you allow an outbound flow, the return traffic is allowed automatically, and vice versa. You do not write a return rule. This is why "allow outbound 443 to the internet" is enough for a VM to make HTTPS calls and receive the responses — the established connection's inbound packets are permitted by the connection-tracking table, not by an inbound rule. In the lab we model statefulness by evaluating only the originating direction; the return path is implicit.
Under the hood — the default rules. Even an empty NSG has behaviour, because Azure adds default rules at the bottom (priorities 65000+) that you cannot delete:
| Direction | Priority | Rule | Effect |
|---|---|---|---|
| Inbound | 65000 | AllowVnetInBound | allow VirtualNetwork → VirtualNetwork |
| Inbound | 65001 | AllowAzureLoadBalancerInBound | allow AzureLoadBalancer → any |
| Inbound | 65500 | DenyAllInBound | deny everything else |
| Outbound | 65000 | AllowVnetOutBound | allow VirtualNetwork → VirtualNetwork |
| Outbound | 65001 | AllowInternetOutBound | allow any → Internet |
| Outbound | 65500 | DenyAllOutBound | deny everything else |
Two consequences a principal carries: (a) intra-VNet traffic is allowed by default (you
must add a Deny to micro-segment), and (b) inbound from the internet is denied by
default (you must add an Allow to expose anything). The lab's _default_rules reproduces
this exactly, which is why evaluate_nsg([], vnet_to_vnet_packet, ...) returns Allow and
evaluate_nsg([], internet_packet, ...) returns Deny.
Service tags. A rule's source/dest can be a service tag — a symbolic name Azure
expands to a managed prefix list: VirtualNetwork (your VNet space + peered + on-prem),
Internet (everything public), AzureLoadBalancer (the probe IP 168.63.129.16),
Storage, Sql, AzureCloud, regionalised like Storage.WestEurope. Tags mean you never
hardcode Microsoft's IP ranges (which change). The lab models the three that the defaults
need.
Production significance. NSGs are the micro-segmentation layer. The platform baseline is usually deny-by-default inbound with explicit allows per tier (web/app/data), and Application Security Groups (ASGs) let you name a role ("web-servers") and write rules against the role instead of IPs. The classic mistake is putting an NSG on both the subnet and the NIC and forgetting that both must allow the flow (they are evaluated independently; the effective decision is the intersection).
Common misconceptions.
- "Higher priority number = more important." Backwards — lower number wins.
- "I need an inbound rule for return traffic." No — stateful. Writing one is a smell.
- "NSG blocks by default, so my VNet is segmented." No —
AllowVnetInBoundlets all intra-VNet traffic through until you add a Deny.
Chapter 4: Effective Routes and User-Defined Routes
From zero. Once an NSG allows a packet out, where does it go? That is the route table. Azure builds an effective route table per NIC from three sources:
- System routes — Azure's built-in defaults: a route to the VNet space (
VnetLocal), to peered VNets (VnetPeering), to on-prem via a gateway, and a catch-all0.0.0.0/0 → Internet. - User-Defined Routes (UDRs) — your route table, attached to a subnet, overriding the system routes.
- BGP routes — learned dynamically from on-prem over ExpressRoute/VPN.
What "effective" means. For a given destination IP, Azure picks one route by
longest-prefix match: among all routes whose prefix contains the destination, the one
with the most specific (longest) prefix wins. A /32 beats a /24 beats a /0.
Under the hood — the tie-break. When two routes have the same prefix length, Azure breaks the tie by origin:
$$ \text{UDR} ;>; \text{BGP} ;>; \text{system} $$
This is why a UDR 0.0.0.0/0 → VirtualAppliance beats the system route 0.0.0.0/0 → Internet (same /0 length, UDR wins) and force-tunnels all egress through your firewall.
The lab's effective_next_hop is exactly this: filter to matching routes, then
min by the key (-prefixlen, origin_priority, name) — longest prefix first, then origin,
then name for determinism.
dest 8.8.8.8:
system 0.0.0.0/0 → Internet (matches, /0)
UDR 0.0.0.0/0 → VirtualAppliance (matches, /0, UDR wins the tie) ★
→ next hop = VirtualAppliance (the firewall) — forced tunnelling
dest 203.0.113.55:
UDR 0.0.0.0/0 → VirtualAppliance (matches, /0)
UDR 203.0.113.55/32 → VirtualAppliance (matches, /32 — longest) ★
→ next hop = the /32 route — one host pinned to a specific appliance
Next-hop types you must know: VirtualAppliance (an NVA / Azure Firewall — requires a
next-hop IP), VirtualNetworkGateway (VPN/ER), VnetLocal (stays in the VNet), Internet
(default egress), VnetPeering, and None (a blackhole — silently drops the packet,
the cause of many "it just disappears" incidents).
Production significance. The 0.0.0.0/0 → firewall UDR is the backbone of a secure
landing zone: every spoke routes its internet-bound and inter-spoke traffic through the hub
firewall for inspection. But it is also a footgun: if you force-tunnel 0.0.0.0/0 to a
firewall and forget a route exception for the firewall's own dependencies (or for Azure
platform traffic), you can blackhole the firewall and take down the whole spoke. And a UDR
that points 0.0.0.0/0 at an appliance with no SNAT/return route breaks asymmetric-
routing flows.
Common misconceptions.
- "The most-recently-added route wins." No — longest prefix, then origin.
- "BGP overrides my UDR." Backwards — UDR overrides BGP at equal prefix length (which surprises people doing hybrid networking; your UDR can silently shadow a learned route).
- "A
Nonenext hop is an error." No — it is a deliberate blackhole, and a sneaky way to drop traffic with no log.
Chapter 5: Private Endpoints, Private Link, and the DNS Override
From zero. A PaaS resource — a storage account, Key Vault, SQL database — has a public
FQDN (acct.blob.core.windows.net) and, by default, a public IP. In a regulated
estate you want it reachable only from your private network. Private Link is the
mechanism, and a Private Endpoint is its concrete object: a NIC with a private IP in
your subnet that maps to the PaaS resource.
The catch (and the whole point): DNS. Your apps connect by name, not IP — they call
acct.blob.core.windows.net. That name publicly resolves to the storage account's public
IP. Creating a Private Endpoint gives you a private IP (say 10.0.3.10) — but unless
DNS resolution is overridden, your VM still resolves the name to the public IP and never
uses the private endpoint. So Private Link works by rewriting DNS:
public DNS: acct.blob.core.windows.net → 20.150.43.16 (public IP)
with Private Endpoint + linked Private DNS zone:
zone: privatelink.blob.core.windows.net holds acct... → 10.0.3.10
VM in a LINKED VNet resolves: acct.blob.core.windows.net → 10.0.3.10 (PRIVATE) ★
VM in an UNLINKED VNet: acct.blob.core.windows.net → 20.150.43.16 (public)
Under the hood. Three objects must all exist and connect:
- the Private Endpoint (the NIC + private IP),
- the Private DNS zone
privatelink.blob.core.windows.netwith an A record for the resource pointing at the private IP (auto-created if you let Azure manage it), - a virtual-network link joining that zone to the VNet the client lives in.
When all three exist, Azure DNS (the 168.63.129.16 resolver) returns the private IP via a
CNAME chain: acct.blob.core.windows.net → acct.privatelink.blob.core.windows.net →
(in the private zone) → 10.0.3.10. The lab's resolve_fqdn models step (3) precisely:
it returns the private record only if the zone holds the record and the caller's VNet
is in the zone's link set; otherwise it falls back to the public record. That conditional is
the bug and the fix.
Service endpoints — the alternative. A service endpoint is the older mechanism: it keeps traffic to the PaaS service on the Azure backbone and lets the service's firewall trust your subnet — but the resource keeps its public IP and public FQDN (no DNS change), and access is per-subnet, not a private IP. Private Endpoint is the modern default (private IP, works across peering and on-prem, granular per-resource); service endpoints are simpler but coarser and do not extend to on-prem.
Production significance. Broken Private Endpoint DNS is the single most common Azure
networking incident. The endpoint is green, the NSG is fine, the firewall is fine — and the
app still hits the public IP (and then fails, because you disabled public access, or
succeeds but leaves the private network, defeating the point). The fix is almost always a
missing VNet link on the Private DNS zone, or a custom DNS server that does not forward
privatelink.* to Azure DNS. In a hub-and-spoke, the Private DNS zones live in the hub and
are linked to every spoke — centralised resolution.
Common misconceptions.
- "I created the Private Endpoint, so it's private now." Not until DNS resolves to the private IP for this VNet — check the zone link.
- "I'll just use the private IP directly." PaaS TLS certs are issued for the public FQDN; hitting the IP breaks certificate validation. You must fix DNS, not bypass it.
- "Service endpoint and Private Endpoint are the same." No — service endpoints don't change DNS or give a private IP, and don't reach on-prem.
Chapter 6: Topology — Hub-and-Spoke, Peering, and the Firewall
From zero. Real estates have many VNets. VNet peering connects two VNets so their VMs talk over the Azure backbone as if on one network (low latency, no gateway, but non-overlapping CIDRs required, and peering is non-transitive — A↔B and B↔C does not give A↔C).
Hub-and-spoke is the canonical topology: a central hub VNet holds shared services —
the Azure Firewall, the VPN/ExpressRoute gateway, the Private DNS zones, shared
DNS — and spoke VNets (per app/team/environment) peer to the hub. Spokes do not peer
to each other; inter-spoke traffic transits the hub firewall (via a 0.0.0.0/0 UDR), which
is why you accept non-transitive peering — you want the firewall in the path.
┌──────────── hub VNet ────────────┐
on-prem ──ER/VPN─┤ Gateway Azure Firewall DNS ├
└───────┬───────────┬───────────┬──┘
peering│ peering│ peering│
┌──────┴──┐ ┌─────┴───┐ ┌────┴────┐
│ spoke A │ │ spoke B │ │ spoke C │
│ (web) │ │ (app) │ │ (data) │
└─────────┘ └─────────┘ └─────────┘
each spoke: UDR 0.0.0.0/0 → firewall private IP (force-tunnel + inspect)
Production significance. The hub centralises cost (one firewall, one gateway), DNS (one set of Private DNS zones linked to all spokes), and policy (one inspection point). It also centralises blast radius — a hub firewall outage takes down every spoke's egress — so the firewall runs zone-redundant, and you keep break-glass routes.
Chapter 7: Load Balancing — Front Door vs App Gateway vs Load Balancer
From zero. Three services balance traffic at different layers and scopes. The principal framing is L7-global vs L7-regional vs L4-regional:
| Service | Layer | Scope | Does | Use when |
|---|---|---|---|---|
| Azure Front Door | L7 (HTTP/S) | global | global routing, anycast, caching/CDN, WAF, TLS offload, failover across regions | a public web app served worldwide; multi-region failover |
| Application Gateway | L7 (HTTP/S) | regional | path/host routing, WAF, TLS, cookie affinity, rewrite | regional web app needing WAF + URL routing |
| Load Balancer | L4 (TCP/UDP) | regional | flow-hash distribution, health probes, ultra-low latency, any TCP/UDP | non-HTTP, very high throughput, or internal (ILB) |
| Traffic Manager | DNS | global | DNS-based routing (returns an endpoint by policy) | when you need DNS-level multi-region steering only |
Under the hood. Front Door and App Gateway terminate HTTP and can run a WAF (OWASP rule inspection) and route by path/host; Load Balancer only sees a flow tuple and hashes it to a backend — it cannot read a URL. A common production stack is Front Door (global edge + WAF + caching) → App Gateway (regional WAF + path routing) → internal Load Balancer (L4) → AKS/VMSS. The interview question "where do you terminate TLS and run WAF?" has the answer "at the L7 layer (Front Door and/or App Gateway), never at the L4 Load Balancer."
Chapter 8: Failure Modes and the 2 a.m. Diagnoses
| Symptom | First suspect | The mechanism |
|---|---|---|
| "Private Endpoint not working; resolves to public IP" | Private DNS zone not linked to this VNet | Ch. 5 — no link → public record returned |
| "Packet dropped, every rule looks fine" | a lower-priority Deny shadowing the Allow | Ch. 3 — first match wins, lowest number first |
| "All egress suddenly broken in this spoke" | a 0.0.0.0/0 UDR to a dead/misrouted firewall | Ch. 4 — force-tunnel + blackhole |
| "Two VNets won't peer" | overlapping CIDRs | Ch. 2 — overlapping address space can't peer |
| "A→C can't talk, A→B and B→C work" | peering is non-transitive | Ch. 6 — needs hub routing/firewall in path |
| "Intra-VNet traffic I meant to block still flows" | AllowVnetInBound default is allow | Ch. 3 — must add a Deny to segment |
| "Return traffic blocked" (you wrote an inbound deny) | NSGs are stateful | Ch. 3 — don't fight the connection tracker |
| "Works from VM, fails from on-prem to PaaS" | on-prem DNS not forwarding privatelink.* | Ch. 5 — DNS conditional forwarder missing |
The throughline: most Azure network incidents are NSG priority, route longest-prefix, or Private DNS — the exact three engines you build in the lab. Calm at 2 a.m. comes from checking them in order (DNS → source NSG → route → dest NSG), not from guessing.
Lab Walkthrough Guidance
Lab 01 — Network Engine, suggested order (matches the file top-to-bottom):
- CIDR math —
subnet_usable_hosts(p)is ( 2^{(32-p)} - 5 ); raise for ( p>29 ) (the /29 floor) and for out-of-range prefixes.cidr_containsandsplit_subnetare thin wrappers overipaddress— let the stdlib parse, you add the Azure rules (Ch. 2). - NSG — write
NsgRule.__post_init__validation (direction, access, priority 100–4096, protocol), then the four matchers (_port_matches,_endpoint_matches,_protocol_matches,_rule_matches), thenevaluate_nsg: filter by direction, sort by priority ascending, return the first match, else fall through to_default_rules(Ch. 3). The test that proves you got it: a priority-100 Deny beats a priority-200 Allow. - Routes —
Route.__post_init__validation (next-hop types,VirtualApplianceneeds an IP), theneffective_next_hop: keep matching routes, raise if none, pick longest prefix with the UDR > BGP > system tie-break via aminkey of(-prefixlen, origin_priority, name)(Ch. 4). The test that proves it: a/32UDR beats/0, and a UDR/0beats a system/0. - Private DNS —
resolve_fqdn: return the private record only if the zone holds it and the caller's VNet is in the (zone link ∪vnet_links) set; else the public record; else raise NXDOMAIN (Ch. 5). The test that proves it: linked → private IP, unlinked → public IP.
Run python solution.py to see all four engines produce a worked example, then make
test_lab.py green against your lab.py.
Success Criteria
You are ready for Phase 07 when you can, from memory:
- State why a
/29has 3 usable hosts and why/29is Azure's floor (the 5 reserved IPs and ( 2^{(32-p)} - 5 )). - Evaluate an NSG rule set in priority order and name the deciding rule — and explain why a low-number Deny shadows a high-number Allow.
- Recite the six default rules and explain why intra-VNet is allowed but inbound-from- internet is denied by default.
- Explain stateful evaluation and why you never write a return rule.
- Pick the next hop from an effective route table by longest-prefix match and apply the UDR > BGP > system tie-break.
- Draw the Private Endpoint DNS override (CNAME chain into the
privatelinkzone) and say exactly which missing object causes a public-IP resolution. - Place Front Door / Application Gateway / Load Balancer at L7-global / L7-regional / L4 and say where TLS terminates and WAF runs.
Interview Q&A
Q: Walk me through how an NSG decides to allow or drop a packet.
Azure keeps the rules for the packet's direction, sorts them by priority ascending — lowest
number first — and evaluates top-down, stopping at the first match. That rule's
access (Allow/Deny) is the decision; no later rule is consulted. So a priority-100 Deny beats
a priority-200 Allow for the same packet. If no user rule matches, Azure's default rules
apply: inbound allows VirtualNetwork→VirtualNetwork and AzureLoadBalancer, then denies
everything; outbound allows VNet and Internet, then denies. And NSGs are stateful, so I
only reason about the direction the flow originates — the return packets are permitted by
connection tracking, not a second rule. If an NSG is on both the subnet and the NIC, both
must allow it; the effective decision is the intersection.
Q: A VM can't reach a storage account over its Private Endpoint. The endpoint shows
"approved," the NSG allows 443, the firewall is fine. What's wrong?
Almost certainly DNS. Private Link works by overriding name resolution:
acct.blob.core.windows.net must resolve to the private IP via the
privatelink.blob.core.windows.net zone, and that zone must be linked to this VNet. I'd
run nslookup acct.blob.core.windows.net from the VM — if it returns the public IP, the
Private DNS zone is missing the A record or, more commonly, the VNet link is missing, so
this VNet never sees the private zone. If the VM uses a custom DNS server, the other cause is
that the server isn't forwarding privatelink.* to Azure DNS (168.63.129.16). The fix
is the missing link or forwarder — not anything on the data path.
Q: How do you force all internet-bound traffic from a subnet through a firewall?
Attach a UDR with route 0.0.0.0/0 → next-hop type VirtualAppliance, next-hop IP = the
firewall's private IP, to the subnet's route table. By longest-prefix match that /0
catches everything not covered by a more specific route, and because at equal prefix length
UDR beats the system route, it overrides the default 0.0.0.0/0 → Internet. I'd also add
the firewall's own return path and any platform exceptions so I don't blackhole the
firewall, and confirm with az network nic show-effective-route-table that the next hop is
the appliance.
Q: Two routes match a destination — how does Azure choose?
Longest-prefix match: the route with the most specific (longest) prefix wins, so a /32
host route beats a /24 beats 0.0.0.0/0. Only when prefix lengths are equal does the
origin tie-break apply: UDR > BGP > system. That's why your UDR 0.0.0.0/0 overrides
both the system default and a BGP-learned 0.0.0.0/0, and it's a hybrid-networking gotcha —
a UDR can silently shadow a route you're learning from on-prem.
Q: When do you use a Private Endpoint vs a service endpoint? A service endpoint keeps traffic to a PaaS service on the Azure backbone and lets the service trust my subnet, but the resource keeps its public IP and FQDN (no DNS change) and it doesn't reach on-prem. A Private Endpoint gives the resource a private IP in my subnet, works across peering and from on-prem, is per-resource granular, and lets me disable public access entirely — at the cost of managing DNS. For a regulated, private- only estate I default to Private Endpoints; service endpoints are a lighter option when I only need backbone routing and per-subnet trust and don't need on-prem or a private IP.
Q: Why is the smallest Azure subnet a /29, and how many hosts does it give you?
Azure reserves five addresses in every subnet — the network address, .1 (gateway),
.2/.3 (Azure DNS mapping), and the broadcast — so usable = ( 2^{(32-p)} - 5 ). A /29
has 8 addresses, minus 5 = 3 usable; a /30 has only 4 total, which can't cover the 5
reservations, so Azure rejects anything smaller than /29.
Q: Front Door, Application Gateway, or Load Balancer — how do you choose? By layer and scope. Front Door is global L7 — anycast edge, caching, WAF, multi- region failover — for a worldwide public web app. Application Gateway is regional L7 — WAF, path/host routing, TLS — for a regional web app. Load Balancer is regional L4 — flow- hash TCP/UDP, lowest latency, internal or public — for non-HTTP or very high throughput. TLS termination and WAF live at L7 (Front Door/App Gateway), never at the L4 Load Balancer. A global app often chains them: Front Door → App Gateway → internal Load Balancer → compute.
References
- Microsoft Learn — Virtual network and subnet concepts, and the five reserved IP
addresses per subnet:
learn.microsoft.com/azure/virtual-network/virtual-networks-overview - Microsoft Learn — How network security groups filter network traffic (priority,
first-match, stateful, default rules):
learn.microsoft.com/azure/virtual-network/network-security-groups-overview - Microsoft Learn — Virtual network traffic routing (system routes, UDR, BGP,
longest-prefix, the UDR>BGP>system selection order):
learn.microsoft.com/azure/virtual-network/virtual-networks-udr-overview - Microsoft Learn — What is Azure Private Link / Private Endpoint and Private Endpoint
DNS configuration (the
privatelinkzones and VNet links):learn.microsoft.com/azure/private-link/private-endpoint-dns - Microsoft Learn — Service tags overview (
VirtualNetwork,Internet,AzureLoadBalancer, regionalised tags):learn.microsoft.com/azure/virtual-network/service-tags-overview - Microsoft Cloud Adoption Framework — Hub-and-spoke network topology and the Azure Landing Zone network design.
- Microsoft Learn — Load-balancing options decision guide (Front Door vs Application Gateway vs Load Balancer vs Traffic Manager).
- RFC 1918 — Address Allocation for Private Internets (the
10/8,172.16/12,192.168/16ranges) and RFC 4632 — CIDR. - The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 06: Azure Networking & the SDN
Read this if: you can click "create virtual network" but a dropped packet still feels like witchcraft. It isn't — it's three algorithms (NSG priority, route longest-prefix, Private DNS) and a subnet-math rule. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the engine.
0. The 30-second mental model
A packet in Azure passes four gates, and a drop is always one of them — silently, with no log unless you turned on flow logging:
(0) DNS what IP is the dest? ← Private Endpoint rewrites this; #1 failure
(1) src NSG may it leave? ← priority order, first match wins, stateful
(2) route where's the next hop? ← longest-prefix match; UDR > BGP > system
(3) dst NSG may it arrive? ← same NSG rules, at the destination
One sentence to tattoo: the Azure network is software — NSGs, routes, and DNS are algorithms on the host, and when something breaks it's almost always NSG priority, route longest-prefix, or Private DNS.
1. The numbers to tattoo on your arm
| Thing | Number |
|---|---|
| Reserved IPs per subnet | 5 (.0, .1, .2, .3, broadcast) |
| Usable hosts | 2^(32−prefix) − 5 |
| Smallest usable subnet | /29 → 3 usable hosts |
| GatewaySubnet | /27 or larger (recommended) |
| NSG user priority range | 100–4096 (lower = first) |
| NSG default priorities | 65000 / 65001 / 65500 |
| AzureLoadBalancer probe IP | 168.63.129.16 (also Azure DNS / wireserver) |
| Route selection | longest prefix, tie → UDR > BGP > system |
| Private DNS zone (blob) | privatelink.blob.core.windows.net |
| RFC 1918 ranges | 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 |
| VNet scope | one region, one subscription (peer to extend) |
Subnet cheat row: /24=251, /26=59, /27=27, /28=11, /29=3 usable.
2. NSG in one breath
- Rules have a priority 100–4096; Azure sorts ascending and stops at the first match. Low number wins. A Deny at 100 shadows an Allow at 200.
- Stateful: allow the outbound flow and the return is automatic. Never write a return rule (it's a smell).
- Defaults (can't delete): allow
VirtualNetwork↔VirtualNetwork, allowAzureLoadBalancerin, allowInternetout, deny the rest. So: intra-VNet is open by default; inbound-from-internet is closed by default. - Service tags save you from hardcoding Microsoft IPs:
VirtualNetwork,Internet,AzureLoadBalancer,Storage,Sql,Storage.WestEurope, …
3. Routes in one breath
- Effective table = system + UDR + BGP, computed per NIC.
- Longest-prefix match picks the next hop.
/32beats/24beats/0. - Equal prefix → UDR > BGP > system. That's why a UDR
0.0.0.0/0overrides the default. - Force all egress through a firewall: UDR
0.0.0.0/0 → VirtualAppliance @ fw-private-ip. - Next-hop types:
VirtualAppliance(needs IP),VnetLocal,Internet,VirtualNetworkGateway,VnetPeering,None(blackhole — silent drop).
4. Private Link in one breath
- A Private Endpoint = a NIC + private IP in your subnet, mapped to a PaaS resource.
- It only works because of a DNS override:
acct.blob.core.windows.net→privatelink.blob.core.windows.netzone → private IP — but only if that zone is linked to your VNet. - The bug: zone not linked → name still resolves to the public IP → connection fails (public access disabled) or silently leaves the private net.
- Service endpoint = the lighter alternative: backbone routing + subnet trust, but no private IP, no DNS change, no on-prem.
5. az one-liners worth memorising
# Subnet usable check — what's the next hop / effective rules for a NIC? (the real debug)
az network nic show-effective-route-table -g RG -n my-nic -o table
az network nic list-effective-nsg-rules -g RG -n my-nic -o table
# Create the spine
az network vnet create -g RG -n hub --address-prefixes 10.0.0.0/16 \
--subnet-name AzureFirewallSubnet --subnet-prefixes 10.0.0.0/26
az network nsg create -g RG -n app-nsg
az network nsg rule create -g RG --nsg-name app-nsg -n AllowHTTPS \
--priority 200 --direction Inbound --access Allow --protocol Tcp \
--source-address-prefixes Internet --destination-port-ranges 443
# Force egress through a firewall (the UDR)
az network route-table create -g RG -n spoke-rt
az network route-table route create -g RG --route-table-name spoke-rt -n default \
--address-prefix 0.0.0.0/0 --next-hop-type VirtualAppliance \
--next-hop-ip-address 10.0.0.4
# Private DNS — the thing people forget to LINK (the #1 fix)
az network private-dns zone create -g RG -n privatelink.blob.core.windows.net
az network private-dns link vnet create -g RG -n spoke-link \
--zone-name privatelink.blob.core.windows.net --virtual-network spoke --registration-enabled false
# Peering (non-transitive; non-overlapping CIDRs required)
az network vnet peering create -g RG -n hub-to-spoke --vnet-name hub \
--remote-vnet spoke --allow-vnet-access
# The first command in ANY "Private Endpoint not working" ticket — run it ON the VM:
nslookup acct.blob.core.windows.net # private IP = good; public IP = unlinked zone
6. War story shapes you'll relive
- "The Private Endpoint is green but the app hits the public IP." → the Private DNS
zone was never linked to that spoke VNet (or a custom DNS server doesn't forward
privatelink.*).nslookupon the VM tells you instantly. Fix the link, not the data path. - "My Allow rule isn't working." → a lower-priority Deny (smaller number) shadows it. First match wins. Sort the rules by priority and read top-down.
- "The whole spoke lost internet at once." → a
0.0.0.0/0UDR force-tunnels to a firewall that's down/misrouted — every egress blackholes. Check the effective route table's next hop. - "These two VNets refuse to peer." → overlapping CIDRs. You can't peer
10.0.0.0/16with10.0.0.0/16. Re-IP one (a one-way door — plan addresses up front). - "A talks to B, B talks to C, but A can't reach C." → peering is non-transitive. Route A↔C through the hub (and its firewall) on purpose.
- "I blocked it with an inbound deny but return traffic still flows." → NSGs are stateful; you're fighting connection tracking. Block the originating direction.
7. Vocabulary that signals you've held the pager
- Effective rules / effective routes — the computed result for a NIC; what you actually
debug (
show-effective-*), not the rule you wrote. - Longest-prefix match — the route selection algorithm;
/32beats/0. - Force tunnelling — UDR
0.0.0.0/0→ firewall; all egress inspected. - Force-tunnel blackhole — that same UDR pointed at a dead next hop.
- Privatelink zone / VNet link — the DNS objects that make Private Endpoints actually private; the link is the thing people forget.
- Service tag — symbolic prefix set (
VirtualNetwork,Internet,Storage.WestEurope). - Non-transitive peering — A↔B + B↔C ≠ A↔C.
- Hub-and-spoke — central firewall/gateway/DNS, peered spokes, UDR to the firewall.
- ASG (Application Security Group) — name a role and write NSG rules against it.
8. Beginner mistakes that mark you in interviews
- Saying "higher priority number wins" — it's lower.
- Writing an inbound rule for return traffic — NSGs are stateful.
- Thinking a Private Endpoint is private the moment it's created — it's the DNS link.
- Assuming the network spans regions or that any two VNets can peer — regional, and non-overlapping CIDRs only.
- Forgetting the 5 reserved IPs and the /29 floor in subnet math.
- Putting WAF/TLS termination on the L4 Load Balancer — that's the L7 layer (Front Door / App Gateway).
- Force-tunnelling
0.0.0.0/0to a firewall with no return/SNAT path and blackholing it.
9. How this phase pays off later
- P07 (AKS/ACR): pods get IPs from the VNet (Azure CNI); NSGs and Private Endpoints to ACR are exactly these engines. Your subnet math sizes the node pools.
- P10–P12 (events, serverless, Key Vault): every secure deployment is **Private Endpoint
- Private DNS** — the resolver you built here.
- P14 (reliability): a hub firewall / gateway is a failure domain; zone-redundancy and break-glass routes are blast-radius decisions on this topology.
- Every interview: NSG priority and longest-prefix routing are the two most-asked Azure networking questions, and "why doesn't my Private Endpoint work" is the most-asked debug.
Now read the WARMUP slowly (do the subnet math by hand), then build the network engine. You'll use these four algorithms to read effective-rules dumps for the rest of your career.
👨🏻 Brother Talk — Phase 06, Off the Record
No slides, no exam objectives. This is me, your brother, telling you the things about Azure networking that people only admit after the incident is over and the second coffee is poured. Read it once now, and again the week before your interview.
Listen. Let me save you a year of confusion with one sentence: in Azure networking, it's
always DNS. I'm only half joking. You will spend hours staring at NSG rules and firewall
logs and route tables, and the answer — over and over — will be that a Private DNS zone was
never linked to the VNet, so the name resolved to the public IP and nothing on the data path
was ever wrong. The first thing you do on any Private Link ticket is nslookup from the
actual VM. If it returns a public IP, you're done diagnosing — go link the zone. The people
who look senior aren't smarter; they just check DNS first instead of last.
Here's the mindset shift that matters most. You don't "set up" an Azure network — you build a program that decides each packet's fate, and then you debug the program. There's no cable. The NSG is a sorted list of rules with a first-match-wins loop. The route table is a longest- prefix-match function. Private DNS is a conditional lookup. Once you see them as the three small algorithms they are — the exact three you build in this lab — the network stops being spooky. A dropped packet isn't bad luck; it's a function returning Deny, or a route returning the wrong next hop, or a name resolving to the wrong IP. You can trace it. That's the whole game, and it's why this lab makes you implement the functions instead of clicking blades.
Let me give you the three things I wish someone had told me.
One: lower priority number wins, and it will burn you in an interview if you flinch on it. Everyone's instinct is "higher priority = more important." In NSGs it's backwards — priority 100 is evaluated before priority 4000, and the first match wins, so a Deny at 100 silently shadows your Allow at 200. Say it out loud until it's reflexive: lower number, evaluated first, first match wins. When an interviewer hands you a rule set and asks "what happens to this packet," sorting by priority ascending and reading top-down is the entire answer, and doing it calmly is the whole signal.
Two: the 0.0.0.0/0 UDR to a firewall is the most powerful and most dangerous line you'll
ever write. It's the backbone of every secure landing zone — force every spoke's egress
through the hub firewall for inspection. It's also how you take down an entire spoke at 2 a.m.
when the firewall's down, or when you forgot a return route and traffic blackholes. Respect
it. When you add force-tunnelling, you always ask "what happens to the firewall's own
traffic, and what's my break-glass route if this appliance dies?" Power and blast radius are
the same wire here.
Three: subnet math is a one-way door, so do it on paper before anything exists. You can
grow a VNet, but you cannot easily resize a subnet full of resources, and two VNets with
overlapping CIDRs can never peer — which means a lazy 10.0.0.0/16 today is a re-IP project
in eighteen months. The platform team owns one non-overlapping address plan for the whole
estate and hands app teams a subnet, not a choice. Memorize the 2^(32−p) − 5 and the /29
floor not because it's on the exam, but because the person who can size the address plan in
their head in the design meeting is the person who gets trusted with the network.
Now, the honest career framing. Networking is the layer that makes you indispensable,
because almost nobody on an app team understands it and almost every nasty production incident
touches it. Identity (Phase 03–05) gets the glory; networking gets the pager. When the app
team is panicking because "the service can't reach storage," and you calmly run one nslookup,
see the public IP, and say "your Private DNS zone isn't linked to this spoke — here's the one
command to fix it," you've just done in ninety seconds what a room full of senior engineers
couldn't do in an hour. That moment — being the person who can see the invisible network —
is how you become the escalation point. And the escalation point is the principal.
One more thing, the thing nobody says: the network is where security and reliability stop
being slogans and become arithmetic. "Is this private?" isn't a vibe — it's "does the name
resolve to a private IP from this VNet, yes or no." "Will the firewall outage take us down?"
isn't a worry — it's "every spoke has a 0.0.0.0/0 UDR to one firewall, so yes, unless it's
zone-redundant with break-glass routes." When you can turn those fears into yes/no questions
with a command that answers them, you're not the networking person anymore. You're the person
the room gets quiet for. That's learnable, and it starts with the four functions in this lab.
Go build the network engine. Then come find me in Phase 07 — that's where the packets you just learned to route start carrying container images, and we make AKS and ACR click.
— your brother 👨🏻
Lab 01 — Network Engine
Phase: 06 — Azure Networking & the Software-Defined Network | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
When a packet is dropped in Azure, the portal will not tell you why. It shows you a green check on the NSG and a green check on the route table and leaves you to figure out that the priority-100 Deny shadowed your priority-300 Allow, or that a /32 UDR is force- tunnelling your traffic into a dead firewall, or that your Private Endpoint's DNS zone was never linked to this VNet so the name still resolves to the public IP. This lab builds the four decision engines that answer "why" — so you can read an effective-rules dump the way you read code, and so the algorithm lives in your fingers instead of in a docs tab you have to go find at 2 a.m.
What you build
subnet_usable_hosts/cidr_contains/split_subnet— the address-plan math: Azure's five reserved IPs per subnet, the /29 minimum, containment, and carving a block into children (the thing you do on a whiteboard before any VNet exists).NsgRule+evaluate_nsg— the stateful, priority-ordered, first-match-wins NSG evaluator: sort by priority ascending, the first matching rule decides, and when nothing matches fall through to Azure's default rules (allow VNet↔VNet, allow AzureLoadBalancer inbound, deny the rest).Route+effective_next_hop— the routing engine: among every route whose prefix contains the destination, pick the longest prefix; break ties UDR > BGP > system. This is how a/32UDR pins one host to a firewall and how0.0.0.0/0force-tunnels the rest.PrivateZone+resolve_fqdn— the Private Endpoint DNS override: a public PaaS FQDN resolves to a private IP for a VNet linked to theprivatelinkzone, and to the public IP for everyone else. The missing link is the #1 Private Link failure, and this function reproduces both the success and the bug.
Key concepts
| Concept | What to understand |
|---|---|
| 5 reserved IPs | every subnet loses network + .1/.2/.3 + broadcast → usable = 2^(32−p) − 5 |
| /29 minimum | a /29 has 8 − 5 = 3 usable; anything smaller is rejected by Azure |
| NSG is stateful | you evaluate the originating direction only; return traffic is implicit |
| Priority = lowest-first | priority 100 beats priority 300; first match wins, so a low-number Deny shadows a high-number Allow |
| Default rules | allow VirtualNetwork↔VirtualNetwork, allow AzureLoadBalancer in, deny all else |
| Longest-prefix match | the route with the most specific (longest) prefix decides — /32 beats /0 |
| Route tie-break | at equal prefix length: UDR > BGP > system |
| Private Endpoint DNS | success = private IP via a linked privatelink zone; broken DNS = public IP = the classic bug |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example: subnet, NSG, routes, DNS
Success criteria
- All 25 tests pass against your implementation.
- You can explain why
test_usable_hosts_29_boundaryasserts 3 (and not 6 or 8): Azure reserves 5 addresses in every subnet, so a /29's 8 addresses yield 3 usable. - You can explain why
test_nsg_priority_lower_number_winsis the whole point of NSG evaluation: a priority-100 Deny is evaluated before the priority-200 Allow, and the first match wins. - You can explain why
test_longest_prefix_32_beats_0chooses the/32route even though a0.0.0.0/0route also matches — and whytest_udr_beats_system_at_equal_prefixneeds the origin tie-break. - You can explain why
test_private_dns_returns_public_ip_when_not_linkedis the bug you will actually debug: the Private Endpoint exists, but the DNS zone is not linked to the caller's VNet, so the name resolves to the public IP and the connection either fails (NSG/firewall blocks public egress) or silently leaves the private network.
How this maps to real Azure
| This lab | Real Azure | Notes / limits |
|---|---|---|
subnet_usable_hosts | subnet sizing during VNet design | Azure reserves 5 IPs/subnet; smallest is /29; the gateway subnet wants /27+ |
evaluate_nsg | NSG security-rule evaluation in the host VFP | real priorities are 100..4096 for user rules, 65000+ for defaults; flows are stateful |
effective_next_hop | the per-NIC effective route table (az network nic show-effective-route-table) | combines system + UDR + BGP; longest-prefix wins; equal-length tie-break is UDR > BGP > system |
resolve_fqdn | Private Endpoint + Private DNS zone + VNet link | the privatelink zone must be linked to the VNet; without it, the public FQDN still resolves to the public IP |
The miniature is faithful to the algorithm; what it does not model: NSG flow logging
and connection tracking internals, Application Security Groups (ASGs) as rule sources,
augmented security rules (multiple prefixes per rule), service-tag expansion to real
Microsoft-published prefix lists, BGP route propagation and AS-path selection, UDR
0.0.0.0/0 interactions with forced tunnelling and the Internet system route's special
cases, and DNS TTL/caching. Those are the README "Extensions" below.
Extensions
- Augmented rules: let
NsgRule.source/destbe a list of prefixes/tags (Azure's augmented security rules) and an Application Security Group reference; match if the packet hits any. - Service-tag expansion: load a
ServiceTags_Public.json-shaped fixture and expandStorage.WestEuropeto its prefix list, so_endpoint_matchesresolves real tags. - BGP path selection: give
Routean AS-path length and add the real ExpressRoute tie-break (more-specific > local-pref > shortest AS-path) after prefix length. - Flow tuple + logging: emit an NSG-flow-log-shaped record (
5-tuple, decision, rule) for each evaluated packet so you can replay a capture through the engine. - Real
az: build the same VNet/NSG/UDR/Private Endpoint withaz network ...or Terraform in your own subscription, dump the effective route table and rules, and diff the live result against this engine's output for the same packets.
Resume / interview bullets
- Built a network-policy decision engine that evaluates NSG rule sets in priority order with stateful first-match semantics, computes effective routes via longest-prefix matching with UDR/BGP/system tie-breaks, and reproduces the Private Endpoint DNS-override failure mode.
- Can evaluate an Azure effective-rules dump by hand: trace why a packet was allowed or dropped, why a route force-tunnels to a firewall, and why a Private Link resource resolves to a public IP.
Phase 07 — Containers, Docker, ACR & AKS Internals
Difficulty: ⭐⭐⭐☆☆ (the digest/manifest model) → ⭐⭐⭐⭐⭐ (the judgment about
immutability, supply-chain trust, and why a pod is Pending)
Estimated Time: 1 week (12–18 hours)
Prerequisites: Phase 00 (control plane vs data plane; the resource-ID shape), Phase 03
(Entra ID — the managed identity token an AKS pod uses to pull from ACR), Phase 04
(RBAC — AcrPull / AcrPush are role assignments). No Docker daemon or live cluster
required for the lab; everything is offline and deterministic.
Why This Phase Exists
The JD names it plainly: "Build, deploy, and manage containerized applications using
Docker and Azure Container Registry (ACR)." Almost every engineer can write a
Dockerfile and run docker push. Almost none can answer the questions that actually
decide a production incident:
- "
:latestwas fine yesterday and broke prod today and nobody deployed — how?" (a tag is a mutable pointer; someone repushed it). - "Two images are 900 MB each but the registry only grew by 200 MB on the second push — why?" (content-addressed layer dedup; the base layer was already stored).
- "We deleted 4 000 old tags and reclaimed nothing — why?" (deleting tags doesn't delete blobs; garbage collection sweeps unreferenced blobs, and yours were still referenced by a tag you missed).
- "The pod is
Pendingand there's plenty of CPU on the dashboard — why won't it schedule?" (the scheduler packs byrequests, not usage, and your node has a taint the pod doesn't tolerate).
A senior operates the tools. A principal holds the mechanism: an image is an
ordered stack of read-only layers + a config, each named by a sha256 digest;
a manifest lists those digests; a tag is a mutable name that points at a manifest
digest. Once you hold that, ACR (a managed OCI registry), AKS (a managed control plane that
bin-packs pods onto node pools), and the serverless container paths (ACA/ACI) stop being
three products and become one content-addressed storage model and one scheduling model.
So this phase makes you build both engines: a content-addressable registry with dedup, missing-blob pulls, garbage collection, and geo-replication; and an AKS-style scheduler that bin-packs pods by requests under taints and tells you, arithmetically, why a pod can't be placed. After you've written the algorithm, "manifest digest" and "Pending pod" stop being portal mysteries and become functions you can run in your head at 2 a.m.
What "Principal-Level" Means Here
A senior pushes images and applies YAML. A principal understands the machinery well enough to:
- Reason in digests, not tags. Explain why a tag is mutable and a digest is immutable,
why production deploys pin
app@sha256:…(notapp:latest), and how a content digest gives you both deduplication and tamper-evidence for free. - Predict registry storage and pull cost. Given two images sharing a base layer, say how many blobs the registry stores (the shared layer once) and how many blobs a client with a warm cache must download (only the missing ones).
- Run garbage collection safely. Explain why deleting tags ≠ reclaiming storage, why GC is mark-and-sweep from the tagged manifests, and why a shared base layer survives until the last image referencing it is gone.
- Secure the supply chain. Pull with a managed identity (
AcrPull), not an admin password; pin by digest; sign manifests (content trust / Notation); scan for CVEs; and use geo-replication so a region failure doesn't stall every pod start. - Debug a
Pendingpod from first principles. Mapkubectl describe podoutput to the scheduler's filter predicates —Insufficient cpu/memoryis requests-vs-allocatable arithmetic;had untolerated taintis a node-pool isolation choice — and fix it without guessing. - Pick the right runtime. Know when AKS (full Kubernetes), Azure Container Apps (serverless Kubernetes-less, scale-to-zero, KEDA), or ACI (a single bare container) is the right tool — and defend it with the operability and cost dials, not taste.
Concepts
- The image model — layers (read-only, content-addressed) + a config; the digest
(
sha256:…) as the universal name; the manifest as the list of digests; the tag as a mutable pointer; the manifest list / image index for multi-arch. - OCI distribution —
pull= resolvetag → manifest → fetch only the MISSING layer blobs; content-addressed deduplication across images; the/v2/API shape ACR speaks. - ACR (Azure Container Registry) — a managed OCI registry; SKUs (Basic/Standard/
Premium); geo-replication; ACR Tasks (build-on-push, base-image-update
triggers); content trust / signing; soft-delete; garbage collection of
untagged manifests and unreferenced blobs; auth via managed identity +
AcrPull. - AKS (Azure Kubernetes Service) — a managed control plane (API server, etcd, scheduler,
controller-manager — Microsoft-run) + customer node pools; the scheduler bin-packs
pods onto nodes by requests (cpu/mem), bounded by limits, respecting taints/
tolerations and node allocatable capacity; QoS classes (Guaranteed/Burstable/
BestEffort) that decide eviction order; why a pod is
Pending. - Requests vs limits — requests are the floor the scheduler guarantees and places by; limits are the ceiling the kubelet enforces at runtime (CPU is throttled, memory is OOM-killed); the gap defines the QoS class.
- The serverless container paths — ACA (Container Apps: scale-to-zero, KEDA-driven, Dapr, no cluster to run) and ACI (a single container, per-second billing) as the non-Kubernetes contrast that frames when AKS is overkill.
Labs
Lab 01 — OCI Registry Engine + AKS Scheduler (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build a content-addressable OCI registry (digests, manifests, mutable tags, dedup, missing-blob pull, mark-and-sweep garbage collection, missing-only geo-replication) and an AKS-style bin-packing scheduler (requests, taints/tolerations, capacity accounting, Pending) |
| Concepts | Content addressing; digest vs tag (immutable vs mutable); manifest closure; pull as set difference; GC reachability from tags; replication of only missing blobs; scheduling by requests under taints; Pending as arithmetic |
| Steps | 1. digest(); 2. Registry.put_blob/put_manifest/tag/resolve/pull with dedup; 3. garbage_collect (mark-and-sweep); 4. replicate (missing blobs only); 5. Node/Pod/schedule first-fit bin-packer |
| How to Test | pytest test_lab.py -v — digest stability + known value, blob dedup, missing-only pull, tag resolve/mutation, GC keeps shared & sweeps exclusive, replication copies only missing, scheduler bin-packs/Pending/taints/determinism |
| Talking Points | "Why is :latest dangerous in prod?" "Why did deleting 4 000 tags reclaim nothing?" "Why is this pod Pending with CPU free on the graph?" "Pull skipped 800 MB — how?" |
| Resume bullet | Built a content-addressable OCI registry with layer deduplication, garbage collection, and geo-replication, plus an AKS bin-packing scheduler that explains Pending pods as requests-vs-capacity arithmetic |
→ Lab folder: lab-01-oci-registry-engine/
Integrated-Scenario Suggestions (carried through the whole track)
These tie Phase 07 to the rest of the platform you are building across the track. Each is a design you should be able to defend with the dials from this phase:
- A golden-image supply chain. ACR Tasks builds on base-image update; images are
signed (content trust) and scanned (Defender/Trivy); deploys pin by digest via a
CI/CD gate (P08); pull uses a managed identity with
AcrPull(P03/P04). The deliverable: no human ever types a registry password, and a CVE in the base image triggers an automatic rebuild of everythingFROMit. - A multi-region, fast cold-start cluster. ACR geo-replication puts blobs in every
region an AKS cluster runs in, so a node in
westeuropepulls locally, not across an ocean; node-pool taints isolate system / spot / GPU workloads; pod requests are tuned so the cluster autoscaler scales the right pool. - Cost-driven runtime selection. A bursty, event-driven worker runs on ACA (scale-to-zero, KEDA on queue depth) instead of an always-on AKS node pool; a one-shot batch job runs on ACI; a stateful, multi-team platform runs on AKS. The deliverable: an ADR that names the operability/cost dial for each.
- A storage-reclamation program. Untagged manifests pile up from CI; an
acr purgeTask plus GC reclaims them on a schedule; soft-delete gives a recovery window. The deliverable: the registry bill stops growing linearly with build count.
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- A container image is content (layers + config) named by digests, listed by a manifest, pointed at by a mutable tag. That one model explains dedup, pulls, GC, and replication — they are all set operations on digests.
- Tags drift; digests don't. Pin production by digest. Treat
:latestas a convenience for humans, never as a deployment contract. - Deleting tags is not reclaiming storage. GC is mark-and-sweep from the tagged manifests; a shared layer survives until the last image using it is gone.
- A
Pendingpod is arithmetic, not magic. The scheduler places by requests under taints against allocatable capacity;Insufficient cpuanduntolerated taintare the two answers, and both are fixable without guessing. - Choose the runtime by the operability/cost dial: AKS for a platform, ACA for serverless/scale-to-zero, ACI for a single short-lived container.
Deliverables Checklist
-
Lab 01 implemented; all tests pass against
solution.pyand against yourlab.py - You can draw the image model (layer → digest → manifest → tag) from memory
-
You can explain why a warm-cache
pulldownloads only the new layers - You can explain why GC after untagging reclaims exclusive blobs but keeps shared ones
-
You can debug a
Pendingpod fromkubectl describeoutput to a root cause - You can pick AKS vs ACA vs ACI for a workload and defend it with the dials
Warmup Guide — Containers, Docker, ACR & AKS Internals
Zero-to-principal primer for Phase 07. We start from "what is a container image, physically" and build up to the exact mechanisms behind
docker push, ACR deduplication, garbage collection, geo-replication, and the AKS scheduler's decision to leave a podPending. By the end you can answer "why did:latestchange under me?", "why did deleting tags reclaim no storage?", and "why won't this pod schedule?" from first principles — and you will have built both a registry and a scheduler.
Table of Contents
- Chapter 1: What a Container Actually Is
- Chapter 2: The Image Model — Layers, Config, and the Digest
- Chapter 3: Manifests and Tags — Immutable Content, Mutable Names
- Chapter 4: OCI Distribution — Push, Pull, and Deduplication
- Chapter 5: Azure Container Registry — The Managed OCI Registry
- Chapter 6: Garbage Collection — Mark and Sweep from the Tags
- Chapter 7: Geo-Replication — Copy Only What's Missing
- Chapter 8: AKS — The Control Plane and the Scheduler
- Chapter 9: Requests, Limits, QoS, and Why a Pod is Pending
- Chapter 10: The Serverless Container Paths — ACA and ACI
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What a Container Actually Is
From zero. A container is not a lightweight VM. A VM virtualizes hardware — it runs a full guest kernel on a hypervisor. A container virtualizes the operating-system view: it is one or more normal Linux processes that the kernel has been told to isolate and limit. Three kernel features do all the work:
- namespaces — isolate what a process can see:
pid(its own process tree),net(its own interfaces),mnt(its own filesystem mounts),uts(hostname),ipc,user. Two containers each think they are PID 1 on their own machine. - cgroups (control groups) — limit and account what a process can use: CPU shares, memory ceiling, I/O. This is the same accounting the AKS scheduler reasons about as requests and limits (Ch. 9).
- a union/overlay filesystem — stack read-only layers with one writable top layer, so many containers share the same underlying image bytes (Ch. 2).
Why it exists. Sharing one kernel makes containers start in milliseconds and pack
hundreds to a host, where VMs start in seconds and pack tens. The cost is weaker isolation
(a shared kernel is a shared attack surface), which is exactly why Azure also offers
Confidential Containers and why multi-tenant clusters lean on user namespaces and
policy.
The principal's framing. A container is a process with a private view and a resource budget, started from an immutable filesystem image. Everything in this phase is about that image (how it's stored and shipped — Ch. 2–7) and that resource budget (how it's scheduled — Ch. 8–9).
Chapter 2: The Image Model — Layers, Config, and the Digest
What it is. A container image is two things:
- an ordered stack of read-only layers, each layer a tarball of filesystem changes
(files added/modified/deleted) contributed by one
Dockerfileinstruction; - a config — a small JSON document with the entrypoint, env, working dir, exposed
ports, and the ordered list of layer digests (the
rootfs.diff_idsandhistory).
Dockerfile image = layers (bottom→top) + config
───────────── ────────────────────────────────────
FROM debian:12 ─────────▶ [ layer0: debian rootfs ] ← shared base
RUN apt-get install ─────────▶ [ layer1: apt packages ]
COPY ./app /app ─────────▶ [ layer2: your app ] ← changes every build
CMD ["/app/run"] ─────────▶ config: { Cmd, Env, diff_ids… }
The key idea — content addressing. Every layer and the config are named by the
sha256 hash of their bytes: a digest, written sha256: followed by 64 hex chars.
$$\text{digest}(b) = \texttt{"sha256:"} ,\Vert, \mathrm{hex}!\left(\mathrm{SHA256}(b)\right)$$
This single decision buys three properties for free:
- Deduplication. Identical bytes have identical digests, so the registry (and your local
Docker cache) stores them once. Two images built
FROM debian:12share that base layer's bytes — one copy on disk, one upload over the wire (Ch. 4). - Tamper-evidence. Change one byte of a layer and its digest changes completely; a manifest that pins layer digests therefore can't be silently altered (Ch. 3, content trust).
- Cache validity. A build step's cache key is the digest of its inputs, which is why
changing
COPY ./appbusts onlylayer2and reuseslayer0/layer1.
In the lab, this is the very first function:
def digest(data: bytes) -> str:
return "sha256:" + hashlib.sha256(data).hexdigest()
Common misconception. "A bigger image is slower to pull." Not necessarily — pull time
is dominated by the layers you don't already have. A 2 GB image whose base you cached
pulls almost instantly; a 200 MB image with an all-new bottom layer does not. Layer
boundaries, not total size, decide pull cost. This is why you put rarely-changing things
(OS, dependencies) at the bottom of the Dockerfile and your fast-changing app at the top.
Chapter 3: Manifests and Tags — Immutable Content, Mutable Names
The manifest. You can't address an image by hashing all its layers concatenated —
that would re-hash gigabytes. Instead a manifest is a small JSON document that lists
the config descriptor and the layer descriptors (each a {mediaType, digest, size}):
{
"schemaVersion": 2,
"mediaType": "application/vnd.oci.image.manifest.v1+json",
"config": { "digest": "sha256:aaa…", "size": 1472 },
"layers": [
{ "digest": "sha256:bbb…", "size": 30412763 }, // base layer
{ "digest": "sha256:ccc…", "size": 12044 } // your app layer
]
}
The manifest is itself content-addressed: its digest is the sha256 of its bytes. So an
image is identified by its manifest digest — a single 32-byte hash that transitively
commits to every layer and the config. That is what app@sha256:… refers to. In the lab:
def _manifest_digest(manifest) -> str:
canonical = "\n".join([manifest.config, *manifest.layers]).encode()
return "sha256:" + hashlib.sha256(canonical).hexdigest()
Multi-arch — the manifest list / image index. What does docker pull python:3.12
return on an ARM Mac vs an x86 server? A manifest list (OCI: image index): a manifest
whose entries are other manifests, one per os/arch. The client picks the one matching
its platform. (The lab models single-arch manifests; the extension adds the index.)
Tags — the mutable layer. A tag like :1.0 or :latest is not part of the
content; it is a mutable pointer: a name in the registry that maps to a manifest digest.
tag (mutable name) manifest digest (immutable content)
───────────────── ────────────────────────────────────
myrepo/app:latest ───────▶ sha256:1d4f… (today)
myrepo/app:latest ───────▶ sha256:9ab2… (after someone re-pushes :latest)
myrepo/app:1.4.2 ───────▶ sha256:1d4f… (by convention, never re-pushed)
This is the single most important operational fact in the chapter. A tag can be
repointed. :latest "changing under you" with nobody deploying means somebody re-pushed
the tag to a new digest. Therefore:
Pin production by digest, not tag.
app@sha256:1d4f…is a contract;app:latestis a suggestion. In the lab,resolve()accepts either, andtag()lets you repoint a name at a new digest — exactly the foot-gun, made visible.
Common misconception. "Immutable tags make a tag immutable." ACR's immutable tags feature (and policy) blocks re-pushing a tag — it doesn't change the data model; it just forbids the mutation. The underlying truth (tag = pointer, digest = content) is unchanged.
Chapter 4: OCI Distribution — Push, Pull, and Deduplication
A registry speaks the OCI distribution spec — a small HTTP API rooted at /v2/. The
two operations that matter:
Push (docker push myrepo/app:1.0):
- For each layer + the config, the client computes the digest and asks the registry "do
you already have
sha256:…?" (aHEADto/v2/myrepo/app/blobs/<digest>). - It uploads only the blobs the registry is missing. (Shared base already there → skipped. This is server-side dedup.)
- It uploads the small manifest and associates the tag.
Pull (docker pull myrepo/app:1.0) — the algorithm the lab's pull() implements:
1. resolve tag ──▶ manifest digest (GET /v2/<repo>/manifests/<tag>)
2. fetch manifest by digest (the small JSON listing blobs)
3. for each blob digest in the manifest:
if NOT in local cache: download it (GET /v2/<repo>/blobs/<digest>)
else: "Already exists" ← layer reuse
4. unpack the layers in order, apply the config
The lab makes this a one-liner — a set difference:
def pull(self, ref, *, client_has: set[str]) -> list[str]:
manifest = self._manifests[self.resolve(ref)]
return [b for b in manifest.blobs() if b not in client_has] # only the missing
client_has = { base } manifest.blobs() = [ config, base, app ]
download = blobs − client_has = [ config, app ] ← base skipped
Why this is the whole game. Deduplication is cross-image and cross-pull. The base
layer you pulled for service-a is reused when you pull service-b built on the same base
— the client downloads only service-b's config and top layer. At fleet scale this is the
difference between every node cold-pulling 900 MB and every node pulling 12 MB on top of a
shared cached base. The cache is keyed by digest, so reuse is automatic and safe.
Common misconception. "Pull deduplication is a Docker client trick." No — it is a
property of content addressing. Any OCI-conformant registry and client get it, because
the blob's identity is its hash. The lab proves it with blob_count(): two 2-layer
images that share a base store 5 unique blobs, not 6.
Chapter 5: Azure Container Registry — The Managed OCI Registry
What it is. ACR is a managed, private OCI registry — the same /v2/ data plane as
Chapter 4, run by Azure, fronted by Azure Resource Manager for the control plane (create the
registry, set SKU, configure replication) and by Entra/RBAC for auth. SKUs:
| SKU | Storage | Throughput | Geo-replication | Notable |
|---|---|---|---|---|
| Basic | low | low | no | dev/test |
| Standard | more | more | no | most prod |
| Premium | most | highest | yes | geo-replication, content trust, private link, CMK |
Authentication — the principal-level point. Never use the admin user / password in
production. A pod on AKS should pull with a managed identity (Phase 03) granted the
AcrPull role (Phase 04) — a token, scoped, rotated, revocable, no secret in a
manifest. AcrPush for CI. This is the control-plane/data-plane split again: RBAC
(AcrPull) is the control-plane grant; the actual blob GET is the data plane.
ACR Tasks — registry-side compute:
az acr build— build the image in the registry (no local Docker), push on success.- Triggered tasks — rebuild on a git commit, on a base-image update (a CVE patch to
debian:12rebuilds everythingFROMit), or on a schedule. This is the supply-chain spine: a base CVE fix propagates without a human.
Content trust / signing — Docker Content Trust (Notary v1) and the newer Notation / sigstore sign a manifest digest; a consumer verifies the signature before running. Because the digest commits to all content, a valid signature means "exactly these bytes, from this signer." (Lab extension models this with an HMAC over the digest.)
Soft-delete + GC — deleted manifests can be recovered within a retention window (soft-delete); storage is reclaimed by garbage collection of untagged manifests and unreferenced blobs (Ch. 6).
Chapter 6: Garbage Collection — Mark and Sweep from the Tags
The problem. CI pushes hundreds of builds a day. Most get a tag like build-4821 that
nobody references a week later. Storage grows forever unless something reclaims it. But
you cannot just "delete old blobs" — a blob might be a base layer that ten current
images still need. You need to delete exactly the blobs no live image references.
The algorithm — mark-and-sweep, rooted at the tags (what the lab's garbage_collect
implements, and what ACR's untagged-manifest GC does):
MARK a manifest is LIVE ⇔ some tag points at it.
every blob in a live manifest's closure (config + layers) is REACHABLE.
SWEEP delete every stored blob that is NOT reachable.
drop every manifest that NO tag keeps alive (untagged manifests).
reachable = set()
for manifest_digest in registry._tags.values(): # roots = the tags
reachable.update(registry._manifests[manifest_digest].blobs())
swept = {d for d in registry._blobs if d not in reachable} # the garbage
The crucial property interviewers probe. A shared layer survives as long as any
tagged image references it. Untagging one image reclaims only that image's exclusive
blobs (its config + unique top layer); the shared base stays because a sibling tag still
reaches it. The base is reclaimed only after the last referencing tag is gone. The lab
proves both halves (test_gc_shared_layer_survives_until_last_tag_gone).
tags: service-a → m_a, service-b → m_b (m_a, m_b share `base`)
untag service-a → GC reclaims top_a, cfg_a (a's exclusive blobs)
base SURVIVES (service-b still uses it)
untag service-b → GC now reclaims base too (last reference gone)
Why "we deleted 4 000 tags and reclaimed nothing" happens. Deleting a tag makes a manifest a candidate, but GC only runs as a separate sweep — and if even one tag (or a manifest-list entry, or a still-pulling client) still references the blobs, they stay. Reclaiming storage = untag and run GC, and the bytes freed = the exclusive closure of the untagged images, not the headline tag count.
Chapter 7: Geo-Replication — Copy Only What's Missing
Why. An AKS cluster in westeurope pulling images from an ACR in eastus pays
cross-region latency on every cold node start — and a region outage at the registry stalls
pod starts everywhere. Geo-replication (Premium ACR) keeps a replica of the registry's
content in each chosen region; a pull resolves to the nearest replica.
The mechanism — same content addressing, applied across regions. Replicating a
repository copies the small manifests always, but the large blobs are content-addressed, so
a region only stores each blob once and a replication into a warm region copies only
the blobs that region is missing. The lab's replicate is exactly this:
for d in manifest.blobs():
if not dst.has_blob(d): # copy ONLY what the destination lacks
dst._blobs[d] = src._blobs[d]
copied.append(d)
dst._manifests[manifest_digest] = manifest # the small manifest always
dst._tags[name] = manifest_digest # replicate the pointer
replicate service-a → cold west region: copies base + cfg_a + top_a + manifest
replicate service-b → base already in west → copies only cfg_b + top_b + manifest
So replicating a second image that shares a base with an already-replicated one is nearly free — the dedup that saved you on push (Ch. 4) saves you again on cross-region copy. This is why geo-replication's storage and egress cost scales with unique content, not with image count.
Chapter 8: AKS — The Control Plane and the Scheduler
What AKS is. Azure Kubernetes Service is a managed Kubernetes: Microsoft runs the
control plane (the API server, etcd, the scheduler, the controller-manager) and
you run node pools (VM scale sets of worker nodes). You pay for the nodes (and optionally
an SLA on the control plane); you never SSH into the API server.
┌──────────────── Microsoft-managed control plane ───────────────┐
kubectl ──▶│ API server ──▶ etcd (desired state) │
│ │ │
│ ▼ │
│ SCHEDULER ── decides which NODE each unscheduled Pod lands on │
│ controller-manager (Deployments→ReplicaSets→Pods, autoscaler) │
└───────────────────────────────┬────────────────────────────────┘
│ binding
┌───────────── customer node pools (VMSS) ─────────────┐
│ node aks-sys-0 [ kubelet | container runtime ] │
│ node aks-gpu-0 [ kubelet | container runtime ] taint=gpu
└──────────────────────────────────────────────────────┘
The scheduler is the component this lab models. Its job: for each pod with no node yet, pick a node. It runs in two phases:
- Filter (predicates) — eliminate nodes that can't run the pod:
PodFitsResources— node's remaining allocatable cpu and memory ≥ the pod's requests;TaintToleration— every taint on the node is tolerated by the pod;- (also
NodeAffinity,nodeSelector, volume limits, topology — beyond the lab).
- Score — among the survivors, rank (least-allocated to spread, or most-allocated to bin-pack tightly) and pick the best; then bind the pod to that node.
The lab implements the filter as a deterministic first-fit bin-pack:
for pod in sorted(pods, key=lambda p: p.name): # deterministic order
for node in nodes: # first-fit, list order
if not pod.tolerates(node): continue # TaintToleration
if pod.cpu_req <= rem.cpu and pod.mem_req <= rem.mem: # PodFitsResources
rem.cpu -= pod.cpu_req; rem.mem -= pod.mem_req # consume capacity
place pod on node; break
else: pod is Pending # fit no node → unschedulable
Taints and tolerations — the node-pool isolation mechanism. A taint on a node
(key=value:NoSchedule) repels pods unless the pod carries a matching toleration.
This is how you keep general workloads off your GPU pool, your spot pool, or your system
pool: taint the special nodes, and only the pods that opt in land there. In the lab a node
with taint gpu accepts only pods whose tolerations include gpu; a node with two taints
needs all of them tolerated.
Chapter 9: Requests, Limits, QoS, and Why a Pod is Pending
This is the chapter that ends the most support tickets. Two numbers per container, and they do different jobs:
| request | limit | |
|---|---|---|
| Used by | the scheduler (placement) | the kubelet (runtime enforcement) |
| Means | "guarantee me at least this" | "never let me exceed this" |
| CPU over it | (CPU can't be "over" request) | throttled (CFS quota) |
| Memory over it | — | OOM-killed |
| If unset | scheduler assumes 0 → overcommit risk | container can starve neighbours |
The scheduler places by requests, never by actual usage. This is the source of the
classic confusion: "the node's CPU graph shows 20% used, why is my pod Pending?" Because
the requests already committed on that node sum to its allocatable capacity — the pods
reserved the CPU even though they aren't using it right now. The scheduler honours the
reservation. The fix is right-sizing requests, not staring at the usage graph.
allocatable vs capacity. A node's raw capacity is not schedulable in full: the
kubelet reserves kube-reserved + system-reserved + an eviction threshold, leaving
allocatable — the number the scheduler actually packs against. (The lab takes
allocatable as given via Node.cpu/Node.mem.)
QoS classes (decided by the request/limit relationship — they set eviction order under node memory pressure):
- Guaranteed — requests == limits for every resource → evicted last.
- Burstable — requests < limits → evicted in the middle.
- BestEffort — no requests/limits → evicted first.
Why a pod is Pending — the complete answer. It maps 1:1 to the filter predicates, and
kubectl describe pod tells you which one:
0/3 nodes are available:
1 node(s) had untolerated taint {gpu: } ← TaintToleration (Ch. 8)
2 Insufficient cpu. ← PodFitsResources (requests > allocatable)
Every Pending is one of: not enough requestable cpu/mem left on any node; a taint
no node-matching pod tolerates; an unsatisfiable affinity/selector/topology; or no node
of the needed shape exists yet (→ the cluster autoscaler adds one, if the pool can grow).
The lab models the first two — the overwhelming majority of real cases — and proves them
deterministically.
Chapter 10: The Serverless Container Paths — ACA and ACI
AKS is powerful and operationally heavy — you own node pools, upgrades, autoscaling, the whole Kubernetes surface. For many workloads that is overkill. Azure offers two non-Kubernetes container paths, and a principal picks deliberately:
| AKS | Azure Container Apps (ACA) | Azure Container Instances (ACI) | |
|---|---|---|---|
| Model | full Kubernetes | serverless containers (Kubernetes hidden) | a single container group |
| Scaling | HPA + cluster autoscaler | scale-to-zero, KEDA event-driven | none (one shot) |
| You operate | nodes, upgrades, k8s | almost nothing | nothing |
| Best for | platforms, complex topologies, full k8s API | microservices, event/queue workers, HTTP apps | batch jobs, CI agents, quick tasks |
| Billing | per node-hour | per-request / per-active-second, $0 idle | per-second while running |
The dial (Phase 00 thinking). The axis is control/flexibility ↔ operability/cost. Choose AKS when you genuinely need the Kubernetes API, custom controllers, DaemonSets, or multi-team platform isolation. Choose ACA when you want containers + autoscaling + scale-to-zero without running a cluster — a bursty queue worker that's idle at night costs nothing. Choose ACI for a single short-lived container (a nightly batch, a build agent). The wrong default — "everything on AKS" — is how teams end up paying for, and being paged by, a cluster a Container App would have run for free at 3 a.m.
Lab Walkthrough Guidance
Lab 01 — OCI Registry Engine + AKS Scheduler, suggested order:
digest(data)(Ch. 2) —"sha256:" + hexdigest. Verify against the knownsha256("")constant; confirm one flipped bit changes it.Registry.put_blob/Manifest.blobs(Ch. 2–3) — store-by-digest with dedup (identical bytes → one blob). Theblob_count()is your dedup proof.put_manifest/_manifest_digest(Ch. 3) — content-address the manifest; re-pushing the same image must return the same digest and store nothing new.tag/untag/resolve(Ch. 3) — the mutable pointer;resolveaccepts a tag or a digest; re-tagging moves the pointer (the:latestfoot-gun).pull(ref, client_has=…)(Ch. 4) — the set difference: manifest's blobs minus the client's cache, config-first order. Cold cache downloads all; warm skips the base.garbage_collect(Ch. 6) — mark from the tags, sweep the rest. Prove the shared base survives an untag and is reclaimed only after the last tag is gone.replicate(Ch. 7) — copy the manifest + only the destination's missing blobs; the second shared-base image copies almost nothing.Node/Pod/schedule(Ch. 8–9) — first-fit bin-pack by requests under taints, decrementing capacity; a pod that fits nowhere isNone(Pending). Test determinism (sort by pod name → input order doesn't matter).
Success Criteria
You are ready for Phase 08 (CI/CD & secure supply chain) when you can, from memory:
- Draw the image model — layer → digest → manifest → tag — and explain what each is.
- Explain content addressing and the three properties it buys (dedup, tamper-evidence, cache validity).
- State why production pins a digest, not
:latest, and what "the tag changed under me" actually means. - Trace a
pullas a set difference and say why a warm-cache pull downloads only new layers. - Run garbage collection in your head: why deleting tags ≠ reclaiming storage, and why a shared layer survives until the last referencing tag is gone.
- Explain geo-replication as content-addressed copy-only-missing, and why replicating a shared-base image is nearly free.
- Describe the scheduler's filter step (
PodFitsResources+TaintToleration) and explain aPendingpod fromkubectl describeoutput. - Distinguish requests (scheduler/placement) from limits (kubelet/enforcement) and name the three QoS classes and their eviction order.
- Pick AKS vs ACA vs ACI for a workload and defend it with the operability/cost dial.
Interview Q&A
Q: What is a Docker image, precisely?
An ordered stack of read-only filesystem layers plus a config JSON, where every
layer and the config are named by the sha256 digest of their bytes. A manifest
lists those digests and is itself content-addressed, so an image is identified by a single
manifest digest. A tag is just a mutable name pointing at a manifest digest. Content
addressing is the whole point: it gives deduplication, tamper-evidence, and cache validity
for free.
Q: :latest worked yesterday and broke prod today and nobody deployed. How?
A tag is a mutable pointer, not content. Someone re-pushed :latest to a new manifest
digest — the name now resolves to different bytes. Your running pods kept the old digest;
the next pull (a new node, a restart) got the new one. The fix is to pin by digest
(app@sha256:…) in production manifests, and to enable immutable tags in ACR so the tag
can't be repushed. :latest is a convenience for humans, never a deployment contract.
Q: Two 900 MB images, but the second push only added 200 MB of storage. Why?
Content-addressed deduplication. The two images share lower layers (same base, same
dependency layers); those blobs already existed in the registry, so the push uploaded only
the blobs whose digests were missing — the differing top layers. The registry stores each
unique blob once regardless of how many images or tags reference it. Same reason a warm-cache
pull downloads only the new layers.
Q: We deleted 4 000 old tags and reclaimed no storage. Why? Deleting a tag doesn't delete blobs — it makes a manifest an untagged candidate. Storage is reclaimed by a separate garbage-collection sweep, and GC only deletes blobs that no live (tagged) manifest reaches. If your "old" images shared layers with current images, those layers stay. Reclaiming storage = untag the manifests and run GC, and the bytes you free are the exclusive closure of the untagged images, not a function of the tag count.
Q: A pod is Pending but the node CPU graph shows 20% utilization. Why won't it schedule?
The scheduler places by requests, not by live usage. The pods already on that node
reserved CPU via their requests; the sum of committed requests equals the node's
allocatable CPU even though actual usage is low. The new pod's request doesn't fit the
remaining requestable capacity, so it's Pending. kubectl describe pod will say
Insufficient cpu. The fix is right-sizing requests (or adding nodes), not looking at the
usage graph — and if it instead says had untolerated taint, the node pool is reserved
(GPU/spot/system) and the pod lacks the toleration.
Q: Requests vs limits — what's the difference, and what's a QoS class?
A request is the floor the scheduler guarantees and places by; a limit is the
ceiling the kubelet enforces at runtime — CPU over limit is throttled, memory over
limit is OOM-killed. The relationship sets the QoS class: requests == limits →
Guaranteed (evicted last under memory pressure); requests < limits → Burstable;
none set → BestEffort (evicted first). Guaranteed for latency-critical workloads,
BestEffort only for truly disposable ones.
Q: AKS, ACA, or ACI — how do you choose? By the operability/cost dial. AKS when I need the full Kubernetes API — custom controllers, DaemonSets, complex topology, multi-team platform isolation — and accept owning node pools and upgrades. ACA (Container Apps) for HTTP services and event/queue workers that want autoscaling and scale-to-zero without me running a cluster — idle costs nothing. ACI for a single short-lived container, like a nightly batch or a CI agent. The anti-pattern is defaulting everything to AKS and paying (in money and pages) for a cluster a Container App would run for free overnight.
Q: How should a pod authenticate to ACR to pull its image?
With a managed identity (Entra, Phase 03) granted the AcrPull RBAC role (Phase 04)
— a scoped, rotating, revocable token, no secret in a manifest. Never the registry admin
user/password in production. This is the control-plane/data-plane split: AcrPull is the
control-plane grant; the blob GET is the data plane. Pair it with digest-pinned
images, content trust/signing, and CVE scanning for a defensible supply chain.
References
- OCI Image Specification — image manifest, config, layers, descriptors, digests: https://github.com/opencontainers/image-spec
- OCI Distribution Specification — the
/v2/push/pull API and content addressing: https://github.com/opencontainers/distribution-spec - OCI Image Index (manifest list) — multi-arch images: https://github.com/opencontainers/image-spec/blob/main/image-index.md
- Azure Container Registry docs — concepts, geo-replication, ACR Tasks, content trust, soft-delete, GC: https://learn.microsoft.com/azure/container-registry/
- ACR authentication with managed identity /
AcrPull: https://learn.microsoft.com/azure/container-registry/container-registry-authentication - Azure Kubernetes Service docs — control plane, node pools, taints, autoscaling: https://learn.microsoft.com/azure/aks/
- Kubernetes Scheduler — filter/score, predicates, taints/tolerations: https://kubernetes.io/docs/concepts/scheduling-eviction/kube-scheduler/
- Kubernetes — Managing Resources (requests/limits) & QoS classes: https://kubernetes.io/docs/concepts/configuration/manage-resources-containers/ and https://kubernetes.io/docs/concepts/workloads/pods/pod-qos/
- Azure Container Apps and Azure Container Instances docs (the serverless paths): https://learn.microsoft.com/azure/container-apps/ and https://learn.microsoft.com/azure/container-instances/
- The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 07: Containers, Docker, ACR & AKS
Read this if: you can write a
Dockerfileandkubectl applybut "why did:latestchange?", "why did deleting tags free no space?", and "why is my podPending?" still send you to the portal. This is the compressed tour the WARMUP derives slowly. Skim it, read the WARMUP, then build the registry + scheduler.
0. The 30-second mental model
An image is content named by a hash: an ordered stack of read-only layers + a
config, each a sha256 digest; a manifest lists those digests; a tag is a
mutable pointer to a manifest digest. From that one model, everything falls out:
push = upload only the blobs the registry is MISSING (dedup)
pull = download manifest's blobs MINUS your local cache (layer reuse)
GC = mark from the TAGS, sweep unreferenced blobs (shared layers survive)
replicate = copy the manifest + only the destination's MISSING blobs
And AKS is one more model: the scheduler bin-packs pods onto nodes by requests,
under taints, against allocatable capacity. One sentence to tattoo: tags are
mutable, digests are immutable — pin prod by digest; and a Pending pod is
requests-vs-capacity arithmetic, not a mystery.
1. The image model, one breath
Dockerfile → layers (read-only, content-addressed) + config
→ manifest (lists config + layer digests, itself a digest)
→ tag (mutable name → manifest digest) e.g. app:1.0 → sha256:1d4f…
Bottom layers = rarely change (OS, deps) → cached & shared. Top layer = your app → changes every build. Layer boundaries, not total size, decide pull cost.
2. The numbers and rules to tattoo on your arm
| Thing | Value / rule |
|---|---|
| Digest | sha256: + 64 hex chars (32 bytes) |
| Tag | mutable pointer → digest; pin prod by @sha256:… |
| Dedup | identical bytes → one blob, registry-wide |
| Pull download | manifest.blobs() − client_cache (only missing) |
| GC roots | the tags (mark-and-sweep); untag ≠ reclaim until GC runs |
| ACR geo-replication | Premium SKU only |
| Auth (prod) | managed identity + AcrPull, never admin password |
| Scheduler places by | requests (cpu millicores, mem MiB), NOT usage |
| Limit enforced by | the kubelet: CPU throttled, memory OOM-killed |
1000m CPU | = 1 vCPU; requests are in millicores |
| QoS order (evicted first→last) | BestEffort → Burstable → Guaranteed |
Pending causes | Insufficient cpu/memory or untolerated taint |
| MG/scope, RBAC | AcrPull/AcrPush are role assignments (P04) |
3. The one-liners you'll actually type
Docker / OCI
docker pull app@sha256:1d4f… # pin by digest (prod), not :latest
docker buildx build --platform linux/amd64,linux/arm64 -t app:1.0 --push . # multi-arch
docker inspect --format '{{.RepoDigests}}' app:1.0 # the digest a tag resolves to
crane digest myacr.azurecr.io/app:1.0 # resolve tag → digest without pulling
Azure Container Registry
az acr build -r myacr -t app:1.0 . # build IN the registry, no local Docker
az acr login -n myacr # token via your Entra identity
az acr repository show-tags -n myacr --repository app # list tags
az acr manifest list-metadata -n myacr -r app # see untagged manifests (GC fodder)
az acr run --cmd "acr purge --filter 'app:.*' --ago 30d --untagged" /dev/null # purge task
az acr replication create -r myacr -l westeurope # geo-replicate (Premium)
az role assignment create --assignee <mi> --role AcrPull --scope <acr-id> # pull rights
AKS / kubectl
kubectl describe pod <p> | sed -n '/Events/,$p' # the WHY of Pending lives here
kubectl get pod <p> -o jsonpath='{.spec.containers[*].resources}' # requests/limits
kubectl describe node <n> | grep -A6 'Allocated resources' # what's reserved
kubectl get nodes -o custom-columns=NAME:.metadata.name,TAINTS:.spec.taints
kubectl top node # USAGE (not what scheduler packs by!)
az aks nodepool add -g rg --cluster-name c -n gpu --node-taints gpu=true:NoSchedule
4. Budget the registry, spend the cache
Two 900 MB images that share a 700 MB base store 1.1 GB, not 1.8 GB — the base lands
once. A node that already cached the base pulls only the 200 MB that differs. So: put
slow-changing layers at the bottom of your Dockerfile, keep base images shared across your
fleet, and geo-replicate so the 200 MB delta is a local pull, not a transatlantic one.
Juniors optimise image size; principals optimise layer reuse and cache locality.
5. War story shapes you'll relive
- "
:latestbroke prod and nobody deployed." → someone re-pushed the tag to a new digest. The fix is pinning@sha256:…and immutable tags — not "be more careful." - "We deleted thousands of tags, the bill didn't move." → deleting tags ≠ deleting blobs; GC only sweeps what no tagged manifest references, and your old images shared layers with current ones. Run GC after untagging, and measure the exclusive closure.
- "Pod's
Pendingbut the dashboard shows the node 20% busy." → the scheduler packs by requests, not usage; the node's CPU is already reserved.describe podsaysInsufficient cpu. Right-size requests; don't stare atkubectl top. - "GPU pod won't schedule even though the GPU node is empty." → the node is tainted
(
gpu=true:NoSchedule) and the pod lacks the toleration. Add the toleration (and anodeSelector), don't remove the taint — the taint is doing its job. - "Cold starts in Europe take forever." → images pulled cross-region from a US registry. Geo-replicate to a Premium ACR replica in the cluster's region.
- "It pulled exactly-once, we're fine." → you re-earn correctness at every boundary; same energy as exactly-once in messaging — pin the digest or you didn't pin anything.
6. Vocabulary that signals you've held the pager
- Digest —
sha256:…; the immutable, content-derived name of a blob or manifest. - Tag — a mutable pointer to a manifest digest (the thing that "changes under you").
- Manifest / image index — the list of blob digests / the multi-arch list of manifests.
- Blob — a content-addressed object (a layer or a config); stored once, referenced many.
- Dedup / layer reuse — the same bytes stored/transferred once across images and pulls.
- Garbage collection — mark-and-sweep from the tags; reclaims unreferenced blobs.
- Geo-replication — Premium ACR copying content to regional replicas (missing-only).
- Requests vs limits — placement floor (scheduler) vs runtime ceiling (kubelet).
- Taint / toleration — node repels pods / pod opts in; the node-pool isolation knob.
- Allocatable —
capacityminus kube/system reserved; what the scheduler packs against. - QoS class — Guaranteed / Burstable / BestEffort; sets memory-pressure eviction order.
Pending/ Unschedulable — no node passed the filter (capacity or taint).- Managed identity +
AcrPull— how a pod pulls without a secret.
7. Beginner mistakes that mark you in interviews
- Saying "an image is a binary/VM" — it's content-addressed layers + a config + a manifest.
- Treating
:latestas a deployment contract instead of a mutable pointer. Pin the digest. - Thinking deleting tags reclaims storage. Untag and GC; measure the exclusive closure.
- Believing pull cost ∝ image size. It's ∝ the layers you don't have cached.
- Reading
kubectl topto explain aPendingpod. The scheduler packs by requests. - Confusing requests and limits — or setting limits without requests (BestEffort surprise).
- Removing a taint to schedule a pod, instead of adding the toleration the taint demands.
- Pulling with the ACR admin password in prod instead of a managed identity +
AcrPull. - Defaulting every workload to AKS when ACA (scale-to-zero) or ACI (one-shot) would do.
8. How this phase pays off later
- Digests + signing + scanning are the spine of P08 (CI/CD & secure supply chain) — the deploy gate pins a digest and verifies a signature.
- Managed identity +
AcrPullis P03/P04 (Entra + RBAC) made concrete for a pod. - Requests/limits/taints are the substrate for any autoscaling, cost, and reliability story (P14) — and the most common real incident you'll debug on a cluster.
- The registry engine and scheduler you build here are the mechanical models you'll run in your head during a 2 a.m. "why won't it pull / why won't it schedule" page.
Now read the WARMUP slowly, then build the registry + scheduler. You'll reach for its
pull-as-set-difference and Pending-as-arithmetic models every time a cluster misbehaves.
👨🏻 Brother Talk — Phase 07, Off the Record
No slides, no certification blueprint. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. Containers are the part of the stack everyone thinks they understand because
they've run docker build and kubectl apply a hundred times. And that's exactly why this
phase matters: the gap between "I can run the commands" and "I understand the mechanism" is
invisible until a senior architect asks you one question and watches you reach for the
portal instead of the model. Let me save you that moment.
Here's the secret the whole phase turns on: an image is content named by a hash, and a
tag is just a sticky note pointing at it. Once that clicks, you stop being surprised. You
stop being surprised that two big images barely grow the registry (same layers, stored
once). You stop being surprised that deleting a thousand tags frees nothing (you deleted the
sticky notes, not the boxes). You stop being surprised that :latest "changed by itself"
(someone moved the sticky note). The engineers who panic at these are the ones who memorised
commands. The ones who shrug and say "yeah, content addressing, that's expected" are the
ones who get the staff offer. The difference is one mental model, and you're building it
this week.
So let me give you the mindset shifts, brother to brother.
Stop deploying tags. Deploy digests. I mean it. app:latest in a production manifest is
a loaded gun pointed at a future incident, because a tag is mutable and someone will
re-push it — a teammate, a CI job, a "quick fix" at 4 p.m. on a Friday. When you pin
app@sha256:1d4f…, you've turned "what's running" from a question into a fact. The first
time you debug an outage and can say "the running digest is X, the tag now points at Y,
here's exactly when it diverged" — while everyone else is guessing — you'll feel the room
shift. Digests are how you make "what's deployed" legible. Legibility is seniority.
Garbage collection is where careful people get burned, so respect it. The instinct
"delete old stuff to save money" is right; the execution is where it goes wrong. Deleting
tags doesn't reclaim bytes — GC does, and GC only sweeps what no live image references.
The flip side is the scarier one: run an over-eager purge without understanding reachability
and you can delete a base layer ten current images depend on, and now nothing pulls. The
principal move is to know the closure: which blobs are exclusive to the thing you're
deleting, and which are shared. The lab makes you compute that closure by hand. Do it until
it's boring, because the day you run a real acr purge you want it to be boring.
Fall in love with the boring force again: a Pending pod is not a mystery. This is the
single most common thing you'll debug on a cluster, and ninety percent of engineers treat it
like weather — they stare at the dashboard, restart things, scale up randomly. Don't be
them. A Pending pod is arithmetic: the scheduler packs by requests against
allocatable capacity, under taints. kubectl describe pod, read the Events, and it
literally tells you: Insufficient cpu (the requests don't fit) or untolerated taint (the
node pool is reserved). That's it. Those are the two answers. When the room is panicking
about "why won't it schedule," and you calmly read the describe output and say "the
committed requests on every node already sum to allocatable — we're not out of CPU, we're
out of requestable CPU; right-size the requests or add a node" — you're not smarter than
them. You just read the machine instead of the dashboard. That's the whole flex.
Learn the requests-vs-limits distinction cold, because almost nobody has. Requests are what the scheduler uses to place; limits are what the kubelet uses to enforce. CPU over the limit gets throttled; memory over the limit gets OOM-killed — silently, brutally, at 3 a.m. The relationship between them sets your QoS class, which decides who gets evicted first when a node runs out of memory. If you set limits but not requests, you've quietly opted into BestEffort and you'll be the first one killed. Most "random restarts" and "the pod just disappeared" tickets are an OOM-kill or an eviction nobody understood. Understand it, and you fix in five minutes what costs other teams a week.
Don't put everything on Kubernetes because it's the cool one. This is a career-shaping judgment call, not a tech preference. AKS is a platform — you own node pools, upgrades, the whole sprawling Kubernetes surface, and the on-call that comes with it. For a bursty queue worker that's idle half the day, Azure Container Apps with scale-to-zero costs nothing overnight and you run no cluster. For a nightly batch, ACI spins up one container and bills by the second. The engineer who reflexively reaches for AKS for everything is the one whose team is perpetually busy operating infrastructure that a managed service would have run for free. Naming the operability/cost dial out loud — "this doesn't need a cluster, it needs scale-to-zero" — is exactly the principal-level judgment the interviewer is listening for.
The honest truth about this phase's difficulty: it's not deep, it's deceptively
simple. The whole thing is a hash function and some set operations and a bin-packing loop.
There's no scary math. The difficulty is that the simplicity hides behind a mountain of
tooling — Docker, BuildKit, OCI, ACR, AKS, kubelet, scheduler, KEDA — and most people learn
the tools and never extract the model. You're doing it backwards on purpose: build the
30-line registry and the 15-line scheduler, watch dedup and Pending emerge from nothing,
and then every product is just "oh, this is that model with a billing page and a portal."
One more thing, and it's the one that lasts. The reason we obsess over digests and immutability and supply chain isn't pedantry — it's that containers are how code gets from your laptop to production, and that path is exactly where attackers and accidents live. A poisoned base image, a re-pushed tag, an unsigned manifest — these are real breaches that have hit real companies. When you pin digests, sign manifests, scan for CVEs, pull with a managed identity instead of a password, and rebuild automatically when a base CVE drops, you're not checking compliance boxes. You're being the person who made the supply chain defensible. That person is who the org trusts with production. That's the ladder.
Go build the registry and the scheduler. Then come find me in Phase 08 — that's where we take these digests and wire them into a CI/CD pipeline with approval gates and OIDC federation, and the "secure supply chain" stops being a buzzword and becomes a thing you can draw.
— your brother 👨🏻
Lab 01 — OCI Registry Engine + AKS Scheduler
Phase: 07 — Containers, Docker, ACR & AKS Internals | Difficulty: ⭐⭐⭐☆☆ | Time: 4–5 hours
A container image is not a binary — it is an ordered stack of content-addressed layers plus a config, named by a
sha256digest, listed by a manifest, and pointed at by a mutable tag. Get that one model right and ACR's whole behaviour — dedup, layer-reuse pulls, garbage collection, geo-replication — falls out as arithmetic on digests. This lab makes you build the registry, then build the AKS scheduler that bin-packs pods onto nodes and tells you, with numbers, why a pod isPending.
What you build
digest(data)—"sha256:" + hexdigest; the content-addressing primitive everything else stands on (same bytes → same name; one flipped bit → a new name).Registry— a content-addressable OCI registry:put_blob(store-by-digest with dedup),put_manifest(store config+layers, return a content-addressed manifest digest),tag(mutable pointer → digest),resolve(tag or digest → manifest digest),pull(ref, client_has=…)(return only the missing blobs — layer reuse),blob_count.garbage_collect(registry)— mark-and-sweep from the tags: a shared layer survives while any tagged image references it; untagging reclaims only an image's exclusive blobs. Returns the swept digest set.replicate(src, dst, name)— ACR geo-replication: copy a tag's manifest + only the blobs the destination is missing.schedule(pods, nodes)— an AKS-style first-fit bin-packer: place each pod (by name order) on the first node whose remaining cpu and mem satisfy its requests and whose taints are all tolerated; decrement capacity as pods land; a pod that fits nowhere isPending(None).
Key concepts
| Concept | What to understand |
|---|---|
| Content addressing | a blob's name is the hash of its bytes → dedup and tamper-evidence are free |
| Digest vs tag | digests are immutable; tags are mutable pointers → pin prod by digest |
| Manifest closure | the set of blobs (config + layers) you must possess to run an image |
| Pull = set difference | download = manifest's blobs minus what the client already cached |
| GC reachability | a blob is garbage iff no tagged manifest reaches it; shared layers survive |
| Replication = missing-only | copy the small manifest always, the big blobs only if absent |
| Schedule by requests | placement uses requests (the floor), not limits (the ceiling) |
Pending is arithmetic | unschedulable = requests exceed remaining capacity, or no taint tolerated |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and signatures/docstrings in place |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All tests pass against your implementation (
pytest test_lab.py -v). - You can explain why
test_shared_base_layer_stored_onceasserts 5 blobs, not 6 — the base layer two images share is stored exactly once because blobs are keyed by content. - You can explain why
test_gc_shared_layer_survives_until_last_tag_goneonly reclaims the base after both referencing tags are gone — GC reachability is computed from the tags. - You can explain why
test_schedule_is_deterministic_by_pod_nameproduces the same result regardless of input order, and whyworker/zetalands asPending. - Given a
docker pullthat "downloads almost nothing," you can say why (cached base layers), and given aPendingpod you can name the two possible causes (capacity, taints).
How this maps to real Azure
| Lab construct | Real Azure / OCI mechanism |
|---|---|
digest() | the OCI image-spec sha256: content descriptor digest |
Registry blob/manifest store | an ACR repository's data plane (the OCI distribution-spec /v2/ API) |
tag is a mutable pointer | myacr.azurecr.io/app:latest drifts; production pins app@sha256:… |
pull(client_has=…) | docker pull skipping already-cached layers ("Already exists") |
garbage_collect() | ACR untagged-manifest GC / az acr manifest list-metadata + acr purge task |
replicate() | ACR geo-replication (Premium SKU) copying manifests/blobs to paired regions |
Node / Pod / schedule() | the kube-scheduler filter step (PodFitsResources, TaintToleration) on AKS node pools |
Pod is Pending | kubectl describe pod → 0/N nodes are available: Insufficient cpu / had untolerated taint |
What the lab simplifies (know these for the interview): real manifests are JSON with
media types and sizes (and a manifest list / image index for multi-arch); ACR GC also
honours soft-delete retention and never deletes a blob mid-pull; the kube-scheduler runs a
scoring phase (least-allocated, spread, affinity) after the filter step we model, and
also weighs nodeSelector, affinity/anti-affinity, topology spread, and pod priority /
preemption. Our first-fit is the filter step, deterministic and good enough to teach the
mechanism.
Extensions
- Add a manifest list (multi-arch index): a manifest whose "layers" are themselves
manifest digests, one per
os/arch; makepullselect by platform. - Add content trust: sign a manifest digest (HMAC over the digest with a key) and make
pullrefuse an untrusted/altered manifest — model Docker Content Trust / Notation. - Add soft-delete:
untagmoves a manifest to a tombstone with a retention window;garbage_collectonly sweeps tombstones past their window;restorebrings one back. - Add a scheduler scoring pass: among nodes that pass the filter, pick least-allocated (spread) or most-allocated (bin-pack to scale down) and show the placement differs.
- Add pod priority + preemption: a high-priority
Pendingpod evicts a lower-priority pod to make room; show the evicted pod becomesPending.
Resume / interview bullets
- Built a content-addressable OCI registry (digests, manifests, mutable tags) with cross-image layer deduplication, a missing-blob pull protocol, mark-and-sweep garbage collection, and missing-only geo-replication — the ACR data plane modelled.
- Implemented an AKS-style bin-packing scheduler (requests, taints/tolerations, capacity
accounting) that explains
Pendingpods as requests-vs-capacity arithmetic.
Phase 08 — CI/CD Pipelines & Secure Supply Chain
Difficulty: ⭐⭐⭐⭐☆ (the mechanism is medium; the security reasoning is principal) Estimated Time: 1–1.5 weeks (14–20 hours) Prerequisites: P03 (OAuth2 / OIDC / JWT — the token you federate is a JWT), P04 (RBAC — the deploy identity's role assignments), P07 (containers/ACR — what you build and push). P01 (ARM deployment) is the thing your pipeline actually triggers.
Why This Phase Exists
Every other phase in this track describes a thing that exists in Azure. This phase describes the thing that puts every other thing into production — and the place where security most often goes to die. The JD asks for it in one line — "Develop and maintain CI/CD pipelines to automate application builds, testing, and deployments" — but the principal-level content is hidden under that sentence:
-
A pipeline is a dependency graph, not a script. Stages depend on stages, jobs on jobs, and the engine runs them in topological order with conditions that decide what gets skipped when something upstream fails. If you think of a pipeline as a linear list of commands, you cannot explain why your deploy stage "didn't run" (it was skipped because a dependency failed its condition) or why a rollback stage fired on a green build (someone set
condition: always()instead offailed()). The execution model is the knowledge. -
The pipeline is your biggest credential-leak surface, and OIDC federation is the fix. For a decade, "CI deploys to cloud" meant storing a long-lived service- principal secret in the CI system — a credential that authenticates from anywhere, forever, and leaks in logs, forks, and breaches. Workload-identity federation replaces it: the pipeline presents a short-lived OIDC token, and Entra exchanges it for an access token only if a configured federated credential matches the token's issuer, subject, and audience. No secret is stored. The subject pins the branch or environment, so a push to a feature branch — or a pull request from a fork — cannot mint a production credential. Understanding why that one string is the security boundary is the principal signal.
-
A deploy is a control problem, not a copy. Blue-green and canary are not buzzwords; they are feedback loops: shift a slice of traffic, measure health, and automatically roll back before a bad release reaches everyone. The principal builds the health gate and the rollback path before the incident, because at 2 a.m. the difference between "we auto-rolled-back at 5% traffic" and "we took prod down for 40 minutes" is whether someone wrote the gate.
-
The supply chain is now an attack surface with a name (SLSA). SolarWinds, Codecov, and the
xzbackdoor all share a shape: the build system was compromised, so the signed artifact was malicious-but-trusted. The modern answer is artifacts + SBOM + provenance + signing, verified at deploy time. This phase frames it so you can reason about it in a design review even before you wire up Sigstore.
If you skip this phase you can still write a .yml that turns green — and you will be
unable to answer "why did the deploy skip," "how does keyless auth work," or "how do you
roll back automatically," which are exactly the questions that separate the person who
owns the delivery platform from the person who copies a template.
Concepts
- Pipeline structure: stages → jobs → steps. A stage is a deployment boundary (often = an environment); a job runs on one agent; a step is one task/script. The unit of dependency is the stage (and the job within a stage).
- The DAG and
dependsOn: edges between stages form a directed acyclic graph; the engine topologically orders it. A cycle is a configuration error (the engine refuses to run); an edge to a non-existent stage is too. - Conditions (
succeeded/always/failed): the gate that decides whether a stage runs given its dependencies' results.succeeded(default) needs all deps green;alwaysruns regardless;failedruns only when a dependency failed (the rollback / notify-on-failure stage). This is where "why didn't my stage run" lives. - Failure propagation: a failed stage isn't
succeeded, so itssucceeded-condition dependents skip, and so on transitively. Failure flows down the DAG. - Matrix fan-out: one job definition expands into the Cartesian product of a matrix (OS × version × …) — N parallel jobs from one spec.
- Environments + approval gates: a protected environment pauses the pipeline until a human approves (or a check passes). The stage is blocked, not failed, until then.
- OIDC workload-identity federation: keyless authentication. The CI provider is an
OIDC issuer; a federated credential on an Entra identity trusts a specific
issuer+subject+audience; the pipeline's OIDC token is exchanged for an access token only on an exact match. Deny by default. - Deployment strategies: blue-green (two slots, atomic swap, instant rollback), canary (ramp traffic %, gate on health, auto-rollback), rolling (replace instances in batches) — each with a health gate that triggers automatic rollback.
- Supply-chain integrity: artifact, SBOM (software bill of materials), provenance (who built what, how), signing/attestation (Sigstore/cosign, SLSA levels), verified at the deploy gate.
Labs
Lab 01 — Pipeline Engine (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the three mechanisms under a CI/CD platform: a deterministic pipeline DAG executor (stages, dependsOn, succeeded/always/failed conditions, approval gates, failure propagation, cycle/unknown-dep detection); an OIDC workload-identity-federation token-exchange evaluator (exact issuer/subject/audience match, deny-by-default, wrong-branch rejection); and a rollout controller (blue-green swap + health-gated canary with automatic rollback) |
| Concepts | Pipelines are DAGs not scripts; a stage runs iff deps satisfied its condition and approval was granted; keyless auth via federated credentials and why the subject is the security boundary; progressive delivery with an automatic rollback |
| Steps | 1. Stage model + deterministic topological order (Kahn; raise on cycle/unknown dep); 2. run_pipeline (condition gate → approval gate → run, with failure propagation); 3. exchange_oidc_token + FederatedCredential + subject_matches (exact match, deny by default); 4. blue_green_swap + canary_rollout (auto-rollback on the first failing step) |
| How to Test | pytest test_lab.py -v — 30 tests: topo execution + cycle/unknown-dep raise; skip on failed dependency; always/failed conditions; approval blocks then proceeds; OIDC accept and reject on wrong issuer/subject/audience; blue-green swap vs rollback; canary completes vs rolls back |
| Talking Points | "Why did the deploy stage skip?" "How does the pipeline authenticate to Azure with no secret — and what stops a feature branch from deploying to prod?" "Walk me through an automatic rollback." |
| Resume bullet | Built a CI/CD DAG executor with conditions and approval gates, an OIDC workload-identity-federation evaluator (keyless, deny-by-default), and blue-green/canary rollout controllers with automatic health-gated rollback |
→ Lab folder: lab-01-pipeline-engine/
Integrated-Scenario Suggestions (carried through the whole track)
These tie Phase 08 to the platform the rest of the track builds:
- Keyless multi-environment promotion. A monorepo builds once, then promotes the
same artifact through dev → staging → prod, each a protected environment with its
own federated credential (different
subjectper environment) and approval gate. No secret anywhere. Prove that a non-mainbranch cannot acquire the prod credential. - Container build-and-deploy with provenance. Build an image (P07), push to ACR, generate an SBOM and a signed provenance attestation, then a deploy gate verifies the signature and the builder identity before rolling it out to AKS — refusing any image not produced by the trusted pipeline.
- Progressive delivery with auto-rollback. A canary on Container Apps shifts 5% →
25% → 50% → 100%, gating on the error-rate/latency SLO at each step, with an automatic
rollback to 0% on the first breach — and a
failed-condition stage that opens an incident only when the rollback fires. - Infra pipeline. The pipeline runs
terraform plan(P02), posts the plan as a PR comment, requires an approval, thenapplyon merge — the IaC change goes through the exact same DAG + gate + keyless-auth machinery as app code. - Blast-radius-bounded deploy. A change to a shared platform library fans out (matrix) to rebuild every dependent service, but a circuit-breaker stage halts the fan-out after the first N canary failures so one bad library version can't roll out everywhere.
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- A pipeline is a DAG with conditions and gates, not a script. The execution model — a stage runs iff its dependencies satisfied its condition and its approval was granted — explains every "why didn't this run" you will ever debug.
- OIDC workload-identity federation is the modern answer to the worst CI security
smell: a long-lived stored cloud secret. The pipeline presents a short-lived,
subject-scoped OIDC token; Entra exchanges it on an exact
iss/sub/audmatch; the subject is the branch/environment boundary, and it denies by default. - Deployments are control loops: blue-green (instant swap/rollback) and canary (ramp + health gate + auto-rollback) bound the blast radius of a bad release. Build the health gate and the rollback path before you need them.
- The supply chain is an attack surface: artifacts → SBOM → provenance → signing, verified at the deploy gate. SLSA is the framework that names the levels.
Deliverables Checklist
-
Lab 01 implemented; all 30 tests pass against
solution.pyand yourlab.py - You can draw a 5-stage pipeline DAG and, for a given failure, say which stages run, skip, or block — and why
-
You can explain the OIDC token-exchange match (
iss/sub/aud) and why a wrong-branch subject is rejected — without a stored secret anywhere - You can contrast blue-green vs canary vs rolling and say which rollback is instant
- You can name the four supply-chain integrity artifacts (artifact, SBOM, provenance, signature) and where each is verified
Warmup Guide — CI/CD Pipelines & Secure Supply Chain
Zero-to-principal primer for Phase 08: what a pipeline actually is (a DAG, not a script), the stage/job/step model and the
dependsOngraph, the condition engine that decides what runs and what skips, approval gates and environments, matrix fan-out, then the part that gets people owned — OIDC workload-identity federation (keyless auth to Azure with no stored secret, and why the token's subject is the security boundary), then the deployment strategies (blue-green, canary, rolling) as control loops with automatic rollback, and finally the secure supply chain (artifacts, SBOM, provenance, signing, SLSA). Every concept goes from what it is to why it exists to the mechanism under the hood (diagrams, tables, code) to production significance to the misconceptions that get people paged or breached.
Table of Contents
- Chapter 1: Why CI/CD Is the Control Plane for Everything Else
- Chapter 2: A Pipeline Is a DAG — Stages, Jobs, Steps
- Chapter 3: The Execution Model — Conditions and Failure Propagation
- Chapter 4: Approval Gates and Environments
- Chapter 5: Matrix Fan-Out
- Chapter 6: The Credential Problem CI Was Born With
- Chapter 7: OIDC Workload-Identity Federation — Keyless Auth
- Chapter 8: The Subject Is the Security Boundary
- Chapter 9: Deployment Strategies as Control Loops
- Chapter 10: The Secure Supply Chain — Artifacts, SBOM, Provenance, Signing
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why CI/CD Is the Control Plane for Everything Else
From zero. Continuous Integration (CI) means: every change is automatically built and tested the moment it's pushed, so integration problems surface in minutes instead of at a quarterly "merge week." Continuous Delivery / Deployment (CD) means: a change that passes CI is automatically packaged and rolled out (delivery = ready to deploy at a button; deployment = deployed without one). Together they are the automated path from a commit to production.
Why it exists. Before CI/CD, releases were manual, batched, and terrifying — a human ran build scripts on their laptop, copied artifacts to servers, and prayed. The failure mode was large, infrequent, high-risk deploys. CI/CD inverts that: small, frequent, low-risk deploys, each automatically verified, each independently reversible. The whole DevOps thesis (Accelerate, the DORA metrics) is that deploy frequency and lead time correlate with — not trade against — stability, because small changes are easy to verify and easy to roll back.
Why a principal cares more than anyone. Every artifact this curriculum produces — an ARM deployment (P01), a Terraform apply (P02), a container image (P07), a Function (P11) — reaches production through a pipeline. That makes the pipeline the control plane for the control plane: it holds the credentials that can change your whole cloud, it's the gate that lets (or stops) a bad change, and it's the single most attractive target an attacker has (compromise the build, and every signed artifact is trusted). So "the pipeline" is not DevOps plumbing you delegate; it is a security-critical distributed system you must understand at the level of which stage runs when, what credential it holds, and what it's allowed to deploy.
The three things this phase makes you own:
the GRAPH the IDENTITY the ROLLOUT
(what runs when) (how it auths, keyless) (how a bad release is contained)
Ch. 2–5 Ch. 6–8 Ch. 9
…plus the supply-chain framing (Ch. 10) that wraps all three.
Chapter 2: A Pipeline Is a DAG — Stages, Jobs, Steps
The hierarchy. Both Azure Pipelines and GitHub Actions use the same three-level model (different nouns):
| Level | Azure Pipelines | GitHub Actions | What it is |
|---|---|---|---|
| top | stage | (job groups via needs) | a deployment boundary, usually one per environment |
| middle | job | job | a unit that runs on one agent/runner; the unit of parallelism |
| bottom | step | step | one task or script line; runs in order within a job |
A stage contains jobs; a job contains steps. Steps in a job run sequentially on the same agent (so they share a filesystem); jobs run in parallel on different agents (so they do not share a filesystem — you pass data via artifacts).
Why it's a graph. Stages declare dependencies with dependsOn (Azure) / needs
(GitHub). Those dependencies are edges; the stages are nodes; together they form a
directed acyclic graph. The engine computes a topological order — every stage runs
after all its dependencies — and runs independent stages in parallel.
┌──────┐
│ build│
└──┬───┘
┌─────┴─────┐
▼ ▼
┌───────┐ ┌────────┐ build → {test, lint} → deploy
│ test │ │ lint │ deploy waits for BOTH test and lint
└───┬───┘ └───┬────┘ test and lint run in parallel
└─────┬─────┘
▼
┌────────┐
│ deploy │
└────────┘
# Azure Pipelines
stages:
- stage: build
- stage: test
dependsOn: build
- stage: lint
dependsOn: build
- stage: deploy
dependsOn: [test, lint] # fan-in: waits for both
Why acyclic matters. A cycle (a depends on b depends on a) has no valid run
order — the engine cannot start either — so it's a configuration error, refused before
anything runs. So is an edge to a stage that doesn't exist. In the lab, _topological_order
raises on both, exactly like the real engine rejecting your YAML. (Kahn's algorithm:
repeatedly emit a node with no remaining incoming edges; if you can't empty the graph,
what's left is a cycle.)
Determinism. CI must be reproducible: the same graph must always run the same way.
When several stages are simultaneously ready, the lab breaks ties by name, so
{stage: status} is byte-identical run to run. Non-determinism in a build system is a bug
you debug for days ("it only fails on Tuesdays").
Chapter 3: The Execution Model — Conditions and Failure Propagation
This is the chapter that explains every "why didn't my stage run?" you will ever debug.
The rule, stated once:
A stage runs iff (a) every dependency satisfied this stage's condition, and (b) if the stage requires approval, that approval was granted. Otherwise it is skipped (condition unmet) or blocked (approval withheld). When it runs, its result is succeeded or failed.
The conditions. A condition decides whether to run given the results of the dependencies:
| Condition | Runs when | Real use |
|---|---|---|
succeeded (default) | all dependencies succeeded | the normal case — deploy only if tests passed |
always | regardless of dependency results | notify / cleanup / publish-logs — must run even on failure |
failed | some dependency failed | rollback / open-incident — run only when something broke |
Failure propagation is the whole point of a DAG. A failed stage is not
succeeded. So any downstream stage with the default succeeded condition finds an unmet
condition and skips. That skip cascades transitively:
build(ok) → test(FAIL) → deploy(?) deploy.condition = succeeded
test is not succeeded ⇒ deploy SKIPS
→ notify(?) notify.condition = always ⇒ notify RUNS
── and a rollback stage with condition=failed runs IFF a dep FAILED ──
Two classic bugs this model explains:
- "My deploy stage silently didn't run." — A dependency failed (or was itself skipped),
so the default
succeededcondition wasn't met. The stage skipped; it didn't fail. Skipped ≠ failed ≠ blocked — three different statuses, three different causes. - "Our rollback ran on a green build." — Someone wrote
condition: always()where they meantfailed().alwaysruns no matter what;failedruns only on a failure.
A subtlety the lab gets right: a dependency that was skipped or blocked also isn't
succeeded and isn't failed. So a succeeded-dependent of a skipped stage skips, and a
failed-dependent of a skipped (not failed) stage also skips — failed needs a real
failure, not merely an absence of success. (This is why, in the worked example, a
rollback that depends on a skipped deploy is itself skipped — nothing actually
failed at deploy; it never ran.)
Chapter 4: Approval Gates and Environments
What it is. An environment (Azure Pipelines / GitHub Actions both call it that) is
a named deployment target — dev, staging, production — that you can protect with
checks: required reviewers (a human clicks Approve), a wait timer, a business-hours
window, an exclusive lock (one deploy at a time), or an automated gate (a query against a
monitor must be healthy).
Why it exists. Not every deploy should be fully automatic. Production often wants a human in the loop, or a "soak" period, or a manual smoke test. The approval gate pauses the pipeline rather than failing it — the stage sits in a blocked / waiting state until the approval arrives (or expires, after which it's auto-rejected).
Mechanism. The engine reaches the protected stage, sees an unmet check, and suspends the run there — no agent is consumed, no job starts — until the check passes. In the lab:
if stage.approval and not approvals.get(stage.name, False):
status[stage.name] = BLOCKED # waiting for a human; downstream sees "not succeeded"
continue
Production significance. A blocked stage is not a failure and not a success — so a
downstream succeeded-dependent skips (the gate hasn't been approved, so nothing past
it should run). This is correct and important: an un-approved prod deploy must not let a
post-deploy smoke-test stage run against a version that was never deployed. The lab's
test_blocked_dependency_skips_downstream pins exactly this.
Misconception. "Approvals are just a UI nicety." No — an approval gate is a control. It's the difference between a continuous-delivery pipeline (auto to staging, gated to prod) and continuous-deployment (auto all the way). Which one you choose is an explicit risk decision, and a principal states it in the ADR, not in a Slack thread.
Chapter 5: Matrix Fan-Out
What it is. A matrix expands one job definition into the Cartesian product of a set of variables — one job per combination, run in parallel.
# GitHub Actions
strategy:
matrix:
os: [ubuntu, windows, macos]
python: ["3.11", "3.12", "3.13"]
# ⇒ 3 × 3 = 9 parallel jobs, one per (os, python) pair
Why it exists. You want to test (or build) across a grid of environments without
copy-pasting nine near-identical jobs. The matrix is the DRY answer: one spec, N runs. The
downstream stage (e.g. publish) depends on the whole matrix — fan-out then fan-in.
Mechanism & cost. The engine multiplies out the matrix dimensions and schedules each
cell as an independent job (subject to a concurrency cap — max-parallel). The principal
note: the matrix is also a cost and time lever. Nine parallel jobs finish in the time
of the slowest, but consume nine agents; a careless matrix (every OS × every version ×
every flag) can be hundreds of jobs and a surprising bill. Prune to the combinations that
actually catch bugs.
Chapter 6: The Credential Problem CI Was Born With
The original sin. To deploy to Azure, a pipeline must authenticate — present an identity Azure trusts. The first-generation answer: create a service principal (an Entra app identity), generate a client secret (a password) or a certificate, and store it in the CI system as a secret variable. The pipeline reads it and signs in.
Why that's dangerous — count the ways:
- It's long-lived. A secret valid for a year (or, lazily, forever) authenticates from anywhere, not just your pipeline. Steal it once and you're in until someone rotates it.
- It's exfiltratable. Secrets leak into logs (
echo $SECRET), into forked pull requests, into compromised actions/tasks, into screenshots, into the breach of the CI vendor itself. - It's rotation hell. Many secrets across many pipelines, each with its own expiry, is a perpetual maintenance tax — and the one nobody rotated is the one that leaks.
- It's a standing target. The secret exists at rest 24/7 whether or not a build is running. The attack surface is "always."
┌───────────┐ stores ┌──────────────────┐ presents ┌────────┐
│ Entra SP │ ───────► │ CI secret store │ ─────────► │ Azure │
│ + secret │ (leaks) │ (logs, forks, │ forever │ ARM │
└───────────┘ │ vendor breach) │ └────────┘
the credential exists at rest, valid from anywhere, long-lived
Every major CI breach (Codecov 2021, CircleCI 2023) had the same blast radius multiplier: stored long-lived cloud credentials. The fix isn't "store it more carefully." It's don't store it at all — Chapter 7.
Chapter 7: OIDC Workload-Identity Federation — Keyless Auth
The idea in one sentence. Instead of storing a secret, the pipeline proves who it is with a short-lived OIDC token that the CI provider mints per-run, and Entra exchanges that token for an Azure access token — only if a pre-configured federated credential trusts the token's issuer, subject, and audience. No secret is stored anywhere.
The cast:
- OIDC issuer — the CI provider runs an OpenID Connect identity provider. GitHub:
https://token.actions.githubusercontent.com. Azure DevOps:https://vstoken.dev.azure.com/{org-id}. It publishes signing keys (JWKS) at a well-known URL and mints a signed JWT (the OIDC token) for each pipeline run. - The OIDC token's claims — a JWT with
iss(the issuer above),aud(the audience, e.g.api://AzureADTokenExchange), and cruciallysub(the subject, identifying which repo, branch, or environment this run is), plusexp(it lives minutes). - Federated credential — a configuration object on an Entra app registration or a
user-assigned managed identity that says "I will trust a token with this
issuer, thissubject, and thisaudience." It stores no secret — just those three strings.
The exchange, step by step:
1. pipeline run starts
2. CI provider mints an OIDC token (a JWT):
{ iss: token.actions.githubusercontent.com,
sub: repo:contoso/app:ref:refs/heads/main,
aud: api://AzureADTokenExchange, exp: now+5m, ... } # signed by issuer
3. pipeline POSTs it to Entra's token endpoint as a client assertion
grant_type=client_credentials
client_assertion_type=...jwt-bearer
client_assertion=<the OIDC token>
4. Entra:
a. fetches the issuer's JWKS, VERIFIES the token's signature + exp
b. looks up the app's federated credentials
c. checks: does some credential match iss AND sub AND aud EXACTLY?
d. yes ⇒ mint an Azure access token (scoped by the identity's RBAC)
no ⇒ reject (AADSTS700213 / 700212)
5. pipeline uses the access token to call ARM (deploy)
The lab models step 4c — the match decision — which is where the security lives (step 4a, the signature check, is P03's JWT-validation lab):
def exchange_oidc_token(token_claims, creds):
for required in ("iss", "sub", "aud"):
if required not in token_claims:
raise ValueError(...) # a malformed token is denied loudly
for cred in creds:
if (cred.issuer == token_claims["iss"]
and cred.audience == token_claims["aud"]
and subject_matches(cred.subject, token_claims["sub"])):
return True # exact match ⇒ exchange allowed
return False # DENY BY DEFAULT
Why this beats a stored secret — directly:
| Property | Stored SP secret | OIDC federation |
|---|---|---|
| Credential at rest | yes, long-lived, always present | none — nothing to steal |
| Validity window | months/years | minutes (the token's exp) |
| Usable from anywhere | yes | no — only a run that the issuer vouches for |
| Scoped to a branch/env | no | yes — via the subject |
| Rotation | manual, perpetual | n/a — there's nothing to rotate |
| Leak in logs/forks | catastrophic | low value — expires in minutes, subject-pinned |
Misconception. "Keyless means there's no identity." Wrong — there's still an Entra identity with RBAC role assignments (P04); what's gone is the stored secret. The identity is the same; the proof changed from "I know a password" to "an issuer Entra trusts vouches that I'm this exact pipeline run."
Chapter 8: The Subject Is the Security Boundary
The single most important field in the whole flow is the OIDC token's sub and the
federated credential's subject that it must match. The subject encodes which exact
pipeline context is asking:
GitHub Actions subjects:
repo:contoso/app:ref:refs/heads/main ← a push to main
repo:contoso/app:ref:refs/heads/release ← the release branch
repo:contoso/app:environment:production ← a deploy to the 'production' environment
repo:contoso/app:pull_request ← ANY pull request (incl. forks!)
Why this is the boundary. Configure the production identity's federated credential with
subject = repo:contoso/app:ref:refs/heads/main, and only a run triggered by main can
mint the prod token. A push to a feature branch produces
sub = ...:ref:refs/heads/feature/x — no match, denied. A pull request from a forked
repo produces sub = ...:pull_request — no match, denied. The wrong subject is
rejected, and that rejection is the security control. The lab's
test_oidc_reject_wrong_subject_branch is the entire point of the OIDC half of the lab:
good = {"iss": ISS, "sub": "repo:contoso/app:ref:refs/heads/main", "aud": AUD}
bad = {**good, "sub": "repo:contoso/app:ref:refs/heads/attacker"}
exchange_oidc_token(good, [cred]) # True — right branch
exchange_oidc_token(bad, [cred]) # False — wrong branch, DENIED
The classic misconfiguration. Making the subject too broad — e.g. a credential that
matches any branch (...:ref:refs/heads/*) on a repo where pull requests run privileged
deploys — lets an attacker open a PR and acquire your cloud credential. The lab supports a
trailing-* pattern because the real systems do, with a giant caveat: a wide pattern
is a wide blast radius. The principal default is the narrowest exact subject per
environment, and one federated credential per (identity, branch/environment) pair.
Audience matters too. The aud (api://AzureADTokenExchange for Entra) prevents a
token minted for another relying party from being replayed at Entra. Wrong audience →
denied (test_oidc_reject_wrong_audience). And the issuer must be exact — a token from
https://evil.example.com is denied even with the right subject
(test_oidc_reject_wrong_issuer). All three must match; deny by default if no
configured credential does.
Chapter 9: Deployment Strategies as Control Loops
A deploy is not copy new version over old. It's a control loop: introduce the new
version gradually or reversibly, measure health, and roll back automatically if
health degrades — so a bad release is contained, not broadcast.
Blue-green. Two identical production environments ("slots"), one live (blue), one idle (green). Deploy the new version to the idle slot, warm it, then swap — an atomic cutover at the load balancer. If post-swap health is bad, swap back: rollback is instant because the old version is still warm in the other slot.
before: [BLUE v1] ◄── 100% traffic [GREEN] (idle, deploy v2 here, warm it)
swap: [BLUE v1] (idle) [GREEN v2] ◄── 100% traffic
healthy? yes ⇒ done. no ⇒ swap back ⇒ [BLUE v1] ◄── 100% (instant rollback)
def blue_green_swap(active, candidate, *, healthy):
return candidate if healthy else active # the slot that ends up live
In Azure this is an App Service slot swap. Cost: you pay for two slots; benefit: zero- downtime cutover and instant rollback.
Canary. Don't cut over 100% — shift a small slice (5%) to the new version, watch its health, then ramp (25% → 50% → 100%) only if it stays healthy. On the first failing step, roll back to 0%. The blast radius of a bad release is capped at the canary slice, and rollback is automatic — no human paged.
5% ─ healthy? ─► 25% ─ healthy? ─► 50% ─ healthy? ─► 100% (done)
│ NO
▼
rollback to 0% { final_pct: 0, rolled_back: True, failed_at: 25 }
def canary_rollout(steps, health_check):
for pct in steps:
if not health_check(pct):
return {"final_pct": 0, "rolled_back": True, "failed_at": pct}
return {"final_pct": 100, "rolled_back": False}
Two design notes the lab pins: on rollback final_pct is 0 (you pulled all the new-
version traffic, not just the failing step's), and on success it's 100 regardless of
the last step's literal value ("all steps passed" = fully rolled out). In Azure this is
AKS/Container Apps progressive traffic-splitting (Argo Rollouts, Flagger, or native
revision weights).
Rolling. Replace instances in batches (e.g. 25% at a time), health-checking each batch before the next. No second environment (cheaper than blue-green), but rollback is slower (you must roll the batches back), and during the roll you run mixed versions — which only works if the change is backward-compatible.
| Strategy | Rollback speed | Extra cost | Mixed versions live? |
|---|---|---|---|
| Blue-green | instant (swap back) | 2× environment | no (atomic swap) |
| Canary | fast (drop the slice) | small (the canary %) | yes (canary + stable) |
| Rolling | slow (re-roll batches) | none | yes (during the roll) |
The principal rule: the health gate and the rollback path must exist before the deploy, not be improvised during the incident. "We'll watch it manually" is not a strategy; an automatic gate is.
Chapter 10: The Secure Supply Chain — Artifacts, SBOM, Provenance, Signing
Why this is now a named discipline. SolarWinds (2020), Codecov (2021), and the xz
backdoor (2024) share one shape: the build/distribution system was compromised, so the
artifact everyone downloaded was malicious but signed-and-trusted. You can't defend
against that by trusting the artifact — you have to be able to ask "who built this, from
what source, with what builder, and can I verify that cryptographically?"
The four artifacts, and where each is checked:
| Artifact | What it is | Produced | Verified |
|---|---|---|---|
| Artifact | the built thing (image, zip, package) — content-addressed by digest | build stage | by digest, on every pull |
| SBOM | software bill of materials — every dependency + version (CycloneDX / SPDX) | build stage | scanned for known CVEs at the gate |
| Provenance | a signed statement: this digest was built by builder X from source commit Y (in-toto / SLSA) | build stage | at the deploy gate |
| Signature | a cryptographic signature over the artifact + provenance (Sigstore/cosign) | build/sign stage | at the deploy gate (verify signer identity) |
SLSA (Supply-chain Levels for Software Artifacts) is the framework that names the
maturity levels: roughly L1 = you have provenance, L2 = it's signed by the build
service, L3 = the build runs in a hardened, isolated builder that can't be tampered with,
L4 = two-party review + hermetic builds. You don't need to memorise the levels; you need to
be able to say in a design review "our deploy gate verifies a signed provenance
attestation that the artifact came from our main pipeline, so a hand-pushed image is
refused."
The connection to the rest of this lab: the same OIDC identity (Ch. 7) that deploys is the identity that signs (keyless signing with Sigstore uses an OIDC token too — the signer's identity is the pipeline's subject), and the deploy gate (Ch. 3–4) is where the verify step lives as a condition. The supply chain isn't a separate system; it's the pipeline, with attestation added at build and verification added at the gate.
Lab Walkthrough Guidance
Lab 01 — Pipeline Engine, suggested order:
Stage+_topological_order— theStagedataclass validates itsname,condition(succeeded/always/failed), and rejects self-dependency. Then Kahn's algorithm with a sorted ready-set for determinism; raise on a duplicate name, an edge to an unknown stage, or a cycle (Ch. 2). Test withtest_cycle_raises,test_unknown_dependency_raises,test_topo_order_is_deterministic.run_pipeline— for each stage in topo order: SKIP if its condition isn't satisfied by its dependencies' statuses; BLOCK if it needs approval and none was granted; else run (stage.run(context)oroutcomes[name], default success) → SUCCEEDED/FAILED (Ch. 3–4). The key cases:test_skip_on_failed_dependency,test_always_runs_even_after_failure,test_failed_condition_runs_only_on_failure,test_approval_blocks_then_proceeds.exchange_oidc_token+FederatedCredential+subject_matches— exact issuer and audience, subject viasubject_matches(exact, plus a trailing-*pattern); deny by default; raise on a missing claim (Ch. 7–8). The four negatives are the whole point:test_oidc_reject_wrong_issuer/..._subject_branch/..._audience, andtest_oidc_deny_by_default_no_creds.blue_green_swap+canary_rollout— blue-green returns the live slot; canary ramps and rolls back to 0% on the first failing step (Ch. 9). Pintest_canary_rolls_back_at_failing_step(returnsfailed_atandfinal_pct: 0) andtest_canary_completes_when_all_healthy(final_pct: 100).
Run it red, then green:
pytest test_lab.py -v # your lab.py
LAB_MODULE=solution pytest test_lab.py -v # the reference — all 30 green
python solution.py # the worked example
Success Criteria
You are ready for Phase 09 when you can, from memory:
- Draw a 5-stage pipeline DAG and, for a given mid-graph failure, say which stages run, skip, or block — and why each (condition unmet vs approval withheld vs dep failed).
- State the execution rule in one sentence ("a stage runs iff every dependency satisfied its condition and any required approval was granted").
- Distinguish
succeeded/always/failedconditions and name the real stage that uses each (deploy / notify / rollback). - Draw the OIDC token-exchange flow end to end and name the three claims that must match
(
iss,sub,aud) and which one is the branch/environment boundary (sub). - Explain — without a stored secret anywhere — why OIDC federation beats a long-lived service-principal secret, using at least three of the table rows in Ch. 7.
- Explain why a wrong-branch subject is denied and why that denial is the security control.
- Contrast blue-green, canary, and rolling on rollback speed, extra cost, and mixed- version exposure — and say which rollback is instant and why.
- Name the four supply-chain integrity artifacts and where each is verified.
Interview Q&A
Q: A teammate says "the deploy stage just didn't run, no error." Walk me through how you
debug it.
First I clarify the status: skipped, blocked, or not-reached — they're different bugs.
Skipped means a dependency didn't satisfy the stage's condition: most often a succeeded
condition where an upstream stage failed or was itself skipped (failure propagates down the
DAG). Blocked means a required approval/check on its environment hasn't been granted — it's
waiting, not broken. Not-reached means an upstream stage is still running or the DAG edge
isn't what they think. I'd look at the upstream stages' results and the deploy stage's
condition, because 90% of "silently didn't run" is "a dependency wasn't succeeded and
the default condition needs all deps green."
Q: How does a pipeline deploy to Azure without storing a secret, and what stops a
feature branch from deploying to production?
OIDC workload-identity federation. The CI provider is an OIDC issuer; per run it mints a
short-lived signed JWT whose sub claim encodes the exact context (repo + branch or
environment). The pipeline presents that token to Entra as a client assertion; Entra
verifies the signature against the issuer's JWKS, then checks whether any federated
credential on the target identity matches the token's iss, sub, and aud exactly —
and if so mints an Azure access token. Nothing is stored: no secret to leak, the token
lives minutes, and it's scoped to one subject. What stops a feature branch from deploying
to prod is the subject: the prod identity's federated credential is pinned to
...:ref:refs/heads/main (or ...:environment:production), so a feature-branch run
produces a different sub, matches no credential, and is denied. The wrong subject is
the boundary.
Q: Why is OIDC federation actually more secure than a service principal with a client secret? Be specific. Four reasons. (1) No credential at rest — there's nothing in the CI vault to steal; a stored secret is a standing target valid 24/7. (2) Short-lived — the OIDC token expires in minutes, so even if it leaks in a log its window is tiny, versus a secret valid for a year. (3) Context-bound — the token is only minted by a real run the issuer vouches for, and only for the specific subject; a stolen secret authenticates from anywhere. (4) No rotation — there's nothing to rotate, which removes the "the one secret nobody rotated" failure mode entirely. The trade is that you must get the federated-credential subject right; too broad a subject (matching any branch, or pull requests on a privileged repo) is the one way to make it less safe than a tightly-scoped secret.
Q: Blue-green vs canary — when do you pick each, and how does rollback differ? Blue-green when I want an atomic, zero-downtime cutover with instant rollback and can afford a second full environment — the old version stays warm in the idle slot, so rollback is one swap. Canary when I want to limit blast radius and let real traffic and metrics gate the rollout: I shift 5%, watch the SLO, ramp only if healthy, and auto-roll- back to 0% on the first breach — so a bad release only ever hit the canary slice. Rolling when I can't afford a second environment and the change is backward-compatible, accepting slower rollback and mixed-version exposure during the roll. The non-negotiable across all three: the health gate and rollback path exist before the deploy, automated — "we'll watch it" is not a strategy.
Q: An attacker opens a pull request to your repo. Your pipeline runs on PRs and deploys
to prod with OIDC. What's the risk and the fix?
The risk is that a PR — including one from a fork the attacker controls — triggers a run
that can mint your prod cloud credential. The defense is the subject: PRs get
sub = ...:pull_request, which must not match any privileged federated credential.
Concretely: pin the deploy identity's credential to ...:ref:refs/heads/main (or the
production environment), require the deploy to run only on main/after merge, never on
pull_request, and use GitHub's environment protection so fork PRs can't even request the
id-token for prod. Also: never run privileged steps with untrusted PR code in the same
job that holds the token. The whole point of the narrow subject is to make
"PR-from-a-fork" a non-match.
Q: What's the difference between continuous delivery and continuous deployment, and how does the pipeline encode it? Continuous delivery keeps the pipeline green and deployable to prod at the click of a button — there's an approval gate before prod. Continuous deployment removes that gate: every change that passes CI goes all the way to prod automatically. In the pipeline it's literally one thing: whether the prod stage's environment has a required-reviewer check. That's a risk decision — how costly is a bad prod deploy vs how mature is your automated verification and rollback — and a principal makes it explicitly in an ADR, not by default.
Q: How do you stop a compromised build from shipping a trusted-but-malicious artifact? You make the artifact verifiable, not just signed. The build emits a provenance attestation (in-toto/SLSA: this digest, from this source commit, by this builder) and signs it (keyless via Sigstore, where the signer identity is the pipeline's OIDC subject). The deploy gate then verifies — refusing any artifact whose provenance isn't signed by the expected builder identity, so a hand-pushed or side-loaded image is rejected even if it's "signed" by something else. Plus an SBOM scanned for known CVEs at the gate. SLSA names the maturity levels; the principal point is that trust moves from "the artifact looks fine" to "I can cryptographically attribute it to my hardened pipeline."
References
- Azure Pipelines — Stages, dependencies & conditions:
https://learn.microsoft.com/azure/devops/pipelines/process/stages - Azure Pipelines — Specify conditions (
succeeded(),failed(),always()):https://learn.microsoft.com/azure/devops/pipelines/process/conditions - Azure Pipelines — Environments and approvals/checks:
https://learn.microsoft.com/azure/devops/pipelines/process/environments - GitHub Actions — Using jobs /
needs(the job DAG):https://docs.github.com/actions/using-jobs/using-jobs-in-a-workflow - GitHub Actions — OIDC hardening /
OIDC token:https://docs.github.com/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect - Microsoft Entra — Workload identity federation (concept + supported scenarios):
https://learn.microsoft.com/entra/workload-id/workload-identity-federation - Microsoft Entra — Configure a federated credential on an app:
https://learn.microsoft.com/entra/workload-id/workload-identity-federation-create-trust - Azure —
azure/loginGitHub Action with OIDC (no secret):https://github.com/Azure/login - Azure App Service — Deployment slots and swap:
https://learn.microsoft.com/azure/app-service/deploy-staging-slots - OpenID Connect Core 1.0 — the
iss/sub/audsemantics:https://openid.net/specs/openid-connect-core-1_0.html - SLSA — Supply-chain Levels for Software Artifacts:
https://slsa.dev - in-toto — Software supply chain attestation:
https://in-toto.io - Sigstore / cosign — keyless signing with OIDC identities:
https://www.sigstore.dev - DORA / Accelerate (Forsgren, Humble, Kim) — deploy frequency vs stability
- The track's own CHEATSHEET.md and GLOSSARY.md
🛸 Hitchhiker's Guide — Phase 08: CI/CD Pipelines & Secure Supply Chain
Read this if: you can make a
.ymlturn green but "why did the deploy skip," "how does keyless auth actually work," and "how do you roll back automatically" still sound like other people's problems. They're yours now. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP.
0. The 30-second mental model
A pipeline is a DAG with a security boundary bolted on. Three things to own:
- The graph — stages depend on stages; the engine runs them in topological order; a
stage runs iff its dependencies satisfied its condition and any required
approval was granted. Failure propagates: a failed/skipped/blocked dep is not
succeeded, so the default-condition dependents skip. - The identity — the pipeline auths to Azure with no stored secret via OIDC
workload-identity federation: it presents a short-lived OIDC token; Entra exchanges it
only if a federated credential matches
iss+sub+audexactly. The subject pins the branch/environment — the wrong subject is denied. That denial is the security. - The rollout — blue-green (instant swap, instant rollback) or canary (ramp %, gate on health, auto-rollback on the first failing step). The gate exists before the incident.
One sentence to tattoo: a stage runs iff every dependency satisfied its condition and its approval was granted — and the pipeline proves who it is with a subject-scoped OIDC token, not a stored secret.
1. The execution model, one breath
topo-order the DAG → for each stage:
condition not satisfied by deps? → SKIPPED
needs approval, not granted? → BLOCKED
else run it → SUCCEEDED / FAILED (failure propagates to dependents)
succeeded = all deps green (default) · always = run no matter what (notify/cleanup) ·
failed = run only if a dep FAILED (rollback). Skipped ≠ failed ≠ blocked — three
statuses, three causes.
2. The OIDC exchange, one breath
run starts → CI mints OIDC JWT { iss, sub=repo:org/app:ref:refs/heads/main, aud } (lives ~minutes)
→ POST to Entra as client_assertion (jwt-bearer)
→ Entra verifies signature (JWKS) + exp, then MATCHES iss & sub & aud to a federated cred
→ match? mint Azure access token (scoped by RBAC) : DENY (AADSTS700213)
No secret stored, anywhere. The subject is the branch/environment knob. Deny by default.
3. The numbers & rules to tattoo on your arm
| Thing | Number / rule |
|---|---|
| Default stage condition | succeeded — needs all deps green |
| The three conditions | succeeded / always / failed |
| OIDC token lifetime | minutes (exp), minted per run |
| Entra exchange audience | api://AzureADTokenExchange |
| Match requirement | iss AND sub AND aud, exact, deny by default |
| Federated creds per identity | ~20 max |
| GitHub OIDC permission | permissions: id-token: write (+ contents: read) |
| Wrong-branch error | AADSTS700213 (no matching federated credential) |
| Blue-green rollback | instant (swap back; old slot stays warm) |
| Canary on failure | roll back to 0% at the first failing step |
| Stored secret = | the worst CI security smell — don't store it, federate |
4. az / gh one-liners you'll actually run
# Create an Entra app + service principal to federate (no secret created!)
az ad app create --display-name "ci-deployer"
APP_ID=$(az ad app list --display-name ci-deployer --query "[0].appId" -o tsv)
# Add a FEDERATED credential pinned to main (this is the security boundary)
az ad app federated-credential create --id "$APP_ID" --parameters '{
"name": "github-main",
"issuer": "https://token.actions.githubusercontent.com",
"subject": "repo:contoso/app:ref:refs/heads/main",
"audiences": ["api://AzureADTokenExchange"]
}'
# Give the identity ONLY the role it needs, at the narrowest scope (P04)
az role assignment create --assignee "$APP_ID" --role Contributor \
--scope /subscriptions/<sub>/resourceGroups/<rg>
# GitHub Actions: log in to Azure with OIDC, NO secret in the repo
# permissions: { id-token: write, contents: read }
# - uses: azure/login@v2
# with: { client-id: ..., tenant-id: ..., subscription-id: ... }
# App Service blue-green: swap staging → production (instant cutover + rollback)
az webapp deployment slot swap -g <rg> -n <app> --slot staging --target-slot production
# Azure Pipelines: a condition that only runs a rollback stage on failure
# - stage: rollback
# dependsOn: deploy
# condition: failed()
# List a pipeline's runs / re-run / approve an environment check
az pipelines runs list --output table
gh run list ; gh run rerun <run-id> ; gh run watch <run-id>
5. War story shapes you'll relive
- "The deploy stage just didn't run, no error." → a dependency failed (or skipped), so
its default
succeededcondition wasn't met → it skipped. Skipped ≠ failed. Check the upstream results and the stage'scondition. - "Rollback fired on a green build." → someone wrote
condition: always()where they meantfailed().alwaysruns no matter what;failedonly on a real failure. - "A fork's PR got our cloud credential." → the federated-credential subject was too
broad (matched PRs / any branch). Pin it to
...:ref:refs/heads/mainor...:environment:production; never federatepull_requestfor privileged deploys. - "Our secret leaked in a build log and someone's mining crypto in our sub." → you were storing a long-lived SP secret. The fix isn't "redact logs harder," it's federate — remove the secret entirely.
- "The bad release took prod down for 40 minutes." → no canary, no health gate, no auto- rollback. A canary would've caught it at 5% and rolled back to 0% with nobody paged.
- "It only fails on Tuesdays / when re-run." → non-determinism in the build (unsorted parallelism, time/random without a seed). CI must be reproducible.
6. Vocabulary that signals you've shipped
- DAG /
dependsOn/needs— the pipeline is a graph; stages are nodes, deps are edges. - Condition —
succeeded/always/failed; the gate that decides skip vs run. - Skipped vs blocked vs failed — condition-unmet vs approval-withheld vs ran-and-failed.
- Environment / approval gate — a protected target that pauses for a human or a check.
- Matrix — one job spec → the Cartesian product of variables → N parallel jobs.
- OIDC / workload-identity federation — keyless auth; trust a token's iss/sub/aud, no secret.
- Federated credential — the (issuer, subject, audience) trust object on an Entra identity.
- Subject (
sub) — the branch/environment the token is for; the security boundary. - Blue-green / canary / rolling — swap two slots / ramp+gate / batch-replace.
- Health gate — the automated check that triggers an automatic rollback.
- SBOM / provenance / attestation / SLSA — what's in it / who built it / signed proof / the levels.
- Keyless signing (Sigstore) — sign with the pipeline's OIDC identity, no key to manage.
7. Beginner mistakes that mark you in interviews
- Calling a pipeline "a script" — missing that it's a DAG with conditions and gates.
- Confusing
always()andfailed()(and being surprised when rollback runs on success). - Thinking "skipped" means "failed" — they're different statuses with different causes.
- Storing a long-lived service-principal secret in CI instead of federating with OIDC.
- Setting the federated-credential subject too broad (any branch / PRs) — a fork PR then owns your cloud.
- Treating "deploy" as a copy, with no canary/health gate/rollback path defined up front.
- Saying "it's signed so it's safe" — signed ≠ attributable to my hardened builder (that's provenance + SLSA).
- A matrix that explodes into hundreds of jobs and a surprise bill — prune to what catches bugs.
8. How this phase pays off later
- The DAG + conditions model is how you reason about every pipeline you'll ever debug, including the IaC pipelines from P01/P02 and the container builds from P07.
- OIDC federation is the keyless-auth pattern that also underpins managed identities (P12) and is the answer to half of "how do you secure X without a secret."
- Blue-green / canary + health gates are the reliability primitives that connect straight into P14 (HA, rollback, circuit breakers) and the capstone (P15).
- The supply-chain framing is the security-design language for any architecture review.
Now read the WARMUP slowly, then build the engine. Once run_pipeline, exchange_oidc_token,
and canary_rollout are green, you can explain a deploy that skipped, an auth with no
secret, and an automatic rollback — which is the whole Round-2/Round-5 signal for this phase.
👨🏻 Brother Talk — Phase 08, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. There's a quiet bias in our industry that I want to inoculate you against right now: people think CI/CD is junior work. "The build pipeline" sounds like the thing the new grad sets up so the real engineers can do the real engineering. That bias has ended more careers — and caused more breaches — than almost anything else I can name.
Here's the truth. The pipeline is the most privileged system you own. It holds the
credential that can change your entire cloud. It is the single gate between a developer's
laptop and production. It is, statistically, the thing attackers go after first, because
compromising the build means everything you ship downstream is malicious-but-signed and
nobody notices for months. SolarWinds, Codecov, CircleCI, the xz backdoor — go read what
actually happened in each. Every one of them is a pipeline story. The thing everyone
treats as plumbing is the thing the whole house's security rests on.
So when you understand pipelines at the level this phase teaches — the DAG, the conditions, the keyless auth, the rollout control loop — you are not doing junior work. You are owning the most security-critical distributed system in the org. Carry yourself like it.
Let me give you the mindset shifts, brother to brother.
Stop storing secrets. I mean it — make it a religion. The single most common thing I
see in a security review is a long-lived service-principal secret sitting in a CI vault,
valid for a year, usable from anywhere on earth. It is a loaded gun pointed at your cloud,
24/7, whether or not a build is running. The moment you learn OIDC workload-identity
federation, you get to delete that gun — no secret at rest, a token that lives minutes,
scoped to one branch. When you walk into a design review and say "we don't store cloud
credentials in CI; we federate, and the prod identity only trusts a token from main,"
you sound like someone who's been breached and learned. That one sentence is a level-up.
The subject is the whole game — guard it like a hawk. Everyone gets excited about
"keyless! no secrets!" and then sets the federated-credential subject to match any branch
because it's convenient, and now a stranger can open a pull request from a fork and walk out
with your production credential. I've watched it happen. The narrow subject — one credential
per environment, pinned to exactly main or exactly the production environment — is not
bureaucracy, it's the entire security boundary. When someone says "just make it match any
branch so the feature pipelines work too," that's the moment you earn your salary by saying
no and explaining why. The lab makes you reject the wrong branch by hand precisely so this
becomes instinct.
Build the rollback before the deploy. Always. No exceptions. Here's the thing nobody tells you until you've lived a bad night: the time to design how you roll back is not at 2 a.m. with the error rate climbing and an exec in the channel. It's now, calm, in the ADR. A canary that auto-rolls-back at 5% traffic turns a career-defining outage into a non-event nobody even hears about. The engineers who get promoted aren't the heroes who fix prod at 3 a.m. — they're the ones whose prod didn't go down because they wired the health gate in advance. Be boring on purpose. "We auto-rolled-back, no customer impact" is the most senior sentence you can say.
Learn the execution model so deeply you can debug a skip in your head. "The deploy
didn't run, no error" is the single most common pipeline ticket on earth, and the engineers
who can't reason about it stare at logs for an hour. You'll know in ten seconds: a
dependency wasn't succeeded, so the default condition skipped it — skipped, not failed,
not blocked, three different things. Hold the rule — a stage runs iff every dependency
satisfied its condition and its approval was granted — and you become the person people
bring the weird pipeline behavior to. That reputation compounds.
The honest truth about why this is hard: it's not hard because any one piece is deep. The DAG is just topological sort. The OIDC exchange is just "do these three strings match." The canary is a for-loop with a health check. It's hard because the consequences are severe and the details are unforgiving — the wrong subject, the wrong condition, the missing rollback are each a one-character mistake with a blast radius the size of your company. That's also why it's defensible in an interview: anyone can wire a green pipeline; very few can tell you why the deploy skipped, how the auth works with no secret, and what happens at 5% canary when the error rate spikes. You're learning the second thing.
One last thing, and it's the career frame. The pipeline is where you get to encode your judgment into something hundreds of engineers inherit. A gate you add protects every team. A keyless-auth pattern you establish removes a class of breach for the whole org. A canary template you write means nobody else has to learn the hard way. That's the leverage of a principal — not "I deploy carefully" but "I made it so everyone deploys safely by default, and they don't even have to know how." That's the difference between doing the work and building the platform the work runs on. This phase is you learning to build that platform.
Go build the engine. Make run_pipeline explain a skip, make exchange_oidc_token reject
the wrong branch, make canary_rollout roll back without a human. Then come find me in
Phase 09 — that's where we put the gateway in front of the whole thing.
— your brother 👨🏻
Lab 01 — Pipeline Engine
Phase: 08 — CI/CD Pipelines & Secure Supply Chain | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
A CI/CD pipeline is not a YAML file — it is a DAG executor with a security boundary bolted to the side. This lab builds the three mechanisms a principal must own under the hood: the stage/job dependency graph that decides what runs and what gets skipped when something fails, the OIDC token-exchange that lets a pipeline authenticate to Azure with no stored secret (and rejects the wrong branch), and the rollout controller that does blue-green and canary deploys with an automatic health-gated rollback. Build these and "the pipeline is stuck" / "why did prod not deploy" / "how does keyless auth actually work" stop being mysteries.
What you build
Stage+run_pipeline— a deterministic topological executor for a pipeline DAG. Stages havedepends_onedges, acondition(succeeded/always/failed), and an optionalapprovalgate. A stage isskippedwhen a dependency didn't satisfy its condition,blockedwhen approval is required and not granted, else it runs tosucceeded/failed. Cycles and unknown dependencies raise.exchange_oidc_token+FederatedCredential+subject_matches— the workload-identity-federation decision. A token is exchanged for an Azure access token iff a configured federated credential matches itsiss+sub+aud. Issuer and audience match exactly; subject matches exactly (with one documented trailing-*pattern). Deny by default. A wrong-branchsubis rejected — the security boundary.blue_green_swap+canary_rollout— the deployment-strategy logic. Blue-green returns the slot that ends up live (instant swap, instant rollback). Canary ramps through traffic percentages, calls a health check at each, and rolls back to 0% on the first failing step — no human paged.
Key concepts
| Concept | What to understand |
|---|---|
| The DAG is the pipeline | stages → jobs → steps; dependsOn defines edges; topo order is the run order |
| A stage runs iff... | every dependency satisfied its condition AND any required approval was granted |
| Failure propagates | a failed/skipped/blocked dependency is not succeeded, so succeeded-condition dependents skip |
always vs failed | always runs regardless (notify/cleanup); failed runs only when a dep failed (rollback) |
| Determinism | sorted topo order → same input, same {stage: status} every run; CI must be reproducible |
| OIDC federation | present a short-lived OIDC token; Entra exchanges it by matching iss/sub/aud — no stored secret |
| The subject is the knob | sub pins the branch/environment; the wrong sub is denied — that's why it beats a long-lived secret |
| Blue-green | two slots, instant atomic swap, instant rollback (the idle slot stays warm) |
| Canary | ramp %, gate on health, auto-rollback on the first failing step; limits blast radius |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers — implement these |
solution.py | complete reference; python solution.py runs a worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All 30 tests pass against your implementation.
- You can explain why
test_skip_on_failed_dependencyreturnsskipped(notfailed) fordeploy: asucceededcondition needs all deps green; a failed dep means the condition is unmet, so the stage never runs. - You can explain the difference between
alwaysandfailedconditions, and which one a rollback stage uses vs a notify stage. - You can explain why
test_oidc_reject_wrong_subject_branchis the whole point of OIDC federation: same repo, same issuer, same audience — but a differentsub(branch) is denied, so a PR from a fork or a push to a feature branch cannot mint a prod credential. - You can state, in one sentence, why OIDC federation beats a stored service-principal secret (no long-lived credential to leak; the token is per-run and subject-scoped).
- You can explain why
canary_rolloutreturnsfinal_pct: 0(not the last step) on rollback, andfinal_pct: 100on success regardless of the last step value.
How this maps to real Azure
| Lab piece | Real Azure / GitHub mechanism | Notes & limits |
|---|---|---|
Stage + depends_on | Azure Pipelines stages: with dependsOn:; GitHub Actions jobs.<id>.needs: | both build a DAG and run it in topological order |
condition | Azure Pipelines condition: succeeded()/always()/failed(); GitHub if: success()/always()/failure() | the default is "run iff all dependencies succeeded" |
approval | Azure Pipelines Environments with approval checks; GitHub Environments with required reviewers | a real gate also supports timeouts, business hours, and exclusive locks |
run_pipeline skip/block | the agent never even allocates a job for a skipped stage; a blocked stage shows "waiting" until approved | real gates can expire and auto-reject after N days |
FederatedCredential | an Entra app registration / user-assigned managed identity federated credential (issuer/subject/audience) | max ~20 federated credentials per identity |
exchange_oidc_token | the client_credentials grant with client_assertion_type=...jwt-bearer presenting the CI OIDC token | Entra validates the OIDC token's signature via the issuer's JWKS before this match — the lab models the match, not the crypto |
subject pin | repo:org/name:ref:refs/heads/main (GitHub) / sc://org/project/pipeline (Azure DevOps) | a too-broad subject claim (e.g. matching any branch) is the classic misconfiguration |
blue_green_swap | Azure App Service deployment slot swap | the swap is atomic at the load-balancer; the old version stays warm in the idle slot |
canary_rollout | AKS/Container Apps progressive traffic split (Argo Rollouts / Flagger / built-in revisions) | a real canary also bakes (waits) and watches metrics, not a single boolean |
Extensions
- SLSA provenance: add
attest_provenance(artifact_digest, builder_id)that emits a signed in-toto-style statement, andverify_provenancethat a deploy step calls before promoting — model the "only deploy artifacts our builder produced" gate. - Matrix fan-out: extend
Stageto expand amatrix={"os": [...], "ver": [...]}into one job per combination, all depending on the same upstream — and assert the Cartesian-product count. - Concurrency / fan-in: return not just statuses but a schedule (which stages could run in parallel at each topological "level"), the thing that decides pipeline wall-clock time.
- Real
az: configure a GitHub OIDC federated credential against an Entra app and deploy withazure/login@v2usingpermissions: id-token: write— no secret in the repo. Then break it on purpose by deploying from a non-mainbranch and watch the token exchange fail withAADSTS700213.
Resume / interview bullets
- Built a deterministic CI/CD DAG executor (stages,
dependsOn, conditions, approval gates) that models Azure Pipelines / GitHub Actions execution and failure propagation. - Implemented an OIDC workload-identity-federation token-exchange evaluator that authenticates pipelines to Entra ID with no stored secret and denies tokens whose subject (branch/environment) is not the configured one.
- Built blue-green and health-gated canary rollout controllers with automatic rollback.
Phase 09 — RESTful APIs, JSON & Secure AuthN/AuthZ
Difficulty: ⭐⭐⭐☆☆ (the protocol) → ⭐⭐⭐⭐⭐ (the judgment about status codes, the validation-then-authorization order, and where the gateway sits) Estimated Time: 1 week (12–18 hours) Prerequisites: Phase 03 (OAuth2 / OIDC / JWT — this phase consumes the token that phase taught you to mint and validate) and Phase 00 (control plane vs data plane, identity is the perimeter). Comfortable reading JSON and HTTP. No live API Management instance is required for the lab.
Why This Phase Exists
The JD asks for one thing in plain language: "Develop and integrate RESTful APIs using
JSON and implement secure authentication and authorization mechanisms (OAuth2, OpenID
Connect)." That sentence hides the entire surface area of how the modern web talks to
itself — and the part that separates a senior from a principal is not "can you write a
controller," it is can you reason about the edge: the gateway that every request crosses
before it reaches your code, the contract that says which method is safe to retry, and the
exact moment a request becomes a 401 rather than a 403 rather than a 429.
Phase 03 built the token. This phase builds the thing that checks the token on every
request and decides what happens next — Azure API Management, modeled as the machine it
actually is: a policy pipeline (inbound → backend → outbound → on-error) that matches
the route, validates the JWT, authorizes the scope, enforces the rate limit, and only then
lets the call through. Almost everyone who has shipped an API can describe this vaguely.
Very few can tell you, without hand-waving, why PUT is idempotent and POST is not
(and why that decides whether a client may retry on a timeout), why a missing scope is
403 and a missing token is 401 (and why an SDK that confuses them causes a retry
storm), or how a token bucket computes the Retry-After you owe a throttled client.
Those are not trivia. "We returned 401 on an expired token and the client re-tried the
same expired token forever" is a real outage. "Our retry-on-5xx logic double-charged
customers because POST /charge isn't idempotent" is a real incident. This phase makes you
build the gateway so REST stops being a vibe and becomes a sequence of decisions you can
defend line by line.
What "Principal-Level" Means Here
A senior engineer wires an API behind APIM and it works. A principal understands the edge well enough to:
- Use the right method for the right semantics —
GET(safe, cacheable),PUTandDELETE(idempotent: same call twice = same state),POST(not idempotent: creates),PATCH(partial, not guaranteed idempotent) — and reason from idempotency to "may the client retry this on a timeout?", which is the whole reason the distinction exists. - Return the correct status code under pressure —
2xxsuccess,4xxyou (the client) made a mistake,5xxwe (the server) did; and inside4xxthe trio that gets confused:401(not authenticated — who are you?),403(authenticated, not authorized — not allowed),429(authenticated, allowed, but too fast). The wrong code makes the client do the wrong thing. - Place the gateway correctly and configure its pipeline — products / APIs / operations
in APIM; the
inboundpolicies that authenticate (validate-jwt), authorize (claim checks), throttle (rate-limit-by-key), and transform; thebackendcall; theoutboundreshaping; andon-error— and explain why each policy lives in the section it does. - Separate authentication from authorization at the edge —
validate-jwtproves the token is real, current, and for this API (signature /iss/aud/exp); the scope check (scpdelegated vsrolesapp-only) decides whether this caller may invoke this operation. AuthN is401-or-pass; AuthZ is403-or-pass. Conflating them is the most common edge bug. - Do the rate-limit arithmetic — a token bucket with a
capacity(burst) and arefill_per_sec(sustained rate); on empty, a429with aRetry-Afterthat says exactly how long until one token accrues — and the difference between a rate limit (smooth the burst) and a quota (cap the long-window total for fairness/billing).
Concepts
- REST as resource orientation. A REST API exposes resources (nouns:
/users/{id},/orders) and acts on them with a small fixed set of methods (verbs), rather than RPC-style endpoints (/createUser,/getUserById). The URL names what; the method names the operation; the body and status carry the result. This is whyGET /users/42andDELETE /users/42are the same resource, two operations. - The HTTP methods and their semantics. Safe = no observable state change
(
GET,HEAD,OPTIONS). Idempotent = doing it N times leaves the same state as doing it once (GET,PUT,DELETE,HEAD— and crucially notPOST).PATCHis partial update and is not guaranteed idempotent. The payoff: a client may safely retry an idempotent request after a timeout (it might already have succeeded) but must not blindly retry aPOST(it might create a second resource) — idempotency is the property that makes the network's "did it happen?" ambiguity survivable. - Status codes that mean something.
2xxsuccess (200 OK,201 Created,204 No Content);3xxredirect;4xxclient error (400malformed,401unauthenticated,403unauthorized,404not found,409conflict,415/406content negotiation,422semantic,429too many requests);5xxserver error (500,502,503,504). The split matters for retries: a client may retry5xx(our fault, transient) and429(with backoff) but must not retry4xxlike400/401/403unchanged — the request itself is wrong. 401vs403vs429— the three that decide client behavior.401 Unauthorizedis misnamed: it means un-authenticated — no token, or an invalid/expired one; the client should re-authenticate.403 Forbiddenmeans authenticated but not authorized — a valid token without the required scope; re-authenticating won't help, the client needs more scope/role.429 Too Many Requestsmeans authenticated and authorized but over budget — the client should back off forRetry-Afterseconds. Three different fixes; the gateway must return the code that triggers the right one.- Pagination and content negotiation. Large collections are paged — offset/limit
(simple, drifts under writes) or cursor/keyset (stable, the production default) — with
a
nextlink/cursor and often a total. Content negotiation lets the client ask for a representation (Accept: application/json) and declare what it sent (Content-Type); a mismatch is406/415. JSON is the lingua franca; the contract is the schema. - Azure API Management as the gateway. APIM sits in front of backends and exposes
products (a bundle of APIs with a subscription and policies) made of APIs made of
operations (method + URL template). Its core is the policy pipeline: every request
flows
inbound(where you authenticate, authorize, rate-limit, transform, route) →backend(the call to the origin) →outbound(reshape the response) →on-error(catch failures) — a programmable choke point that turns cross-cutting concerns into declarative policy instead of code in every service. - AuthN vs AuthZ at the edge.
validate-jwtauthenticates: the signature proves the token wasn't forged,issproves Entra issued it,audproves it's for this API (skip this and you're a confused deputy),exp/nbfprove it's current. The scope check authorizes:scp(space-delimited delegated scopes — a user is present) orroles(app roles — a daemon, client-credentials) must contain what the operation requires. AuthN failure →401; AuthZ failure →403. Fail closed: anything you can't prove, deny. - Token-bucket rate limiting. A bucket holds up to
capacitytokens and refills atrefill_per_sec; each request spends one; an empty bucket yields429+ aRetry-Afterof(1 − tokens) / refill_per_sec.capacityis the burst you tolerate;refill_per_secis the sustained rate you guarantee. A quota is the same idea over a long window (calls/day) for fairness and billing rather than burst smoothing.
Labs
Lab 01 — API Gateway: Routing, Scope Authz & Rate Limiting (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the engine APIM runs on every request: a Route(method, path_template, required_scopes) matcher that extracts path params and prefers the most-specific template; an authorize(claims, required_scopes) that returns 401 (no/invalid token) / 403 (valid, insufficient scope) / 200, failing closed; a continuous-refill TokenBucket (and per-client RateLimiter) returning (allowed, retry_after); and a handle(...) pipeline that runs match (404) → authorize (401/403) → rate-limit (429 + Retry-After) → backend (200) in that order |
| Concepts | Path-template matching + param extraction + most-specific-wins; method dispatch; the 401/403/429 distinction; fail-closed scope containment (scp); token-bucket math + Retry-After; per-client bulkheading; the pipeline order as the security contract |
| Steps | 1. Route + match_route (params, most-specific, 404); 2. authorize (the three statuses, fail closed); 3. TokenBucket.allow (continuous refill, retry_after); 4. RateLimiter (per-client buckets); 5. handle (the four-stage pipeline, order pinned by tests) |
| How to Test | pytest test_lab.py -v — 29 tests: param extraction, most-specific-wins, method dispatch, 404; 401 vs 403 vs 200 for the scope cases; bucket allows to capacity then 429 with correct retry_after, refills after injected elapsed time, caps at capacity; and the pipeline ordering (404 before 401, 401 before 429), per-client isolation, determinism |
| Talking Points | "Walk me through what a gateway does to a request, in order." / "401 vs 403 — when does each fire and why does it matter operationally?" / "Implement a token bucket; what should Retry-After say?" / "Why route before auth, and auth before rate-limit?" |
| Resume bullet | Built an API-gateway engine (method + path-template routing with most-specific match and param extraction, OAuth2 scope authorization with the 401/403 fail-closed distinction, and a continuous-refill token-bucket rate limiter with Retry-After) modeling the Azure API Management inbound policy pipeline |
→ Lab folder: lab-01-api-gateway/
Integrated-Scenario Suggestions (carried through the whole track)
The phases compound. Keep these in mind — each plugs the gateway into a larger system:
- SPA → APIM → API → downstream API. A browser app got an access token via
auth-code + PKCE (Phase 03); APIM
validate-jwtchecksaud= your API andscpcarries the user's scopes; an insufficient scope is a403, an expired token a401. This is Phase 03'svalidate_jwtrunning as a policy on every request. (→ P03) - Daemon → APIM → API. A nightly job uses client-credentials; its token carries
roles, notscp, so the operation authorizes on app roles. The grant choice (P03) determines which claim the gateway authorizes on. (→ P03, P12) - Rate limiting as fairness and as defense.
rate-limit-by-keyper subscription smooths bursts and bulkheads tenants; aquota-by-keycaps the monthly total for billing; a flood gets429 + Retry-After, and a circuit breaker downstream (P14) sheds load when the backend is sick. The gateway is the first place reliability is enforced. (→ P14) - Managed Identity behind the gateway. APIM calls a backend that authenticates with a Managed Identity (P12) — no secret on the wire — and the backend reads Key Vault for anything else. The gateway authenticates the caller; MSI authenticates the backend. (→ P12)
- Observability of the edge. Every
401/403/429/5xxis a signal; APIM emits to Application Insights and Log Analytics, and a KQL query (P13) over the gateway logs is how you find "tenant X is getting 90%403s — they're missing a scope." (→ P13)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- The gateway is a pipeline, and the order is the policy. Match (
404) before auth (no operation to authorize), auth (401/403) before rate-limit (429) (don't let probing cost rate budget), then the backend (200). Reorder it and you have a security or a fairness bug, not just a style choice. 401≠403≠429, and each triggers a different client action.401re-authenticate,403get more scope,429back off. The whole reason status codes are standardized is so a generic client does the right thing without reading your docs — pick the wrong one and you've broken that contract.- Idempotency decides retryability.
PUT/DELETE/GETare idempotent and safe to retry on a timeout;POSTis not. Designing your write as idempotent (or giving it an idempotency key) is what makes the network's "did it happen?" ambiguity survivable. - Authenticate, then authorize, and fail closed. Prove the token is real, current, and
for you (
validate-jwt); then check the scope. Anything you can't prove denies. Skipaudand you're a confused deputy; conflate401and403and clients retry forever. - Rate-limit math is arithmetic, not magic.
capacity= burst,refill= sustained,Retry-After = (1 − tokens) / refill. Quota is the same idea over a long window. Put the numbers in the design review, not in the post-incident review.
Deliverables Checklist
-
Lab 01 implemented; all 29 tests pass against
solution.pyand yourlab.py - You can recite the gateway pipeline order and say what each stage's failure code is
-
You can state the
401/403/429rule and the client action each should trigger - You can list which HTTP methods are idempotent and explain why that decides retryability
-
You can implement a token bucket and compute the
Retry-Afterit owes a client -
You can explain
scpvsrolesauthorization and which grant produces each (ties to P03) -
You can place
validate-jwt,rate-limit-by-key, and routing in the right APIM pipeline section and say why each belongs there
Warmup Guide — RESTful APIs, JSON & Secure AuthN/AuthZ
Zero-to-principal primer for Phase 09: REST as resource orientation, the HTTP methods and the idempotency that decides retryability, the status codes that mean something (and the
401/403/429trio everyone gets backwards), pagination and content negotiation, Azure API Management and its policy pipeline, authentication-vs-authorization at the edge (validate-jwtthen scope), and the token-bucket math behind a rate limiter. Every concept goes from what it is to why it exists to the mechanism under the hood (diagrams, tables, code, math) to production significance to the misconceptions that get people paged.
Table of Contents
- Chapter 1: REST — Resources, Not Procedures
- Chapter 2: HTTP Methods, Safety, and Idempotency
- Chapter 3: Status Codes That Mean Something
- Chapter 4: JSON, Pagination, and Content Negotiation
- Chapter 5: Azure API Management — The Gateway
- Chapter 6: The Policy Pipeline — Inbound, Backend, Outbound, On-Error
- Chapter 7: Authentication vs Authorization at the Edge
- Chapter 8:
401vs403vs429— The Three That Decide Client Behavior - Chapter 9: Token-Bucket Rate Limiting — The Math
- Chapter 10: Route Matching — Templates, Params, and Specificity
- Chapter 11: The Pipeline Order Is the Policy
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: REST — Resources, Not Procedures
From zero. Before REST, web APIs were RPC — you called a procedure by name over
HTTP: POST /createUser, POST /getUserById, POST /deleteUser. Every endpoint was a new
verb, every team invented its own, and nothing was uniform. REST (Representational State
Transfer, Roy Fielding's 2000 dissertation) inverts this: you model the world as
resources — nouns with stable URLs — and act on them with a small fixed set of
methods. /users/{id} is a resource; GET reads it, PUT replaces it, DELETE removes
it, POST /users creates one. The URL says what; the method says the operation; the
status code and body say the result.
Why it exists. Uniformity is the whole point. Because every REST API uses the same nine
methods and the same status-code families, a client, a cache, a proxy, and a gateway can all
reason about any REST API without reading its docs: a cache knows GET is cacheable; a
client knows it may retry a PUT; a gateway knows 429 means throttle. RPC throws all of
that away — the verb POST /doTheThing is opaque to every layer but the one that implemented
it. REST trades a little expressiveness for an enormous amount of shared, machine-readable
semantics, and that trade is why it won.
Mechanism. A REST request is METHOD URL HEADERS [BODY]; a response is STATUS HEADERS [BODY]. The contract lives in three places: the URL template (/users/{id} — the
resource shape), the method (the operation's semantics), and the media type (Content- Type: application/json — the representation). A well-designed API is "obvious": you can guess
GET /orders/99/items/3 without asking.
Misconception. "REST means JSON over HTTP." No — REST is the constraints (resources,
uniform methods, statelessness, cacheability, HATEOAS); JSON is just the most common
representation. You can do REST with XML, and you can do non-REST (RPC) with JSON — which is
what most "REST APIs" that have POST /api/doStuff endpoints actually are.
Chapter 2: HTTP Methods, Safety, and Idempotency
From zero. The methods are not interchangeable; each carries a promise about what it does to server state. Two properties matter more than any other:
- Safe — the method has no observable effect on server state.
GET,HEAD,OPTIONSare safe: a crawler can fetch them freely. (AGETthat deletes something is a famous bug class — search engines have wiped admin panels by following "delete" links that wereGETs.) - Idempotent — performing it N times leaves the resource in the same state as performing it once. This is the property that decides retryability.
| Method | Safe | Idempotent | Meaning | May a client retry on timeout? |
|---|---|---|---|---|
GET | ✅ | ✅ | read a resource | yes |
HEAD | ✅ | ✅ | read headers only | yes |
PUT | ❌ | ✅ | replace a resource wholesale | yes — second PUT = same state |
DELETE | ❌ | ✅ | remove a resource | yes — already-gone stays gone |
POST | ❌ | ❌ | create / process (non-idempotent) | no — may create a duplicate |
PATCH | ❌ | ❌ (not guaranteed) | partial update | only if you made it idempotent |
Why idempotency is the whole game. Networks are unreliable: a client sends a write, the
server processes it, the response is lost. Did it happen? The client cannot tell. If the
write was idempotent, the client simply retries — worst case it re-applies the same state,
no harm. If it was a POST /charge, retrying might charge the customer twice. So
idempotency is not academic: it is the property that makes the network's fundamental
ambiguity ("did my request succeed?") survivable. Principals design writes to be idempotent
— a PUT to a known id, or a POST with an idempotency key the server dedups on — so
clients can retry fearlessly. (Azure's whole control plane is built on this: every ARM write
is an idempotent PUT, Phase 01.)
PATCH is the trap. PATCH is partial update, and the RFC does not guarantee it's
idempotent. PATCH {"balance": "+10"} (relative) applied twice adds 20; PATCH {"name": "Ann"} (absolute) applied twice is fine. If you offer PATCH, you decide and document which
it is — and most clients should not blindly retry it.
Chapter 3: Status Codes That Mean Something
From zero. The status code is a three-digit machine-readable verdict, grouped by first digit:
1xx informational (rare; 100 Continue, 101 Switching Protocols)
2xx success 200 OK · 201 Created · 202 Accepted · 204 No Content
3xx redirection 301 Moved · 304 Not Modified (caching)
4xx CLIENT error YOU sent something wrong — do not retry unchanged
5xx SERVER error WE failed — usually transient, retry with backoff
The split that decides retries. The 4xx/5xx boundary is the single most important
distinction: a 4xx says the request itself is wrong (a bad body, a missing token, an
unknown route) — retrying the same request gets the same error, so a smart client fixes
the request or gives up. A 5xx says the server failed (a crash, a dependency timeout) —
which is usually transient, so a smart client retries with exponential backoff (Phase
14). Getting this wrong is expensive in both directions: returning 500 for a malformed body
makes clients hammer you with a request that can never succeed; returning 400 for a
transient backend hiccup makes them give up on a request that would have worked.
The 4xx codes you'll actually return:
| Code | Name | When |
|---|---|---|
400 | Bad Request | malformed syntax, unparseable JSON, missing required field |
401 | Unauthorized | un-authenticated — no/invalid/expired token (see Ch. 8) |
403 | Forbidden | authenticated but not authorized — wrong scope/role |
404 | Not Found | no such resource / route |
405 | Method Not Allowed | route exists, but not for this method |
406 / 415 | Not Acceptable / Unsupported Media Type | content-negotiation mismatch |
409 | Conflict | the write conflicts with current state (e.g. optimistic-concurrency) |
422 | Unprocessable Entity | syntactically valid but semantically wrong |
429 | Too Many Requests | rate/quota exceeded — back off (see Ch. 8–9) |
Misconception. "200 with {"error": ...} is fine." No — burying errors in a 200
body breaks every layer that reads the status: monitoring thinks you're healthy, the gateway
won't retry, the cache caches the error. The status code is the protocol-level truth; use
it.
Chapter 4: JSON, Pagination, and Content Negotiation
JSON is the lingua franca: a Content-Type: application/json body whose contract is a
schema (OpenAPI / JSON Schema). The principal discipline is to treat that schema as a
versioned contract — additive changes (new optional field) are safe; removing a field or
tightening a type is a breaking change that needs a new version (/v2/...) or a
deprecation window, exactly like the schema-compatibility rules in a data platform.
Pagination. You never return a million rows in one response. Two strategies:
- Offset / limit —
GET /users?offset=40&limit=20. Simple, but drifts: if rows are inserted/deleted between pages, you skip or repeat items, and deep offsets are slow (the DB scans and discardsoffsetrows). - Cursor / keyset —
GET /users?after=<opaque_cursor>&limit=20, where the cursor encodes "the last key I saw." Stable under concurrent writes and O(1) to resume — the production default. The response carries anextcursor (often as a link), and the client follows it until it's absent.
Content negotiation. The client says what representation it wants (Accept: application/json) and what it sent (Content-Type: application/json); the server picks the
best it can produce. A Content-Type the server can't parse is 415 Unsupported Media
Type; an Accept it can't satisfy is 406 Not Acceptable. This is how one URL can
serve JSON to an app and CSV to a spreadsheet.
Chapter 5: Azure API Management — The Gateway
From zero. An API gateway is a reverse proxy that sits in front of one or more backend services and is the single front door every client request passes through. Azure API Management (APIM) is Azure's managed gateway. Why have one at all? Because there are cross-cutting concerns — authentication, authorization, rate limiting, caching, transformation, logging, versioning — that every backend needs, and you do not want to re-implement them (subtly differently, with subtly different bugs) in every microservice. The gateway centralizes them: write the policy once, at the edge, and every backend inherits it.
The APIM object model (learn these nouns; interviewers use them):
Product ── a bundle of APIs with a subscription (key) and product-level policies
└─ API ── a set of operations sharing a base path and backend (e.g. the "Users API")
└─ Operation ── one method + URL template (e.g. GET /users/{id})
└─ Policy ── the inbound/backend/outbound/on-error rules that run for it
A subscription (and its subscription key) is how a consumer is identified and metered; a product decides what's bundled and gated. Policies can be attached at every level (global → product → API → operation) and they compose — operation policies run inside API policies inside product policies, like nested middleware.
Why it exists / production significance. The gateway is where you enforce the things that must be uniform and non-bypassable: no backend should ever see an unauthenticated request, no tenant should be able to flood another, and no client should depend on a backend's internal URL. Centralizing that at APIM means a security fix (e.g. tightening JWT validation) ships once and protects everything — and a backend that someone forgot to secure is still protected because traffic can't route around the gateway.
Misconception. "The gateway is just a proxy." It's a programmable proxy: the policy pipeline (next chapter) is a small language for rewriting, authenticating, throttling, and routing every request and response — far more than forwarding bytes.
Chapter 6: The Policy Pipeline — Inbound, Backend, Outbound, On-Error
Mechanism. Every request through APIM runs a four-section pipeline, and which section a policy lives in is part of its meaning:
┌──────────────────────────────────────────────┐
request ──▶│ INBOUND authenticate (validate-jwt), │
│ authorize (claim checks), │
│ rate-limit (by-key), transform, │
│ cache-lookup, route/rewrite │
└───────────────────────┬──────────────────────┘
│ (if not short-circuited)
┌───────────────────────▼──────────────────────┐
backend ──▶│ BACKEND the call to the origin server │
│ (set backend, mTLS, MSI, timeout) │
└───────────────────────┬──────────────────────┘
│
┌───────────────────────▼──────────────────────┐
response ◀──│ OUTBOUND reshape response, set headers, │
│ cache-store, strip internal fields │
└──────────────────────────────────────────────┘
┌──────────────────────────────────────────────┐
error ◀──│ ON-ERROR catch anything thrown above; │
│ shape the error response │
└──────────────────────────────────────────────┘
In real APIM this is XML:
<policies>
<inbound>
<base />
<validate-jwt header-name="Authorization" failed-validation-httpcode="401">
<openid-config url="https://login.microsoftonline.com/{tenant}/v2.0/.well-known/openid-configuration" />
<audiences><audience>api://my-api</audience></audiences>
<issuers><issuer>https://login.microsoftonline.com/{tid}/v2.0</issuer></issuers>
<required-claims>
<claim name="scp" match="all"><value>users.read</value></claim>
</required-claims>
</validate-jwt>
<rate-limit-by-key calls="10" renewal-period="1" counter-key="@(context.Subscription.Key)" />
</inbound>
<backend><forward-request /></backend>
<outbound><base /></outbound>
<on-error><base /></on-error>
</policies>
The key insight: inbound policies run in document order, and an inbound policy can
short-circuit the whole pipeline (a failed validate-jwt returns 401 and the backend is
never called). That ordering is the gateway's security and fairness logic — which is
exactly what the lab's handle function models in Python.
Chapter 7: Authentication vs Authorization at the Edge
From zero — the two questions. Authentication asks "who are you?"; authorization asks "are you allowed to do this?" They are different checks, run in order, and they fail with different status codes. Conflating them is the most common edge bug.
AuthN — validate-jwt (Phase 03's validator, run as a policy). The gateway proves the
bearer token is real and meant for it, in order:
- Signature — recompute/verify with the issuer's public key (selected by the token's
kidfrom the issuer's JWKS). Until this passes, the payload is attacker-controlled JSON — read nothing from it. iss— the token was issued by your Entra tenant, not some other issuer.aud— the token's audience is this API (api://my-api). Skip this and you're a confused deputy: you'd accept a perfectly-signed token Entra minted for a different service.exp/nbf— the token is currently valid (within clock-skew leeway).
Any failure here is 401 — the token did not authenticate. Pass, and you know who the
caller is.
AuthZ — the scope/role check. Now decide if this caller may invoke this operation:
scp(space-delimited) — delegated scopes: a user is present and consented (e.g."users.read orders.write"). Produced by the auth-code + PKCE grant.roles— app roles: the application itself was granted them (no user). Produced by the client-credentials grant.
The operation requires a set of scopes; authorization passes iff required ⊆ granted. A
failure here is 403 — authenticated, but not authorized.
┌─────────────┐ no/invalid token ┌──────┐
token ─▶│ validate-jwt│────────────────────▶│ 401 │
└──────┬──────┘ └──────┘
│ valid (we know who)
┌──────▼──────┐ scp ⊉ required ┌──────┐
│ scope check │────────────────────▶│ 403 │
└──────┬──────┘ └──────┘
│ scp ⊇ required
┌──▼──┐
│ ok │ → rate limit → backend
└─────┘
Fail closed. The default at every step is deny. No token, malformed token, can't reach
JWKS, missing the claim you authorize on — all deny. "Allow on doubt" is how breaches happen.
The lab's authorize encodes exactly this: not-a-dict / not-valid / no scp → 401;
scp missing a required scope → 403; otherwise 200.
Chapter 8: 401 vs 403 vs 429 — The Three That Decide Client Behavior
This is the chapter interviewers live in, because the three codes look similar and trigger three different client behaviors — and a client that gets the code wrong does the wrong thing, sometimes catastrophically.
| Code | Literal meaning | Real meaning | Correct client action |
|---|---|---|---|
401 | Unauthorized | un-authenticated — no/invalid/expired token | re-authenticate (get a fresh token) |
403 | Forbidden | authenticated, not authorized — valid token, wrong scope | acquire more scope/role (re-auth won't help) |
429 | Too Many Requests | authenticated + authorized, over budget | back off for Retry-After seconds, then retry |
Why the distinction is load-bearing:
- Return
403when you meant401(e.g. on an expired token) and a smart client won't refresh its token — it'll assume it lacks permission and give up, or escalate to a human. Return401when you meant403and the client will refresh its token and retry the same forbidden call forever — a refresh storm against Entra and a hot loop against you. This exact bug has caused production incidents at large shops. - Return a plain
403/401when you meant429and the client won't back off — it treats throttling as a permanent failure.429with aRetry-Afteris a cooperative signal: "you're fine, just slow down for N seconds." A well-behaved client honors it and the system self-stabilizes; a429withoutRetry-Afterleaves the client guessing (usually retrying too fast and making it worse).
Mnemonic. 401 = "I don't know you." 403 = "I know you, the answer's no." 429 =
"I know you, you're allowed, but not this fast." Three different fixes; the gateway's job is
to return the one that triggers the right fix.
Chapter 9: Token-Bucket Rate Limiting — The Math
From zero — why limit at all. A backend has finite capacity. Without a limiter, one
buggy client (or one noisy tenant, or one attacker) can consume it all and starve everyone
else — the noisy-neighbor problem. A rate limiter caps how fast each caller may go, so a
flood from one client gets 429'd while everyone else sails through. It is simultaneously a
fairness mechanism and a defense mechanism.
The token bucket — the canonical algorithm. Picture a bucket that holds up to
\( C \) tokens (the capacity, i.e. the burst you tolerate) and is refilled at a steady
\( r \) tokens per second (the sustained rate you guarantee). Each request must take one
token; if the bucket is empty, the request is denied (429).
The bucket is described by two numbers and updated lazily — there's no background timer; you compute the refill on each request from the elapsed time. Let \( t_{\text{last}} \) be the last time we touched the bucket and \( n \) the token count then. At a new time \( t \):
$$ n' \;=\; \min\!\bigl(C,\; n + (t - t_{\text{last}})\cdot r\bigr) $$
The \( \min \) with \( C \) is the cap: you cannot bank unused rate beyond the burst size — idle for an hour and you still only get \( C \) tokens, not 3600r. Then:
$$ \text{allow} \;=\; \begin{cases} \text{True}, & n' \ge 1 \quad(\text{spend one: } n'' = n' - 1) \\[4pt] \text{False}, & n' < 1 \end{cases} $$
And when denied, the client deserves to know exactly how long until it can try again — the time for one full token to accrue:
$$ \text{Retry-After} \;=\; \frac{1 - n'}{r} $$
Worked example (the lab's numbers). Capacity \( C = 2 \), refill \( r = 1 \)/s, bucket starts full:
t=0.0 n=2.0 → allow, n=1.0
t=0.0 n=1.0 → allow, n=0.0
t=0.0 n=0.0 → DENY. Retry-After = (1 − 0)/1 = 1.0 s → 429, Retry-After: 1
t=1.0 n=min(2, 0 + 1·1)=1.0 → allow, n=0.0
And a partial-token case: \( C=1,\ r=2 \)/s, spend the token at \( t=0 \), ask again at
\( t=0.25 \): \( n' = \min(1, 0 + 0.25\cdot 2) = 0.5 \), denied, \( \text{Retry-After}
= (1 - 0.5)/2 = 0.25 \) s. (This is test_bucket_retry_after_partial_token.)
Rate limit vs quota. A rate limit (the bucket: calls per second) smooths bursts and
protects the backend right now. A quota (calls per day/month) caps the long-window
total for fairness and billing ("your tier gets 1M calls/month"). Both return 429 when
exceeded, but they answer different questions — a client can be under its monthly quota and
still get 429'd for a one-second burst, and vice-versa. APIM has both: rate-limit-by-key
and quota-by-key.
Distributed reality. APIM runs a fleet of gateway nodes, so the bucket state is shared/ replicated and necessarily approximate — a global "10/s" might briefly allow 12 across nodes. The lab models the exact single-node bucket (which is the algorithm you must know); the distributed version is the same math with eventually-consistent counters.
Chapter 10: Route Matching — Templates, Params, and Specificity
Mechanism. A gateway holds a table of (method, path_template) operations and, for each
request, must find the one that matches. The template /users/{id} is split into segments
("users", "{id}"); the request /users/42 into ("users", "42"). They match iff:
- the methods are equal (
GET /ordersandPOST /ordersare different operations — method is part of the key), and - the segment counts are equal (
/users/42/ordersdoes not match/users/{id}), and - each template segment matches: a literal (
users) must equal the actual segment; a parameter ({id}) matches any single non-empty segment and binds its name (id → "42").
Most-specific-wins. Two templates can both match one path: /users/me and /users/{id}
both match GET /users/me. The gateway must pick the most specific — the one with the
most literal segments — so a concrete operation (/users/me, the current user) shadows
the generic parameterized one (/users/{id}). The tie-break is "literal count, then segment
count," and the lab sorts candidates by exactly that key. Getting this wrong means
/users/me routes to the by-id handler with id="me" — a real, subtle routing bug.
request: GET /users/me
/users/{id} → matches, specificity (literals=1, segs=2)
/users/me → matches, specificity (literals=2, segs=2) ← wins (more literals)
No candidate matches → None → the gateway returns 404.
Chapter 11: The Pipeline Order Is the Policy
The lab's handle runs four stages, and the order is not arbitrary — it is the security
and fairness policy:
1. match_route no route? → 404 (short-circuit)
2. authorize no/invalid token? → 401
valid, wrong scope? → 403
3. rate-limit bucket empty? → 429 + Retry-After
4. backend all clear → 200
Why 404 before 401. If no route matches, there is no operation to authorize against
— required_scopes is undefined. So routing must come first; a request to a nonexistent path
is 404, full stop, with or without a token. (There's a subtle information-disclosure debate
— some APIs return 404 for unauthorized access to existing resources to avoid confirming
they exist — but the structural reason is simpler: you can't authorize what you can't
match.)
Why 401/403 before 429. Authorization runs before rate limiting for two reasons.
First, don't let unauthorized probing cost rate budget: if rate-limit came first, an
attacker with no token could drain a client's bucket (or, if the bucket is shared, deny
service) purely by spamming requests that were going to be 401'd anyway. Second, cost
attribution: a 401 tells the attacker "you need a token," a 429 tells them "you're going
too fast" — you want the honest signal (401) for an unauthenticated caller, and you only
spend the (more expensive to compute, and budget-consuming) rate check on callers who've
proven they're real. The lab pins this with test_pipeline_401_before_429.
Why the backend is last. The backend is the expensive, stateful thing you're protecting.
Every cheap rejection (404, 401, 403, 429) that happens before the backend call is a
backend request you didn't have to make — the gateway is a shield, and the order is the
shield's logic.
Lab Walkthrough Guidance
Lab 01 — API Gateway, suggested order (matches the file top-to-bottom):
Route+match_route(Ch. 10). Validate the template; split into segments; match method + arity + each segment; extract params; among matches return the most-specific (most literal segments).Noneon no match. Test param extraction, most-specific-wins, method dispatch, and404.authorize(Ch. 7–8). Fail closed: not-a-dict / not-valid/ noscp→401;scpnot a superset ofrequired_scopes→403; else200. Test all three, including the superset-is-fine case and the "needs two scopes, has one"403.TokenBucket.allow(Ch. 9). Lazy refillmin(capacity, tokens + elapsed·refill); reject a backwardnow; consume one if≥ 1; elseretry_after = (1 − tokens)/refill. Test allow-to-capacity-then-429, the partial-tokenretry_after, refill after elapsed time, and the cap.RateLimiter— one bucket per client, lazily created. Test per-client isolation (Alice draining doesn't affect Bob).handle(Ch. 11). The four-stage pipeline; pin the order with the404-before-401and401-before-429tests; emitRetry-After(whole seconds, rounded up, min 1) on429.
Success Criteria
You are ready for the next phase when you can, from memory:
- Explain REST vs RPC and why uniform methods give every layer free, doc-less semantics.
- List which HTTP methods are safe and idempotent, and explain why idempotency decides whether a client may retry on a timeout.
- Name the status-code families and the
4xx/5xxretry rule. - State the
401/403/429distinction and the client action each should trigger, and give the production failure mode of getting401-vs-403backwards. - Draw the APIM policy pipeline (
inbound → backend → outbound → on-error) and placevalidate-jwt,rate-limit-by-key, and routing in the right section. - Separate authentication (
validate-jwt: signature/iss/aud/exp) from authorization (scp/roles), and explainscp-vs-roles(delegated vs app, by grant). - Implement a token bucket and compute its
Retry-After, and distinguish rate-limit from quota. - Justify the gateway pipeline order (
404before401,401before429).
Interview Q&A
Q: A client sends a write, the server processes it, but the response times out. What
should the client do?
It depends entirely on idempotency. If the write was idempotent — a PUT to a known id, a
DELETE, or a POST carrying an idempotency key the server dedups on — the client safely
retries: worst case it re-applies the same state. If it was a plain non-idempotent POST
(POST /charge), the client must not blindly retry — it might create a second resource or
double-charge. This is the reason idempotency matters: the network can't tell the client
whether the request succeeded, and idempotency is what makes that ambiguity survivable. As a
principal I design writes to be idempotent precisely so retries are safe by default.
Q: Walk me through what an API gateway does to a request, in order, and what each stage
returns on failure.
Four stages. Route match — find the operation by method + path template; no match is a
404, and it runs first because there's no operation to authorize against otherwise.
Authorize — validate-jwt (signature, iss, aud, exp) authenticates: failure is
401; then the scope check (scp ⊇ required) authorizes: failure is 403. Rate limit —
a token bucket per caller; empty bucket is 429 with a Retry-After. Backend — only a
fully-authorized, in-budget request reaches the origin, returning 200. The order is the
policy: 404 before auth, auth before rate-limit so probing can't cost rate budget, backend
last because it's the expensive thing the gateway shields.
Q: 401 vs 403 — when does each fire, and why does it matter operationally?
401 means un-authenticated — no token, or an invalid/expired one; the literal name
"Unauthorized" is a historical misnomer. The client should re-authenticate. 403 means
authenticated but not authorized — a perfectly valid token that simply lacks the required
scope or role; re-authenticating won't help, the client needs more scope. Operationally the
distinction is critical: if you return 401 when you meant 403, a smart client refreshes
its token and retries the same forbidden call forever — a refresh storm against Entra and
a hot loop against you. If you return 403 when you meant 401 (e.g. on an expired token),
the client won't refresh and gives up on a call that a fresh token would have allowed. I've
seen both cause incidents.
Q: Implement a token bucket. What should Retry-After say when you reject?
A bucket holds up to capacity tokens (the burst), refills continuously at refill_per_sec
(the sustained rate), and each request spends one. I refill lazily on each call: tokens = min(capacity, tokens + elapsed·refill). If tokens ≥ 1, consume one and allow; else deny.
On denial, Retry-After should be exactly the time for one token to accrue:
(1 − tokens) / refill_per_sec, rounded up to whole seconds with a floor of 1. That precise
value lets a well-behaved client back off exactly long enough and no longer — it
self-stabilizes the system. The min(capacity, …) cap is the subtle part: you can't bank
idle time into unlimited burst.
Q: Rate limit vs quota — what's the difference, and why have both?
A rate limit caps short-term speed (calls per second) — it smooths bursts and protects the
backend's instantaneous capacity; it's the token bucket. A quota caps the long-window total
(calls per day/month) — it's about fairness and billing, e.g. "your tier gets 1M
calls/month." Both return 429, but they answer different questions: a client can be well
under its monthly quota and still get rate-limited for a one-second spike, and can be slow
enough to never hit the rate limit yet exhaust its monthly quota by mid-month. APIM exposes
rate-limit-by-key and quota-by-key for exactly this — you usually want both.
Q: Where does authentication end and authorization begin at the gateway, and why is the
order fixed?
Authentication is validate-jwt: prove the token is real (signature), from your tenant
(iss), for this API (aud — skip it and you're a confused deputy accepting tokens minted
for another service), and current (exp/nbf). That answers who. Only after it passes do
I read the scp/roles claims to authorize what — does this caller hold the scopes this
operation needs. The order is fixed because you must never read a claim from a token you
haven't verified — until the signature checks out, the whole payload is attacker-controlled
JSON. AuthN failure is 401, AuthZ failure is 403, and both fail closed.
References
- RFC 9110 — HTTP Semantics (2022) — the authoritative spec for methods, idempotency
(§9.2.2), safe methods (§9.2.1), and status codes (§15);
401§15.5.2,403§15.5.4,404§15.5.5,429(RFC 6585 §4),Retry-After§10.2.3. - RFC 6585 — Additional HTTP Status Codes (2012) —
429 Too Many Requestsand its intended use withRetry-After. - RFC 6749 — The OAuth 2.0 Authorization Framework (2012) — §3.3 scope (the space-
delimited
scpconvention), the grant types, and §5.2 theinvalid_token/insufficient_scopeerrors behind401/403. - RFC 6750 — OAuth 2.0 Bearer Token Usage —
WWW-Authenticateand theerror="invalid_token"/error="insufficient_scope"semantics the gateway emits. - Microsoft Learn — Azure API Management policies — the
validate-jwt,rate-limit-by- key,quota-by-key, and policy-pipeline (inbound/backend/outbound/on-error) reference. - Microsoft Learn — Protect an API in APIM using OAuth 2.0 and Microsoft Entra ID — the
end-to-end
validate-jwt+ scope-check configuration this lab models. - Microsoft identity platform —
scp(delegated) vsroles(app) claims — which grant produces which claim, and how an API authorizes each. - Roy Fielding, Architectural Styles and the Design of Network-based Software Architectures (2000), Ch. 5 — the original definition of REST and its constraints.
- The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 09: RESTful APIs, JSON & Secure AuthN/AuthZ
Read this if: you can build a REST controller but "design our API edge" means something vaguer than it should. The gateway, the status codes, the idempotency, the rate limiter — they're a small set of sharp rules, and this is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the gateway.
0. The 30-second mental model
Every request to a managed API crosses a gateway first, and the gateway makes the same
four decisions in the same order: does this route exist (404) → who is this and may
they (401/403) → are they over budget (429) → call the backend (200). One
sentence to tattoo: the gateway is a pipeline, and the order is the security policy —
route before auth (nothing to authorize otherwise), auth before rate-limit (don't let
probing cost rate budget), backend last (it's the expensive thing you're shielding).
1. The methods, one breath
GET safe, idempotent, cacheable read
HEAD safe, idempotent read headers
PUT idempotent (NOT safe) replace wholesale → retry-safe
DELETE idempotent (NOT safe) remove → retry-safe
POST neither create/process → NOT retry-safe
PATCH not guaranteed idempotent partial update → retry only if you made it so
Idempotent = doing it twice == doing it once == safe to retry on a timeout. That single
property is why PUT-to-a-known-id and POST-with-an-idempotency-key are how principals
design writes — so a client can retry through the network's "did it happen?" fog.
2. The status codes to tattoo on your arm
| Code | Means | Client should |
|---|---|---|
200/201/204 | OK / Created / No Content | proceed |
400 | malformed request | fix the request, don't retry as-is |
401 | un-authenticated (no/bad/expired token) | re-authenticate |
403 | authenticated, not authorized (wrong scope) | get more scope (re-auth won't help) |
404 | no such route/resource | check the URL |
409 | conflict (optimistic concurrency) | re-read, re-apply |
415/406 | wrong Content-Type / Accept | fix media type |
429 | over rate/quota | back off Retry-After seconds |
5xx | server failed (transient) | retry with exponential backoff (P14) |
The one rule under all of it: 4xx is your fault (don't retry unchanged), 5xx is our
fault (retry with backoff), 429 is "slow down" (back off, then retry).
3. 401 vs 403 vs 429 — the thing they actually test
401 "I don't know you." → refresh your token
403 "I know you. The answer's no." → ask for more scope
429 "I know you, you're allowed, → wait Retry-After, retry
but not this fast."
Get 401/403 backwards and you cause an incident: return 401 on a forbidden call and
a smart client refreshes its token and retries the forbidden call forever (refresh storm
- hot loop). Return
403on an expired token and the client gives up instead of refreshing.
4. The APIM nouns (use them and you sound like you've shipped one)
Product = bundle of APIs + a subscription (key) + product policies
API = operations sharing a base path + backend
Operation = one METHOD + URL template (GET /users/{id})
Policy = inbound → backend → outbound → on-error
Policies compose top-down (global → product → API → operation), like nested middleware. The
inbound section is where 90% of the action is: validate-jwt, rate-limit-by-key,
transform, route. An inbound policy can short-circuit the whole pipeline (a failed
validate-jwt returns 401 and the backend is never called).
5. The numbers you'll actually use
| Thing | Number / rule |
|---|---|
| Idempotent methods | GET HEAD PUT DELETE (+ OPTIONS/TRACE); not POST, not PATCH |
Retry-After on 429 | (1 − tokens) / refill_per_sec, rounded up, min 1 s (RFC 9110 §10.2.3) |
| Token bucket | tokens = min(capacity, tokens + elapsed·refill); spend 1 per call |
capacity vs refill | capacity = burst you tolerate; refill = sustained rate you guarantee |
| Rate-limit vs quota | rate-limit = calls/second (burst); quota = calls/day (fairness/billing) |
Clock skew (JWT exp) | Entra default leeway ~±300 s (P03) |
scp vs roles | scp = delegated (user present, space-delimited); roles = app-only (daemon) |
| Pagination default | cursor/keyset, not offset (offset drifts + slows at depth) |
6. az / policy one-liners
# Create an APIM instance (Consumption tier = serverless, pay-per-call).
az apim create -n my-apim -g rg --publisher-email me@x.com --publisher-name "Me" --sku-name Consumption
# Import an API from an OpenAPI spec.
az apim api import -g rg --service-name my-apim --api-id users \
--path users --specification-format OpenApi --specification-url https://.../openapi.json
# Show a subscription key (the per-consumer identity/meter).
az apim subscription list -g rg --service-name my-apim -o table
<!-- The two policies that are 90% of the edge: authenticate + throttle. -->
<inbound>
<validate-jwt header-name="Authorization" failed-validation-httpcode="401">
<openid-config url="https://login.microsoftonline.com/{tid}/v2.0/.well-known/openid-configuration" />
<audiences><audience>api://my-api</audience></audiences> <!-- aud check: anti confused-deputy -->
<required-claims><claim name="scp" match="all"><value>users.read</value></claim></required-claims>
</validate-jwt> <!-- missing scope → 403 -->
<rate-limit-by-key calls="10" renewal-period="1"
counter-key="@(context.Subscription.Key)" /> <!-- 10/s per consumer; over → 429 -->
</inbound>
7. War story shapes you'll relive
- "Clients are hammering Entra with token refreshes." → someone returned
401for an authorization failure. The client thinks the token's bad, refreshes, retries the same forbidden call, loops. Fix: it's a403. The code is the contract. - "We double-charged customers during the outage." → retry-on-
5xxlogic replayed a non-idempotentPOST /charge. Fix: idempotency keys, so a replay is a no-op. - "
/users/mereturns the wrong user." → route matching picked/users/{id}withid="me"over the literal/users/me. Fix: most-specific-wins (literal beats wildcard). - "The throttle isn't working / is too aggressive." → confusing rate-limit (burst) with
quota (monthly), or sizing
capacity(burst) wrong. Draw the bucket; do the arithmetic. - "Service A accepts service B's token." →
validate-jwtchecked the signature but notaud. Confused deputy. Always checkaud. - "Our
429s aren't helping." → noRetry-After, so clients retry too fast and make it worse. Emit the precise back-off.
8. Vocabulary that signals you've held the pager
- Idempotent — twice == once; the property that makes a write retry-safe.
- Confused deputy — a service that honors a token minted for someone else (skipped
aud). - Fail closed — the default decision is deny; you only allow what you can prove.
- Token bucket — burst capacity + sustained refill; the canonical rate limiter.
- Noisy neighbor — one tenant's flood starving others; rate-limit-by-key is the bulkhead.
- Policy pipeline — APIM's
inbound → backend → outbound → on-error; order is the policy. scpvsroles— delegated (a user is present) vs app-only (a daemon).- Cursor pagination — opaque "last key I saw"; stable under writes, unlike offset.
- Content negotiation —
Accept/Content-Typedance; mismatch is406/415.
9. Beginner mistakes that mark you in interviews
- Saying "REST" but designing RPC (
POST /api/doStuff) — no resources, no uniform methods. - Confusing
401and403(the single most common edge bug, and it causes real incidents). - Returning
200with an error in the body — breaks every layer that reads the status. - Retrying a
POSTon a timeout without an idempotency key — duplicate writes. 429withoutRetry-After— clients retry too fast and amplify the overload.- Checking the JWT signature but not
aud— the confused-deputy breach. - Rate-limiting before authenticating — letting unauth probes drain rate budget.
- Route matching that lets
/users/{id}shadow the literal/users/me. - Offset pagination on a write-heavy collection — skipped/duplicated rows at the seams.
10. How this phase pays off later
- The gateway pipeline is where reliability (P14:
429/Retry-After, circuit breaking), identity (P03: the JWT this phase validates), and observability (P13: every4xx/5xxis a KQL signal) all meet — it's the edge of the whole platform. - Idempotency recurs everywhere: ARM
PUT(P01), Service Bus dedup (P10), Durable replay (P11). The "twice == once" instinct you build here is a track-wide muscle. scp/rolesauthorization is Phase 03's token, consumed. This is where minting and validating a token turns into gating real operations with it.- The token-bucket math is the same
429/Retry-Afterarithmetic you'll compose into a retry budget and a circuit breaker in P14.
Now read the WARMUP slowly, then build the gateway. You'll reach for its pipeline order and
its 401/403 rule in every API design review for the rest of your career.
👨🏻 Brother Talk — Phase 09, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. You've built REST APIs. Everyone has. You wired up a controller, returned some JSON,
slapped @authorize on it, shipped. And here's the uncomfortable thing nobody tells you:
that experience is exactly what makes this phase sneaky-hard. You think you know HTTP,
so you skim. Then an interviewer asks "why is PUT idempotent and POST isn't, and what
does that have to do with retries?" and you realize you've been using these words for years
without ever being able to defend them. This phase is where you stop using HTTP by reflex
and start using it on purpose.
Let me give you the things that actually matter, brother to brother.
The 401/403 thing is not pedantry — it's an incident generator. I know it feels like
trivia. It is not. I have watched a perfectly competent team take down their own auth service
because an endpoint returned 401 when it should have returned 403. A scope was missing.
The client SDK saw 401, thought "oh, bad token," refreshed against Entra, retried the same
forbidden call, got 401, refreshed again — a refresh storm that hammered Entra and a hot
loop that hammered the API, all from one wrong digit. The fix was a three-character code
change. 401 means "I don't know you" and 403 means "I know you, the answer is no."
One says re-authenticate, the other says that will never work, ask for more access. Burn
that in. It's the single highest-leverage thing in this phase.
Idempotency is the most important word you'll under-appreciate. Here's the mental shift:
the network cannot tell you whether your request succeeded. You send a write, the server
does it, the response evaporates in a dropped TCP connection. Did it happen? You genuinely
cannot know. That ambiguity is permanent and unavoidable — and the only defense is
designing the operation so that doing it twice is the same as doing it once. Then you just
retry and stop caring. PUT to a known id: retry-safe. DELETE: retry-safe. POST /charge
without an idempotency key: a loaded gun. The day you internalize this, you stop writing
"hope the network holds" code and start writing code that's correct under failure — which is
the whole job at this level.
The gateway is where you become a platform person. A senior secures one API. A principal realizes that auth, rate limiting, JWT validation, and CORS are concerns every API has, and that re-implementing them per service is how you get fourteen subtly-different security bugs. So you push them to the edge — the gateway — and now there's one place that's correct, one place to fix, and no backend can route around it. That move — "this is a cross-cutting concern, it belongs at the edge, not in every service" — is principal thinking. APIM is just Azure's name for the place you put it.
Fall in love with fail-closed. Every junior writes if not_authorized: return 403 and
forgets the forty ways authorization can be undetermined — token missing, malformed, JWKS
unreachable, claim absent. A principal flips the default: the answer is "no" unless I can
prove "yes." No token? No. Can't verify the signature? No. Missing the claim I authorize
on? No. This feels paranoid until the first time someone hands you a request that hits a code
path you didn't anticipate — and it denies, because deny was the default, instead of sailing
through because you only coded the "allow" cases. Security is a default, not a feature you
add.
Do the rate-limit arithmetic out loud — it's a flex. When someone asks "how do we stop one
tenant from taking us down," most people say "uh, rate limiting" and stop. You say: "token
bucket, capacity is the burst we tolerate, refill is the sustained rate we guarantee, per
subscription key so one tenant's flood drains their bucket and nobody else's, and on empty we
return 429 with a Retry-After of exactly (1 − tokens)/refill so well-behaved clients
self-throttle to precisely the right speed." That's the same question, answered with
arithmetic. That's the difference between sounding senior and being it. The math is in the
lab; it's not hard; it just has to be yours.
Here's the honest truth about why this phase matters more than it looks. The edge is where
everything meets. The token your identity phase minted gets checked here. The reliability
patterns — 429, Retry-After, circuit breaking — start here. The observability — every
401, 403, 429, 5xx is a signal — flows from here. When something is wrong in
production, the gateway logs are usually where you start, because they see every request
before anyone else does. Master this phase and you're not just "good at APIs" — you understand
the membrane between your platform and the entire outside world, and you can reason about what
crosses it and why.
One last thing, and it's the career framing. The people who run the API platform run the
company's nervous system. Every team's service talks through APIs; every partner integration
is an API; every mobile app, every webhook, every internal call — it all crosses the edge you
own. That's enormous leverage and enormous trust. You get there not by knowing more HTTP verbs
than the next person, but by being the one who can say, calmly, in a design review: "that
should be a PUT so it's retry-safe, return 403 not 401 there or you'll cause a refresh
loop, and rate-limit by subscription key at 10/s burst — here's the bucket math." Specific.
Numbered. Defensible. That's the whole game, and it's completely learnable. It starts with the
gateway in this lab.
Go build it. Then come find me in Phase 10 — events and Service Bus, where "at-least-once" and "idempotency" stop being words and become the thing that keeps you from double-processing a million messages.
— your brother 👨🏻
Lab 01 — API Gateway: Routing, Scope Authz & Rate Limiting
Phase: 09 — RESTful APIs, JSON & Secure AuthN/AuthZ | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Every request to a managed API hits a gateway before it ever reaches your code, and that gateway makes the same four decisions in the same order: does this route exist (
404), who is this caller and may they do this (401/403), are they over their rate budget (429), and only then call the backend (200). This lab builds that machine — the route matcher with path-template params and most-specific-wins, the scope authorizer whose whole subtlety is401-vs-403, the token-bucket rate limiter with a correctRetry-After, and the pipeline that wires them in the order that is the security policy. Strip away TLS, the JWKS fetch, and the YAML policy syntax, and what's left is route matching, set containment, a little arithmetic on a bucket of tokens, and a careful sequence ofifstatements — exactly the part interviewers probe and incidents turn on.
What you build
Route(method, path_template, required_scopes)— an API operation. The template uses{name}segments (/users/{id}) that match any single non-empty segment and bind the param; literal segments match only themselves. Validates the method and that the path starts with/, and normalises the method to upper-case.match_route(routes, method, path)— method + path dispatch with param extraction (/users/42→{"id": "42"}), returning the most-specific match when several templates fit (/users/meshadows/users/{id}), orNone(→404) when nothing does.authorize(token_claims, required_scopes)— returns an HTTP status:401(no / malformed / unverified token — who are you?),403(valid token, but its space-delimitedscpis missing a required scope — not allowed), or200(authenticated and every required scope present; a superset is fine). Fails closed.TokenBucket(capacity, refill_per_sec)— the continuous-refill rate limiter:allow(now)returns(allowed, retry_after_seconds); refill ismin(capacity, tokens + elapsed × refill), consume one if available, and on emptyretry_after = (1 − tokens) / refill. Deterministic — time is injected asnow, never read from a clock.RateLimiter— one bucket perclient_id, created lazily, so one noisy tenant drains its bucket and nobody else's (the bulkhead).handle(gateway, request, now)— the pipeline: match (404) → authorize (401/403) → rate-limit (429+Retry-After) → backend (200), returning aResponse(status, body, headers). The order is the contract.
Key concepts
| Concept | What to understand |
|---|---|
| Path-template matching | {id} matches one segment and binds it; literal matches only itself; arity must match |
| Most-specific-wins | among matches, the template with the most literal segments wins (/users/me ≻ /users/{id}) |
| Method dispatch | GET /orders and POST /orders are different operations; method is part of the key |
401 vs 403 | 401 = authentication failed (no/invalid token) → re-auth; 403 = authorization failed (valid token, wrong scope) → re-login won't help |
| Fail closed | absent/malformed token, or a required scope you can't prove, denies — never "allow on doubt" |
scp containment | required scopes must be a subset of the token's granted scopes; a superset of grants is fine |
| Token bucket | capacity = burst, refill_per_sec = sustained rate; tokens accrue continuously, one spent per request |
Retry-After | on 429, tell the client exactly how long until one token accrues so it backs off precisely |
| Pipeline order | 404 before 401 (no op to authorize); 401/403 before 429 (don't let probing cost rate budget) |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py walks a 404, 401, 403, 200, a 429 after the bucket drains, and a 200 after refill |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 29 tests pass against your implementation and against
solution.py. - You can explain why
test_pipeline_404_before_authreturns404and not401even though no token is present — routing runs before authorization, and there is no operation to authorize against. - You can explain why
test_pipeline_401_before_429returns401and not429— auth runs before rate limiting, so an unauthenticated probe never touches the bucket and an attacker can't drain another caller's budget. - You can state the
401-vs-403rule from memory:401is "I don't know who you are" (re-authenticate);403is "I know exactly who you are and you're still not allowed" (ask for more scopes — re-login won't fix it). - You can explain why
test_bucket_retry_after_partial_tokenexpects0.25— with0.5of a token accrued and a2/srefill, one full token is(1 − 0.5) / 2 = 0.25 saway. - You can explain why the bucket starts full and why it caps at
capacity(burst allowance; you can't bank unused rate forever).
How this maps to real Azure (API Management)
| The lab | Real Azure API Management |
|---|---|
Gateway | an APIM instance (the gateway) fronting one or more backends |
Route | an operation on an API (method + URL template), grouped into a product |
match_route | APIM's operation matching by method + URL template; {id} is a template parameter |
authorize (401/403) | the validate-jwt inbound policy (signature/iss/aud/exp) then a scp/roles claim check; 401 on no/invalid token, 403 on insufficient scope |
required_scopes | the required-claims block of validate-jwt, or a check-header/choose on the scp claim |
TokenBucket / RateLimiter | the rate-limit-by-key policy (calls per period, by subscription key / IP / a claim); quota-by-key is the longer-window cousin |
Retry-After header on 429 | APIM emits Retry-After on throttling so clients back off correctly |
| the pipeline order | APIM's policy pipeline: inbound (auth, rate-limit, transform) → backend → outbound → on-error |
Response headers | APIM set-header/set-status policies and the context.Response it returns |
What the miniature leaves out (and why it's fine): real APIM validates RS256 JWTs
against the issuer's JWKS (fetched and cached by kid) over TLS, runs the policy
pipeline in XML with <inbound>/<backend>/<outbound>/<on-error> sections, caches
responses, transforms payloads (JSON↔XML), does request/response mTLS to the backend,
and rate-limits across a distributed gateway fleet (so the bucket state is replicated /
approximate, not exact). None of that changes the decisions — route match, the
401-vs-403 split, the token-bucket arithmetic, the pipeline order — which is the part
interviewers probe and on-call debugs. We inject now and stub JWT validation to a valid
flag purely to stay offline and deterministic; swap the flag for the Phase 03 validate_jwt
and the rest is the real thing.
Extensions (build these in your own subscription)
- Real
validate-jwt: replace the"valid"flag with the Phase 03 validator (signature →iss→aud→exp/nbf→ claim), soauthorizeruns the full check and still returns401/403. Now the gateway is end-to-end correct. roles(app-only) authorization: add app-role authorization alongsidescp— a client-credentials token carriesroles, notscp; require eitherscp ⊇ delegatedorroles ⊇ app, so the same operation works for a user and a daemon.- Quota vs rate-limit: add a
Quota(limit, window_seconds)(a hard count per long window, e.g. 10k calls/day) on top of the per-second token bucket; show that429can come from either and that they answer different questions (burst vs monthly fairness). 429headers: emitX-RateLimit-Limit,X-RateLimit-Remaining, andX-RateLimit- ResetalongsideRetry-After, the GitHub/Stripe convention well-behaved clients read.- Pagination + content negotiation: add a
GET /usersthat returns a page with anextcursor, and honourAccept: application/json; return406on an unsupported media type and415on an unsupported requestContent-Type. PATCHvsPUTidempotency: add both and test that two identicalPUTs converge to one state (idempotent) while twoPOSTs create two resources (not) — the property that decides whether a client may safely retry on a timeout.- Wire it to a real instance:
az apim create, import an API (az apim api import), attach avalidate-jwt+rate-limit-by-keypolicy, and watch a missing scope return403and a flood return429withRetry-Afteron real traffic.
Interview / resume
- Talking points: "Walk me through what an API gateway does to a request, in order." /
"
401vs403— when does each fire and why does the distinction matter operationally?" / "Implement a token bucket and tell me whatRetry-Aftershould say." / "Why does routing run before auth, and auth before rate limiting?" / "Rate-limit vs quota — what's each for?" - Resume bullet: Built an API-gateway engine (HTTP method + path-template routing with
most-specific match and param extraction, OAuth2 scope authorization with the
401/403fail-closed distinction, and a continuous-refill token-bucket rate limiter emittingRetry-After) modeling the Azure API Management inbound policy pipeline.
Phase 10 — Event-Driven: Event Grid, Service Bus & Logic Apps
Difficulty: ⭐⭐⭐⭐☆ (the mechanism) → ⭐⭐⭐⭐⭐ (the delivery-semantics judgment)
Estimated Time: 1.5 weeks (16–22 hours)
Prerequisites: Phase 03 (Entra ID — the identity a publisher/consumer authenticates with),
Phase 09 (REST/JSON + AuthZ — the shape of a webhook handler), and a working mental model of
idempotency from Phase 01 (ARM PUT) and Phase 02 (Terraform apply). This phase is where
those threads converge into distributed-systems delivery guarantees.
Why This Phase Exists
Up to here the curriculum has been about the control plane (ARM, Terraform, RBAC, landing zones, networking) and synchronous request/response (the API gateway in P09). But the platforms the JD describes are event-driven: a service emits an event, and one or more other services react — asynchronously, decoupled, at their own pace. The moment you decouple producer from consumer with a network and a buffer in between, you have signed up for the hardest problems in distributed systems: messages get lost, duplicated, reordered, and redelivered, and "it worked in the demo" tells you nothing about what happens when a consumer crashes mid-process at 50,000 messages/second.
A senior engineer wires up a Service Bus queue and an Event Grid subscription. A principal understands the delivery state machine underneath well enough to answer the only questions that matter:
- When a consumer pulls a message and then crashes, what happens to that message? (the peek-lock + lock-expiry redelivery answer)
- When the same event is delivered twice, does the system double-charge a customer? (the idempotency + exactly-once-effect answer)
- When a message can never be processed, where does it go and who gets paged? (the max-delivery → dead-letter-queue answer)
- When you need strict order, what does that cost you in throughput? (the sessions answer)
- Should this be a queue, a topic, a stream, or a reactive event? (the Service Bus vs Event Hubs vs Event Grid answer — the most common architecture mistake in the whole space)
The JD names it directly: "Design and implement event-driven architectures using Azure Event Grid, Azure Service Bus, and Azure Logic Apps." This phase makes you build the two delivery engines — the Service Bus broker and the Event Grid retry-delivery path — so the guarantees become arithmetic and a state machine you can draw, not a feature you hope works.
Concepts
- Broker vs router vs log — the three event-plane shapes, and the single decision that
picks one:
- Azure Service Bus — an enterprise message broker. A consumer pulls a message, locks it (peek-lock), processes, and settles it (complete/abandon/dead-letter). Queues (point-to-point) and topics/subscriptions (pub/sub with filters). The home of ordering (sessions), duplicate detection, scheduled/deferred messages, transactions, and dead-letter queues. Use it for commands and workflow steps that must not be lost and may need order or transactions.
- Azure Event Grid — a reactive event router. The publisher fires a small CloudEvents-shaped notification ("a blob was created", "a resource changed") and Event Grid pushes it to subscribers' webhooks/handlers, at-least-once, with exponential-backoff retry and dead-lettering to storage. Use it for "X happened, react" fan-out at massive scale with no consumer to manage.
- Azure Event Hubs — a high-throughput partitioned log (Kafka-shaped): millions of events/second, consumers track offsets, events are replayable within a retention window. Use it for telemetry/streaming/analytics ingestion, not discrete commands. (Named here for the contrast; built fully in the streaming track.)
- Peek-lock delivery — the receive-then-settle state machine:
receivelocks a message invisible forLockDuration; the consumer mustcomplete(remove),abandon(release now), ordead_letterit before the lock expires. Lock expiry → redelivery +DeliveryCount++; once a message has been deliveredMaxDeliveryCounttimes it moves to the dead-letter queue (DLQ) instead of being redelivered. This is the crash-safety mechanism: a dead consumer's message comes back automatically. - Delivery semantics — the spectrum every async system lands on:
- at-most-once — fire-and-forget; no retry; may lose (Service Bus
ReceiveAndDelete, UDP-style). - at-least-once — retry until acknowledged; may duplicate (peek-lock without settle-on-success-only, Event Grid push). The practical default.
- exactly-once-effect — at-least-once plus an idempotent or de-duplicating consumer so a duplicate is a no-op. The only "exactly-once" that exists across a network boundary, and a per-data-product dial, not a free platform setting.
- at-most-once — fire-and-forget; no retry; may lose (Service Bus
- Idempotency keys & duplicate detection — a producer stamps a
MessageId; Service Bus drops a repeat within the dedup window; a consumer keys side effects on it so a redelivery is harmless. The same idea as ARM's idempotentPUT, now on the data plane. - Sessions / FIFO — ordering is not free: a session locks all messages of one
SessionIdto a single consumer, in order, trading parallelism for guaranteed sequence. - CloudEvents — the CNCF interoperability envelope (
id,source,type,subject,time,data) Event Grid speaks, so events are portable across clouds and tools. - Logic Apps — serverless workflow orchestration (a trigger + a graph of connector actions) — the low-code integration complement to code-first Functions (P11). It is how "when an event arrives, call these five SaaS systems in order with retries" gets built without a service.
Labs
Lab 01 — Messaging Broker (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the two delivery engines: a Service Bus-style Broker (peek-lock receive, complete/abandon/dead-letter, lock-expiry redelivery with DeliveryCount++, max-delivery → DLQ, duplicate detection, scheduled messages, FIFO sessions with a single active consumer) and an Event Grid-style deliver_with_retry (at-least-once push with deterministic exponential-backoff retry, then dead-letter), plus a classify_delivery semantics map — all offline with time injected |
| Concepts | Peek-lock state machine; lock-expiry redelivery off-by-one; max-delivery dead-lettering; dedup window; scheduled messages; session FIFO; at-least-once + idempotency = exactly-once-effect; exponential backoff |
| Steps | 1. Message + validation; 2. Broker.send (dedup + scheduled + session rules); 3. receive peek-lock + FIFO/session selection; 4. complete/abandon/dead_letter + _requeue_or_dead_letter; 5. tick lock-expiry redelivery + DLQ; 6. deliver_with_retry backoff + dead-letter; 7. classify_delivery |
| How to Test | pytest test_lab.py -v — dedup drop+allow-after-window, peek-lock invisibility + lock-expiry boundary, complete/abandon, max-delivery → DLQ (via abandon and lock-expiry), scheduled-not-early, session FIFO + single-active-consumer, Event Grid schedule + dead-letter + raises-as-failure, classifier mapping, determinism |
| Talking Points | "A consumer crashes mid-process — walk me through what happens to the message." "Is this exactly-once? Prove it." "Queue or topic or Event Grid or Event Hubs — and why?" "What does MaxDeliveryCount actually guard against?" "Why does ordering cost throughput?" |
| Resume bullet | Built a Service Bus-style broker (peek-lock, sessions, duplicate detection, dead-letter) and an Event Grid-style at-least-once retry-delivery engine, modeling the delivery-semantics guarantees behind a production event-driven platform |
→ Lab folder: lab-01-messaging-broker/
Integrated-Scenario Suggestions
These carry the phase into the rest of the track — each is a real event-platform problem the delivery engines underpin:
- Order-processing pipeline — an
OrderPlacedcommand flows Service Bus queue → inventory consumer → payment consumer, each step idempotent onOrderId, poison orders to the DLQ with an alert, and a Logic App fanning notifications. Which steps need sessions (per-customer ordering)? Where do you re-earn exactly-once at each boundary? (Uses the whole lab; builds on P09 auth, P12 Key Vault for the payment secret.) - Reactive blob-processing fan-out —
Microsoft.Storage.BlobCreated→ Event Grid → N subscribers (thumbnailer, virus-scan, indexer), each filtered bysubjectprefix, with dead-letter-to-storage and retry budgets. When does at-least-once double-process a blob, and how does an idempotent handler fix it? (Builds on P07 containers, P11 Functions.) - Telemetry vs commands split — the same product emits both high-volume device telemetry and discrete control commands. Route telemetry to Event Hubs (partitioned log, replay) and commands to Service Bus (ordered, transactional, DLQ). Defend the split with the read:write ratio and the loss/duplicate cost per stream.
- Dead-letter triage runbook — given a DLQ filling at 200 msg/min, write the playbook:
classify the failure (poison payload vs downstream outage vs lock-too-short), decide
replay vs discard, and fix the root cause (raise
MaxDeliveryCount? lengthenLockDuration? add idempotency?). (Builds on P13 observability, P14 reliability.) - Saga / workflow orchestration — a multi-system business process (book flight → charge card → email) as a Logic App (or Durable Function, P11) with compensation steps, retries, and a timeout, sitting on top of Service Bus for the durable steps. Where does the orchestration state live, and how does a replay stay deterministic?
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Where This Phase Sits in the Track
This phase is the asynchronous backbone: it turns the synchronous request/response world of P09 into the decoupled, buffered, retry-driven platform the business actually runs on.
| Connects to | How |
|---|---|
| P03 — Entra ID | a publisher/consumer authenticates with a Managed Identity (P12); RBAC data-plane roles (Azure Service Bus Data Sender/Receiver, EventGrid Data Sender) gate send/receive |
| P09 — REST APIs & AuthZ | an Event Grid subscriber is a webhook — a JWT-validated, idempotent HTTP handler exactly like the gateway taught |
| P11 — Serverless / Functions | a Service Bus / Event Grid trigger invokes a Function; the scale controller sizes instances from the queue backlog this phase produces; Durable Functions orchestrate the multi-step sagas |
| P12 — Key Vault & Managed Identity | the secret-free identity a consumer uses, and where a payment/credential in a workflow step is fetched |
| P13 — Observability | DeliveryCount, active/dead-letter message counts, and retry rates are the alerts that catch a stuck consumer before customers do |
| P14 — Reliability | retry + backoff + jitter, the DLQ as a failure bulkhead, and the at-least-once/idempotency contract are the reliability primitives applied to messaging |
| P15 — Capstone | the event-driven, serverless, secure platform is this phase's broker + Event Grid + Functions wired together under a landing zone |
The mental model to carry forward: decoupling is a buffer plus a delivery guarantee, and the guarantee is at-least-once unless you engineer the duplicate away. Every later phase that reacts to something is standing on one of the two engines you build here.
Key Takeaways
- Service Bus is a broker (pull + lock + settle), Event Grid is a router (push + retry), Event Hubs is a log (stream + offsets). Picking the wrong one is the most common — and most expensive — event-architecture mistake. Match the shape to the workload.
- Peek-lock is the crash-safety mechanism: receive locks a message invisible; settle it,
or the lock expires and it's redelivered with
DeliveryCount++; afterMaxDeliveryCountdeliveries it dead-letters. A dead consumer never silently loses a message — that's the whole point. - The default guarantee is at-least-once, which means duplicates happen. "Exactly-once" end-to-end is a myth across a network; what you build is at-least-once + an idempotent / de-duplicating consumer = exactly-once-effect, chosen per data product by what a duplicate (or a loss) actually costs.
- Ordering is not free: a session trades parallelism for FIFO. Don't pay for ordering a stream doesn't need.
- Duplicate detection (
MessageId) and idempotency keys are the same idea as ARM's idempotentPUT, now on the data plane — stamp the producer, key the consumer, and a redelivery becomes a no-op. - The DLQ is a bulkhead, not a graveyard: a poison message that can't be processed must
leave the hot path (so it stops blocking healthy traffic) and raise an alert (so a
human triages it) — that is what
MaxDeliveryCountbuys you.
Deliverables Checklist
-
Lab 01 implemented; all tests pass against
solution.pyand yourlab.py -
You can draw the peek-lock state machine (receive → lock → complete/abandon/
dead-letter; lock-expiry → redeliver +
++; max-delivery → DLQ) from memory - You can state, for a given workload, whether it wants Service Bus, Event Grid, or Event Hubs — and defend it with the read:write ratio and the loss/duplicate cost
-
You can explain why
now == lock_untilcounts as expired (the off-by-one) - You can turn a "we got charged twice" bug into the exactly-once-effect fix (idempotency key on the consumer), and say where the key comes from
- You can name what sessions cost (throughput) and when ordering is actually required
- You can sketch an Event Grid retry policy (backoff schedule, TTL, dead-letter target) and a Logic App workflow that consumes from it
Warmup Guide — Event-Driven: Event Grid, Service Bus & Logic Apps
Zero-to-principal primer for Phase 10: what an event-driven architecture actually is, the three event-plane shapes (broker, router, log) and the one decision that picks between them, the peek-lock delivery state machine under the hood of Service Bus, the at-least-once + exponential-backoff push model under Event Grid, the delivery-semantics spectrum (and why "exactly-once" is a precise lie), sessions and ordering, duplicate detection, dead-lettering, CloudEvents, and Logic Apps — every term from first principles, the mechanism, the math, and the failure signature.
Table of Contents
- Chapter 1: Why Event-Driven — Decoupling Is a Buffer Plus a Guarantee
- Chapter 2: The Three Shapes — Broker vs Router vs Log
- Chapter 3: Service Bus — The Broker and Its Entities
- Chapter 4: Peek-Lock — The Delivery State Machine Under the Hood
- Chapter 5: Dead-Lettering and the Poison-Message Guard
- Chapter 6: Duplicate Detection, Scheduled & Deferred, Transactions
- Chapter 7: Sessions — Ordering Has a Price
- Chapter 8: Delivery Semantics — The Precise Lie of "Exactly-Once"
- Chapter 9: Event Grid — Reactive Routing, CloudEvents, and Retry
- Chapter 10: Event Hubs — The Streaming Sibling (for Contrast)
- Chapter 11: Logic Apps — Serverless Workflow Orchestration
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: Why Event-Driven — Decoupling Is a Buffer Plus a Guarantee
From zero. A synchronous call — the API gateway of Phase 09 — is a phone call: the caller waits, holds the line, and fails if the callee is down. An event-driven architecture is the postal system: the producer drops a message in a box and walks away; the consumer picks it up when it's ready. The producer does not know who consumes, how many consumers there are, or whether they're up right now. That is decoupling, and it buys three things you cannot get synchronously:
- Temporal decoupling — the consumer can be down when the producer sends, and still process the message later. The buffer absorbs the outage.
- Load leveling — a 10× traffic spike fills the buffer instead of melting the consumer; the consumer drains at its own steady rate (this is the basis of the P11 scale controller).
- Fan-out — one event, many independent reactions, added and removed without touching the producer.
But here is the principal-level truth that the postal metaphor hides: the moment you put a network and a buffer between producer and consumer, you inherit every hard problem in distributed systems. A message can be:
- lost (the producer's ack never came back; the consumer crashed after pulling it),
- duplicated (the producer retried a send that actually succeeded; the consumer processed then crashed before acknowledging, so it's redelivered),
- reordered (two messages take different network paths or land on different partitions),
- delayed (stuck behind a slow consumer, a poison message, or a long lock).
So "event-driven" is not "easier than synchronous." It is different — you trade synchronous failure (an error returned to the caller, now) for asynchronous delivery guarantees that you must reason about explicitly. The rest of this phase is exactly those guarantees. The headline: the default guarantee is at-least-once, which means duplicates are not a bug — they are the contract, and your consumer must be built for them.
Chapter 2: The Three Shapes — Broker vs Router vs Log
Azure gives you three event services, and 80% of event-architecture mistakes are choosing the wrong one. They are three shapes, not three brands:
| Service Bus | Event Grid | Event Hubs | |
|---|---|---|---|
| Shape | broker (queue) | router (pub/sub) | log (stream) |
| Model | consumer pulls & locks | service pushes to handler | consumer reads at an offset |
| Unit | a command/message | a notification ("X happened") | a telemetry event |
| Throughput | thousands–tens of thousands/s | very high fan-out | millions/s |
| Order | yes, via sessions | no | per partition |
| Replay | no (settled = gone) | no | yes (within retention) |
| Dedup | yes (MessageId window) | no (idempotent consumer) | consumer-side (offset) |
| Dead-letter | yes (DLQ entity) | yes (to storage) | n/a (offsets) |
| Pay for | namespace + ops | per million operations | throughput units / partitions |
| Use when | must-not-lose commands, order, transactions, workflows | "react to X" fan-out at scale, no consumer to run | high-volume streaming/analytics ingestion, replay |
The one-line decision rule, the thing to say out loud in an interview:
Is it a command I must not lose and maybe must order/transact? → Service Bus. Is it a notification I want many things to react to, push-style, at scale? → Event Grid. Is it a firehose of telemetry I need to stream, partition, and replay? → Event Hubs.
The trap: people reach for Event Hubs because "high throughput" sounds impressive, then need per-message dead-lettering and ordering and transactions — which is Service Bus's job. Or they build a polling loop on a Service Bus queue to do reactive fan-out — which is Event Grid's job. Match the shape to the workload, not the buzzword.
Chapter 3: Service Bus — The Broker and Its Entities
Service Bus is an enterprise message broker: a durable, ordered, transactional middleman. Its namespace contains two entity types:
- Queue — point-to-point. Producers send; competing consumers each pull and the broker hands each message to exactly one of them. This is work distribution / load leveling.
- Topic + Subscriptions — publish/subscribe. A producer sends to a topic; the broker copies the message into every subscription whose filter (a SQL/correlation expression on properties) matches. Each subscription is then its own queue with its own consumers. This is fan-out with routing.
Queue (point-to-point): Topic + Subscriptions (pub/sub):
producer → [ q ] → consumer A ┌─ sub:Orders [filter type='order'] → consumers
→ consumer B producer →[topic]┤
(one of A/B per msg) └─ sub:Audit [filter 1=1] → consumers
A message carries a body (≤ 256 KB Standard, ≤ 100 MB Premium), system properties
(MessageId, SessionId, ScheduledEnqueueTimeUtc, TimeToLive, DeliveryCount), and
arbitrary application properties (used by subscription filters). The lab's Message
models body, message_id, session_id, scheduled_for, plus the broker-owned
delivery_count and dead_letter_reason.
Two receive modes sit at opposite ends of the delivery-semantics spectrum:
- ReceiveAndDelete — the broker removes the message the instant it's handed over. Fast, simple, at-most-once: if the consumer crashes before processing, the message is gone.
- PeekLock — the broker locks the message but keeps it; the consumer must explicitly settle it. This is at-least-once and the subject of the next chapter. It is what you use for anything that matters.
Chapter 4: Peek-Lock — The Delivery State Machine Under the Hood
This is the chapter the whole lab exists to make muscle memory. Peek-lock is the mechanism that makes a broker crash-safe. Here is the state machine:
receive(now)
┌──────────────────────┐
┌──────────▼─────────┐ │ DeliveryCount++ each delivery
│ READY (visible) │ │
└──────────┬─────────┘ │
│ receive: lock for │
│ LockDuration │
┌──────────▼─────────┐ │
│ LOCKED (invisible │ │
│ until lock_until) │ │
└─┬────────┬───────┬─┘ │
complete │ abandon│ dead- │ lock expires │
│ │ letter│ (now>=until) │
┌──────▼──┐ ┌──▼───────┴──────────────┘
│ REMOVED │ │ back to READY ──── if DeliveryCount >= MaxDeliveryCount ──▶ DLQ
└─────────┘ └─────────────────┘
Walk it slowly, because every transition is an interview question:
receive(now)pulls the next visible message and locks it: it becomes invisible to every other consumer untillock_until = now + LockDuration. The broker incrementsDeliveryCount(this delivery counts, whether or not it succeeds). The consumer gets the message and a lock token.- The consumer now has until
lock_untilto do one of three things:complete(token)— "I processed it successfully." The broker removes the message. Done, gone, settled.abandon(token)— "I can't process it right now; give it back." The lock releases immediately and the message is receivable again now (by this or another consumer).DeliveryCountalready went up, so abandon-loops are bounded.dead_letter(token, reason)— "this message is poison; don't retry it." It moves to the DLQ with the reason attached.
- If the consumer does none of these before
lock_until— it crashed, GC-paused, or the handler ran long — the lock expires. The broker reclaims the message, makes it receivable again, and on the next deliveryDeliveryCountclimbs again. This is the crash-safety guarantee: a dead consumer cannot silently swallow a message; it comes back automatically.
The off-by-one that interviewers love. When exactly is a lock expired? The lab's rule —
and the right one — is now >= lock_until (inclusive). At the exact instant the lock
duration has elapsed, the message is back. In the lab:
# lab/solution Broker.tick:
expired = [t for t, e in self._locked.items() if e.lock_until <= now]
If a message was received at t=0 with LockDuration=10, it is locked through t=9 and
receivable again at exactly t=10 (test: test_lock_expiry_at_exact_boundary_is_expired).
Abandon vs lock-expiry are the same effect (redelivery, DeliveryCount++) reached two
ways: abandon is the consumer voluntarily giving the message back now; lock-expiry is the
broker involuntarily reclaiming it after the lock. The lab routes both through one
function, _requeue_or_dead_letter, which is exactly how to think about it.
In real code the receiver also calls RenewMessageLockAsync to extend a lock for a
long-running handler (so a slow-but-alive consumer isn't treated as crashed). The lab
deliberately omits renewal and models only its consequence — if you don't renew and you're
slow, you get redelivered, which is the whole point.
Chapter 5: Dead-Lettering and the Poison-Message Guard
A poison message is one that can never be processed: a malformed payload, a reference
to a deleted resource, a bug that throws every time. Without a guard, such a message is
abandoned, redelivered, abandoned, redelivered — forever — burning the consumer and
blocking the queue behind it (head-of-line blocking). The guard is MaxDeliveryCount.
The rule (and the lab's contract): a message is delivered at most MaxDeliveryCount
times. Once it has been delivered that many times, the next disposition that would redeliver
it instead routes it to the dead-letter queue (DLQ):
# solution Broker._requeue_or_dead_letter:
if msg.delivery_count >= self.max_delivery_count:
msg.dead_letter_reason = reason
self._dlq.append(msg) # poison → DLQ, NOT redelivered
return
self._ready.insert(0, msg) # otherwise back in line (FIFO preserved)
With MaxDeliveryCount=2: deliver (count 1) → abandon → deliver (count 2) → abandon → DLQ
at count 2. The message was delivered exactly twice, never a third time
(test_max_delivery_count_moves_to_dlq_via_abandon). Service Bus's default is 10.
A message also dead-letters on TTL expiry (it sat unprocessed past TimeToLive) and on
explicit dead_letter(). The DLQ is a real sub-entity (<queue>/$DeadLetterQueue) you
receive from like any queue — to triage, fix, and replay.
The principal framing: the DLQ is a bulkhead, not a graveyard. Its job is to get the poison message off the hot path (so healthy traffic flows) and raise an alarm (so a human triages it). A DLQ filling up is a signal, and "active vs dead-letter message count" is one of the first alerts you wire in P13. The worst failure mode is a DLQ nobody watches.
Chapter 6: Duplicate Detection, Scheduled & Deferred, Transactions
Duplicate detection. Because delivery is at-least-once, producers also retry — and a
producer that retries a send which actually succeeded creates a duplicate at the source.
Service Bus can drop these: with RequiresDuplicateDetection, the broker remembers every
MessageId for a DuplicateDetectionHistoryTimeWindow (up to 7 days) and silently drops
a repeat within that window. The lab models it precisely:
# solution Broker.send:
if self.dedup_window is not None and msg.message_id is not None:
self._expire_dedup(now)
if msg.message_id in self._seen:
return False # dropped (same outcome as the original)
self._seen[msg.message_id] = now
A repeat MessageId at t=5 with a 60s window is dropped; the same id at t=70 (window
elapsed) is accepted again (test_dedup_drops_repeat_id_in_window,
test_dedup_allows_after_window). This is the producer-side half of exactly-once-effect —
the consumer-side half is idempotency (Chapter 8).
Scheduled messages. A message with ScheduledEnqueueTimeUtc in the future is enqueued
but not receivable until that time — delayed jobs, "send this reminder in 24h", retry
backoff implemented as a reschedule. The lab's scheduled_for does exactly this: receive
skips a message whose scheduled_for > now (test_scheduled_message_not_receivable_early),
and crucially does not let a future-scheduled message block a ready one behind it
(test_scheduled_does_not_block_ready_messages).
Deferred messages (described, not implemented). A consumer can Defer a message it's not
ready to handle in order — the broker sets it aside and the consumer fetches it later by
sequence number. Used for out-of-order workflow steps ("I got step 3 before step 2; defer
3"). This is an extension in the lab README.
Transactions (framing). Service Bus supports atomic operations within a transaction
scope: e.g. complete an input message and send an output message atomically — both
happen or neither does. This is how you build "process-and-forward" without a window where the
input is consumed but the output never sent. It does not make end-to-end exactly-once
across different systems — that still needs idempotency at each boundary.
Chapter 7: Sessions — Ordering Has a Price
By default a queue's competing consumers process messages in parallel and out of order — which is great for throughput and wrong when order matters (apply these bank transactions in sequence; process this customer's events as they happened). Sessions are how Service Bus gives you FIFO:
- Every message carries a
SessionId(the partition/ordering key — a customer id, an order id, a device id). - A session-enabled queue hands an entire session to one consumer at a time: the consumer accepts a session, gets its messages in order, and no other consumer can touch that session until the first releases it.
- Different sessions are processed in parallel by different consumers — so you get FIFO within a key and parallelism across keys.
session A: a1 → a2 → a3 ─locked to─▶ consumer 1 (strict order within A)
session B: b1 → b2 ─locked to─▶ consumer 2 (strict order within B)
(A and B run in parallel)
The lab models this with sessions_enabled=True: a receive that locks session A makes
all of A's messages unavailable to other consumers; the next receive skips to a
different session rather than handing out A's later messages
(test_session_single_active_consumer), and within a session strict FIFO holds
(test_session_fifo_within_session).
The price — and the principal point — is in the parallelism: your maximum useful consumer count is bounded by your number of active sessions, and one slow session is a head-of-line block for itself. Ordering is a real throughput tax. Don't pay it for a stream that doesn't need order (most analytics/notification streams don't; most financial/stateful ones do). The interview signal is asking "do we actually need order here, and at what granularity?" before reaching for sessions.
Chapter 8: Delivery Semantics — The Precise Lie of "Exactly-Once"
This is the most important chapter for an interview, because it separates people who say "exactly-once" from people who understand delivery. There are three guarantees:
| Guarantee | Mechanism | Failure mode | When |
|---|---|---|---|
| at-most-once | no retry; fire-and-forget | may lose (consumer crash = gone) | metrics you can drop; ReceiveAndDelete |
| at-least-once | retry until acknowledged | may duplicate | the practical default; PeekLock, Event Grid |
| exactly-once-effect | at-least-once + idempotent/dedup consumer | a duplicate is a no-op | financial, stateful, anything a dup corrupts |
The crucial truth: true exactly-once delivery is impossible across a network boundary. The reason is the two-generals problem — after the consumer processes a message it must acknowledge; if that ack is lost, the broker must redeliver (it can't tell "processed but ack lost" from "never processed"), so a duplicate is unavoidable. Anyone who promises exactly-once delivery either has a closed system (Kafka transactions within Kafka) or doesn't understand the problem.
What you can build is exactly-once-effect: accept that delivery is at-least-once, and make the consumer idempotent so processing the same message twice has the same effect as processing it once. The pattern:
# idempotent consumer (the consumer-side half of exactly-once-effect)
def handle(msg):
key = msg.message_id # the idempotency key the producer stamped
if already_processed(key): # a dedup store: Redis/Cosmos/a unique index
ack() # duplicate → no-op, just ack
return
with transaction():
do_the_side_effect(msg)
mark_processed(key) # atomic with the effect
ack()
classify_delivery(retries, idempotent_consumer) in the lab is exactly this truth table:
def classify_delivery(retries, idempotent_consumer):
if not retries: return "at-most-once"
if not idempotent_consumer: return "at-least-once"
return "exactly-once-effect"
And the principal framing: exactly-once-effect is a per-data-product dial, not a platform default. It costs throughput (the dedup check) and operability (the dedup store). You pay it where a duplicate is expensive — a duplicate payment is a refund and a furious customer; a duplicate ad impression is a rounding error. Ask "what does one duplicate cost, and what does one loss cost?" per stream before you reach for it.
Chapter 9: Event Grid — Reactive Routing, CloudEvents, and Retry
Event Grid is the push sibling of Service Bus's pull. It is a fully-managed
event router: a publisher emits a small event (a notification, not a payload — "blob
X was created", "resource Y changed"), and Event Grid pushes it to every subscriber
whose filter matches, over HTTP, with no consumer infrastructure to run. It is built for
massive fan-out at low operational cost ("react to X").
CloudEvents. Event Grid speaks CloudEvents 1.0, the CNCF standard envelope, so events are portable across clouds and tooling. The required and common attributes:
{
"specversion": "1.0",
"id": "a1b2c3", // unique; the idempotency key
"source": "/subscriptions/.../storageAccounts/x",// who emitted it
"type": "Microsoft.Storage.BlobCreated", // what happened (filter on this)
"subject": "/blobServices/default/containers/in/img.png", // the resource (filter on this)
"time": "2026-06-25T12:00:00Z",
"data": { "url": "https://x.blob.core.windows.net/in/img.png" }
}
Subscribers filter by type and subject (prefix/suffix), so a thumbnailer subscribes
only to BlobCreated under containers/images/ — the routing is declarative.
Delivery and retry — the heart of the lab's deliver_with_retry. Event Grid is
at-least-once: it pushes the event and waits for a 2xx from the subscriber's handler. On
a failure (a non-2xx, a timeout, or no response) it retries with exponential backoff — a
fixed, growing schedule of intervals (seconds → minutes), continuing until the event is
accepted or its TTL (up to ~24h) / max attempts are exhausted, at which point it
dead-letters to a configured blob container. The lab models this exactly and
deterministically:
# solution deliver_with_retry — first attempt at `now`, then back off per `schedule`
for attempt in range(max_retries + 1):
interval = schedule[attempt] if attempt < len(schedule) else schedule[-1]
clock += interval
record.attempts.append(clock)
try:
ok = handler(event)
except Exception:
ok = False # a raised handler is a failed delivery, like a 500
if ok:
record.delivered = True; return record
record.dead_lettered = True # retries exhausted → dead-letter to storage
return record
A handler that fails on attempts 1 and 2 then succeeds on attempt 3 is delivered at times
[0, 10, 40] (with schedule (0,10,30,…)); a handler that always fails is dead-lettered
after max_retries+1 attempts (test_event_grid_retries_on_fixed_schedule_then_succeeds,
test_event_grid_dead_letters_after_max_retries). The whole thing is deterministic —
now is injected and advanced by the schedule, never read from a clock
(test_event_grid_is_deterministic), which is the only way to test a retry policy.
The consumer is a webhook — a JWT-validated, idempotent HTTP handler exactly like the
Phase 09 gateway. Because delivery is at-least-once, that handler must be idempotent on the
CloudEvent id (Chapter 8) — Event Grid will occasionally deliver the same event twice.
Chapter 10: Event Hubs — The Streaming Sibling (for Contrast)
You will be asked "Event Hubs or Service Bus?" so hold the contrast even though the full streaming build is in another track. Event Hubs is a partitioned, append-only log — the Azure-native, Kafka-shaped firehose:
- Events are appended to partitions; order is guaranteed within a partition (keyed by a partition key), not across.
- Consumers don't settle messages; they track an offset (a position in the log) and can rewind to replay history within the retention window (1–90 days). There is no per-message lock, ack, or DLQ — the unit is the stream position, not the message.
- Throughput is sized in throughput units / processing units / partitions, scaling to millions of events/second — orders of magnitude past a broker.
So: Event Hubs is for high-volume telemetry/streaming you process in aggregate and may replay (IoT, clickstream, logs, metrics → analytics). Service Bus is for discrete commands you must not lose and may need to order, dead-letter, or transact (orders, payments, workflow steps). The tell: if you find yourself wanting per-message dead-lettering or sessions on Event Hubs, you wanted Service Bus; if you're polling a Service Bus queue at a million msg/s, you wanted Event Hubs.
Chapter 11: Logic Apps — Serverless Workflow Orchestration
The first three services move events. Logic Apps react to and orchestrate them without you writing a service. A Logic App is a serverless workflow: a trigger (an event arrives, a timer fires, an HTTP request comes in — including a Service Bus message or an Event Grid event) followed by a graph of actions drawn from hundreds of connectors (Service Bus, Office 365, Salesforce, SQL, a custom HTTP call, a condition, a loop). It is the low-code integration complement to code-first Functions (P11):
trigger: "Service Bus message in queue 'orders'"
→ action: parse JSON
→ condition: amount > 10000 ?
→ yes → action: call fraud API → action: post to Teams
→ no → action: write to SQL → action: send confirmation email
→ (built-in: retry policy, run history, per-action error handling)
Why it exists: an enormous amount of "enterprise integration" is glue — when an event happens, call these five SaaS systems in order, with retries and conditionals. Writing and operating a service for that is waste; a Logic App is a declarative workflow with built-in retries, a visual run history (every run, every action, the data that flowed — a debugging gift), and managed connectors. It is the orchestration layer that consumes the events the broker and router deliver. The principal judgment: use a Logic App for integration glue and human-in-the-loop workflows; use a Durable Function (P11) when the orchestration is code with complex state, loops, and determinism requirements. (Logic Apps and Durable Functions are the two "workflow" answers; knowing which to reach for is the signal.)
Lab Walkthrough Guidance
Lab 01 — Messaging Broker, suggested order (each step has tests that go green in turn):
Message.__init__— validatemessage_id(non-empty string or None),session_id(string or None),scheduled_for(number or None); initdelivery_count=0,dead_letter_reason=None. (Ch. 3)Broker.__init__+send— validate construction; enforce the session_id rule (required iffsessions_enabled); implement duplicate detection (drop a repeatmessage_idinsidededup_window) and_expire_dedup. (Ch. 6)receive+_next_receivable_index— peek-lock: calltick, find the next receivable message (skip future-scheduled_for, skip already-locked sessions), pop it,delivery_count++, mint a lock token, record the_LockEntry, lock the session. (Ch. 4, 7)complete/abandon/dead_letter+_take_lock/_release_session/_requeue_or_dead_letter— settle (remove), release-now (requeue or DLQ), explicit DLQ. Get the>= max_delivery_countboundary right. (Ch. 4, 5)tick— reclaim every lock withlock_until <= now(the off-by-one), in stable order, routing each through_requeue_or_dead_letter. (Ch. 4)deliver_with_retry— first attempt atnow, back off perschedule(clamp at the last interval), treat a raised handler as failure, dead-letter when retries exhaust. (Ch. 9)classify_delivery— the three-way semantics map. (Ch. 8)
Run pytest test_lab.py -v after each step and watch the relevant tests flip green. Then
python solution.py to see all six mechanisms in one worked example.
Success Criteria
You are ready for Phase 11 when you can, from memory:
- Name the three event-plane shapes (broker/router/log = Service Bus/Event Grid/Event Hubs) and the one decision that picks between them.
- Draw the peek-lock state machine: receive→lock→complete/abandon/dead-letter; lock-expiry→
redeliver +
DeliveryCount++;MaxDeliveryCount→ DLQ. - Explain the lock-expiry off-by-one (
now >= lock_untilis expired) and why abandon and lock-expiry reach the same redelivery effect two ways. - State the three delivery semantics, prove true exactly-once delivery is impossible across a network, and describe the idempotent-consumer pattern that gives exactly-once-effect.
- Explain duplicate detection (producer side) vs idempotency (consumer side) as the two halves of exactly-once-effect.
- Say what sessions cost (throughput / bounded parallelism) and when ordering is required.
- Sketch an Event Grid CloudEvent, its
type/subjectfilter, its backoff-retry policy, and its dead-letter-to-storage target. - Choose between a Logic App and a Durable Function for a given orchestration.
Interview Q&A
Q: A consumer pulls a Service Bus message and then the process crashes. Walk me through
exactly what happens to that message.
In PeekLock mode the message was locked, not removed — it's invisible to other consumers
until lock_until = receiveTime + LockDuration, and DeliveryCount was already incremented.
Because the crashed consumer never called complete, the lock simply expires; the broker then
makes the message visible again and redelivers it (to this or another consumer), incrementing
DeliveryCount again. So nothing is lost — the crash-safety is automatic. The one nuance: a
slow but alive consumer must renew the lock (RenewMessageLockAsync) or it'll be treated as
crashed and get a duplicate; and a message that keeps failing climbs DeliveryCount until it
hits MaxDeliveryCount and dead-letters, which is what stops a poison message from looping
forever.
Q: Is your event pipeline exactly-once? Yes or no, and prove it.
No — and nothing across a network is. Delivery is at-least-once: after my consumer processes a
message it must ack, and if that ack is lost the broker can't distinguish "processed, ack
lost" from "never processed," so it must redeliver — a duplicate is unavoidable (the
two-generals problem). What I build is exactly-once-effect: at-least-once delivery plus an
idempotent consumer keyed on the producer's MessageId/CloudEvent id, so a duplicate is
a no-op. I also turn on duplicate detection at the broker for producer-side retries. I apply
that per data product, sized by what a duplicate costs — a duplicate payment gets it, a
duplicate analytics impression doesn't.
Q: Service Bus, Event Grid, or Event Hubs — and how do you decide? By the shape of the workload. A discrete command I must not lose and might need to order or transact → Service Bus (broker: pull, lock, settle, sessions, DLQ). A notification I want many things to react to, push-style, with no consumer to operate → Event Grid (router: CloudEvents, at-least-once push, retry, dead-letter to storage). A firehose of telemetry I process in aggregate and may replay → Event Hubs (partitioned log, offsets, millions/s). The mistake I watch for is reaching for Event Hubs because "high throughput" sounds good, then needing per-message dead-lettering and ordering — that was Service Bus all along.
Q: What does MaxDeliveryCount actually protect against, and what's a sane value?
Poison messages and head-of-line blocking. A message that can never be processed —
malformed payload, bug, deleted dependency — would otherwise be abandoned and redelivered
forever, burning the consumer and blocking the queue behind it. MaxDeliveryCount caps
deliveries; after that many attempts the message dead-letters (off the hot path) and an alert
fires (a human triages). The default 10 is reasonable for transient failures; I'd lower it
if my failures are usually deterministic (no point retrying a parse error 10 times) and pair
it with a DLQ-depth alert and a documented replay runbook. The DLQ is a bulkhead, not a
graveyard — the failure mode is a DLQ nobody watches.
Q: Why does ordering cost throughput?
Because order requires a single writer per ordering key. A session locks an entire SessionId
to one consumer, in sequence — so your useful parallelism is bounded by the number of active
sessions, not the number of consumers, and one slow session is a head-of-line block for
itself. Without sessions, competing consumers drain the queue in parallel and out of order at
full throughput. So I only enable sessions where order is genuinely required, and I choose the
finest ordering granularity that's still correct (per-customer, not global) to keep
parallelism high.
Q: Walk me through Event Grid's retry behavior and where a permanently-failing event ends
up.
Event Grid is at-least-once push. It POSTs the CloudEvent to the subscriber's webhook and
waits for a 2xx. On a non-2xx, timeout, or no response, it retries on an exponential
backoff schedule — growing intervals from seconds to minutes — and keeps retrying until the
event is accepted or its TTL (up to ~24h) / max attempts are exhausted. At that point it
dead-letters the event to a configured blob container, where I can inspect and replay it.
Because it's at-least-once, the webhook has to be idempotent on the CloudEvent id. In the
lab, deliver_with_retry models exactly this deterministically — first attempt at now, back
off per a fixed schedule, treat a thrown handler like a 500, dead-letter when retries run out.
Q: When do you use a Logic App vs a Durable Function? Both orchestrate multi-step workflows. I reach for a Logic App for integration glue — "when this event arrives, call these SaaS connectors in order with retries and a condition" — because it's declarative, has hundreds of managed connectors, and gives me a visual run history that's a debugging gift, with no code to operate. I reach for a Durable Function when the orchestration is code: complex state, dynamic fan-out/fan-in, loops, and tight determinism requirements (P11). Rule of thumb: connectors and human-in-the-loop → Logic App; algorithmic, stateful, code-first orchestration → Durable Function.
References
- Azure Service Bus — Service Bus messaging overview, queues/topics/subscriptions, message transfers, locks, and settlement (peek-lock), dead-letter queues, duplicate detection, message sessions, scheduled and deferred messages: https://learn.microsoft.com/azure/service-bus-messaging/
- Azure Event Grid — Event Grid overview, event delivery and retry, dead-letter and retry policies, event filtering, CloudEvents schema: https://learn.microsoft.com/azure/event-grid/
- CloudEvents 1.0 specification (CNCF) — the event envelope Event Grid speaks: https://github.com/cloudevents/spec and https://cloudevents.io/
- Azure Event Hubs — Event Hubs overview, partitions, consumers, and offsets (for the Service Bus vs Event Hubs contrast): https://learn.microsoft.com/azure/event-hubs/
- Azure Logic Apps — Logic Apps overview, triggers, actions, and connectors, Standard vs Consumption: https://learn.microsoft.com/azure/logic-apps/
- Azure Functions — Service Bus and Event Grid triggers/bindings (the consumer side, P11): https://learn.microsoft.com/azure/azure-functions/
- Gregor Hohpe & Bobby Woolf, Enterprise Integration Patterns — the canonical messaging patterns (Competing Consumers, Dead Letter Channel, Idempotent Receiver, Message Sessions).
- Martin Kleppmann, Designing Data-Intensive Applications, Ch. 11 (Stream Processing) — the delivery-semantics and exactly-once-effect treatment from first principles.
- The track's own CHEATSHEET.md (the messaging numbers) and GLOSSARY.md (every term, defined).
🛸 Hitchhiker's Guide — Phase 10: Event-Driven Messaging
Read this if: you can click "Create Service Bus queue" but "design our event-driven platform" and "is this exactly-once?" still make you sweat. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the broker.
0. The 30-second mental model
Three event services, three shapes, one decision:
Service Bus = BROKER → pull, LOCK, settle. Commands you must not lose, order, transact, DLQ.
Event Grid = ROUTER → push, retry, dead-letter. "React to X" fan-out at scale, no consumer to run.
Event Hubs = LOG → stream, offsets, replay. Telemetry firehose (millions/s), process in aggregate.
One sentence to tattoo: decoupling is a buffer plus a delivery guarantee, and the guarantee is at-least-once — so duplicates aren't a bug, they're the contract, and your consumer must be built for them.
1. The peek-lock state machine (draw this in the interview)
receive(now) → LOCKED (invisible until now+LockDuration), DeliveryCount++
├─ complete → removed (success)
├─ abandon → back to READY *now* (DeliveryCount already ++)
├─ dead_letter → DLQ with a reason
└─ lock expires → back to READY (consumer crashed/too slow), redeliver, DeliveryCount++
└─ once delivered MaxDeliveryCount times → DLQ instead of redeliver
The off-by-one: a lock is expired at now >= lock_until (inclusive). Received at t=0 with
LockDuration=10 → locked through t=9, back at exactly t=10.
2. The numbers to tattoo on your arm
| Thing | Number |
|---|---|
Service Bus LockDuration | default 30s, max 5 min (renew for longer handlers) |
MaxDeliveryCount | default 10 → then dead-letter |
| Duplicate-detection window | up to 7 days |
| Message TTL | default 14 days (then DLQ) |
| Message size | 256 KB Standard, up to 100 MB Premium |
| Sessions / transactions | Premium-friendly; sessions = FIFO + 1 active consumer per SessionId |
| Event Grid delivery | at-least-once, exponential backoff, retry to ~24h, then dead-letter to blob |
| Event Hubs retention | 1–90 days; order per partition; scale = throughput units / partitions |
| Strong delivery truth | true exactly-once delivery is impossible across a network; build exactly-once-effect |
3. Delivery semantics (the whole interview, compressed)
no retries → at-most-once (may LOSE) e.g. ReceiveAndDelete
retries, dumb consumer → at-least-once (may DUPLICATE) the practical default
retries + idempotent consumer → exactly-once-EFFECT (dup = no-op) the only "exactly-once" there is
Two halves of exactly-once-effect: producer stamps MessageId/dedup window;
consumer is idempotent on that id (dedup store + atomic mark-processed). Apply it
per data product, sized by what one dup vs one loss costs.
4. The decision tree (say this out loud)
Must-not-lose command? need order/transactions/DLQ? ───────────────▶ Service Bus
"Something happened, many things react", push, huge fan-out? ──────▶ Event Grid
Telemetry firehose, replay, partitioned, millions/s? ─────────────▶ Event Hubs
Glue together SaaS systems / human-in-the-loop workflow? ─────────▶ Logic App
Code-first stateful orchestration with determinism? ──────────────▶ Durable Function (P11)
5. az one-liners (your subscription, after the lab)
# Service Bus: namespace → queue with sessions, dedup, DLQ-on-expiry
az servicebus namespace create -g rg -n myns --sku Premium
az servicebus queue create -g rg --namespace-name myns -n orders \
--max-delivery-count 5 --lock-duration PT1M \
--enable-session true --requires-duplicate-detection true \
--default-message-time-to-live PT14D --dead-lettering-on-message-expiration true
az servicebus queue show -g rg --namespace-name myns -n orders \
--query "countDetails" # activeMessageCount, deadLetterMessageCount ← your alert
# Topic + filtered subscription (pub/sub)
az servicebus topic create -g rg --namespace-name myns -n events
az servicebus topic subscription create -g rg --namespace-name myns \
--topic-name events -n high-value
az servicebus topic subscription rule create -g rg --namespace-name myns \
--topic-name events --subscription-name high-value -n amt \
--filter-sql-expression "amount > 10000"
# Event Grid: topic + a webhook subscription with filters, retry policy, and dead-letter
az eventgrid topic create -g rg -n mytopic -l eastus
az eventgrid event-subscription create -n thumbnailer \
--source-resource-id $(az eventgrid topic show -g rg -n mytopic --query id -o tsv) \
--endpoint https://api.example.com/hooks/thumb \
--included-event-types Microsoft.Storage.BlobCreated \
--subject-begins-with /blobServices/default/containers/images/ \
--max-delivery-attempts 5 --event-ttl 1440 \
--deadletter-endpoint /subscriptions/.../containers/eg-deadletter
Terraform shape: azurerm_servicebus_namespace / _queue (max_delivery_count,
lock_duration, requires_session, requires_duplicate_detection) and
azurerm_eventgrid_event_subscription (retry_policy { max_delivery_attempts, event_time_to_live },
storage_blob_dead_letter_destination).
6. War story shapes you'll relive
- "We charged the customer twice." → at-least-once redelivered the payment message and the
consumer wasn't idempotent. Fix is a dedup key on
MessageId+ atomic mark-processed, not "turn on exactly-once" (there's no such switch). - "The queue is stuck and nothing processes." → a poison message at the head, abandoned and
redelivered forever, blocking everything behind it. Fix:
MaxDeliveryCount→ DLQ + an alert on dead-letter count. (Head-of-line blocking is especially nasty with sessions.) - "Messages get reprocessed every ~30s for no reason." → handler runs longer than
LockDuration, the lock expires mid-process, the broker redelivers. Fix: renew the lock or raiseLockDuration(max 5 min) — and make the handler idempotent so the dup is harmless. - "We can't keep up; throughput tanked when we added ordering." → sessions cap parallelism at the active-session count. Fix: finer ordering key (per-customer not global) or drop ordering where it isn't required.
- "Event Grid stopped delivering." → the webhook returned 500s, Event Grid backed off and eventually dead-lettered to the blob you forgot to watch. The events are in the dead-letter container, not lost — go drain it.
- "We picked Event Hubs and now we need per-message DLQ and sessions." → you wanted Service Bus. Event Hubs has offsets, not locks; there's no per-message settle or dead-letter.
7. Vocabulary that signals you've held the pager
- Peek-lock / settle — receive-lock, then complete/abandon/dead-letter. The crash-safety mechanism.
DeliveryCount/MaxDeliveryCount— the redelivery counter and the poison-message cap.- DLQ (dead-letter queue) — a bulkhead for unprocessable messages, not a graveyard.
- At-least-once / exactly-once-effect — the real guarantee, and the only "exactly-once."
- Idempotency key — the producer's
MessageId/CloudEventidthe consumer dedups on. - Session /
SessionId— FIFO per key, single active consumer; ordering's throughput tax. - Duplicate detection — the producer-side dedup window on
MessageId. - CloudEvents — the CNCF envelope (
id/source/type/subject/time/data) Event Grid speaks. - Head-of-line blocking — one stuck message blocking everything behind it.
- Competing consumers — many consumers pulling one queue for parallel work distribution.
8. Beginner mistakes that mark you in interviews
- Saying "exactly-once" without "-effect" — instant tell you haven't built a real consumer.
- Reaching for Event Hubs because "high throughput" sounds good, then needing DLQ/sessions.
- Forgetting the consumer must be idempotent because delivery is at-least-once.
- Using
ReceiveAndDelete(at-most-once) for anything that matters, then losing messages. - No DLQ alert — the poison messages pile up silently until someone notices the gap.
- Enabling sessions "to be safe" and tanking throughput for a stream that didn't need order.
- A handler slower than
LockDurationwith no lock renewal → mysterious constant redelivery. - Treating Event Grid delivery as guaranteed-once instead of at-least-once-with-retry.
9. How this phase pays off later
- P11 (Serverless) — a Service Bus / Event Grid trigger invokes a Function; the scale controller sizes instances from the queue backlog you produce here; Durable Functions are the code-first sibling of Logic Apps.
- P12 (Key Vault / MI) — the secret-free Managed Identity a consumer authenticates
with; data-plane RBAC (
Service Bus Data Receiver,EventGrid Data Sender) gates it. - P13 (Observability) —
DeliveryCount, active/dead-letter counts, and retry rates are the alerts that catch a stuck consumer before customers do. - P14 (Reliability) — retry+backoff+jitter, the DLQ-as-bulkhead, and at-least-once+idempotency are the reliability primitives, applied to messaging.
- P15 (Capstone) — the event-driven serverless platform is this broker + Event Grid + Functions, wired under a landing zone.
Now read the WARMUP slowly, then build the broker. The peek-lock state machine you implement is the one you'll draw on a whiteboard for the rest of your career.
👨🏻 Brother Talk — Phase 10, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. This phase is where a lot of strong engineers quietly get exposed, and I want you to be the one who doesn't. Here's why: messaging looks easy. You create a queue, you send a message, you receive it, it works in the demo, everyone claps. And then six months later production charges a customer twice, or loses an order, or a queue gets stuck and the on-call spends four hours figuring out why — and it turns out nobody on the team actually understood the delivery guarantee. They understood the API. They didn't understand the contract.
So let me give you the thing that took me too long to learn:
The default guarantee is at-least-once, which means duplicates will happen. That's not a failure mode — it's the contract. Your entire job in this space is to decide, per stream, whether a duplicate is harmless, and if it isn't, to engineer it away.
Say that back to yourself until it's reflex. Because the single most common way I've watched people fail an event-driven design interview is the word "exactly-once." They say it breezily — "oh, we'll just do exactly-once" — and every senior person in the room quietly writes them off, because there is no such thing across a network. The moment you say "exactly-once" without the word "effect" after it, you've told the room you've never actually debugged a duplicate at 2 a.m. The person who says "delivery is at-least-once, so I make the consumer idempotent on the message id — that's exactly-once-effect, and I turn it on where a duplicate is expensive" — that person has clearly held the pager. Be that person. It costs you nothing but a single word, and it changes how they see you for the rest of the conversation.
Now the mindset shifts, brother to brother.
Fall in love with the boring failure path. Everyone designs the happy path — message goes in, message gets processed, confetti. Nobody wants to talk about the message that can't be processed. But that's where all the real engineering is: the poison message, the crashed consumer, the lock that expired mid-process, the DLQ filling up. When you're the person who, in a design review, asks "okay, but what happens to a message that fails every time? where does it go, who gets paged, what's the replay runbook?" — you've just demonstrated that you've operated this stuff. The DLQ is not a detail. The DLQ is the proof you've been on call. A platform without a watched dead-letter queue and a replay plan is a platform run by people who've never had a bad night.
Idempotency is the whole game, and it's a consumer-side discipline, not a feature you buy.
There is no "make it exactly-once" toggle. There is you, stamping a stable id on the producer
side and writing a consumer that checks "have I already done this?" before it does the
side-effect, atomically. That's it. That's the magic. The reason I keep hammering idempotency
across this whole track — ARM's PUT, Terraform apply, and now message consumers — is that
it's the same idea everywhere, and the engineers who internalize it stop being afraid of
retries. Retries become free. Crashes become free. Replay becomes free. You build systems that
heal instead of systems that corrupt, and the difference is one habit.
Match the shape, not the buzzword. I've seen so many people reach for Event Hubs because "millions of events per second" sounds like a flex, and then spend a month bolting per-message dead-lettering and ordering onto a thing that fundamentally doesn't have them — because they needed Service Bus. And I've seen people poll a Service Bus queue in a tight loop to do reactive fan-out, reinventing Event Grid badly. Hold the three shapes in your head — broker, router, log — and pick by the workload, not by which service has the most impressive throughput number on its marketing page. "It's a command I can't lose" and "it's a notification I want to react to" and "it's a firehose I want to replay" are three different sentences that pick three different services, and saying which sentence applies is the architecture.
The honest truth about why this is hard: it's not the API — you'll learn the SDK in an
afternoon. It's that messaging makes you reason about time and failure simultaneously, and
human brains hate that. "The message is locked until a time in the future, unless the consumer
crashes, in which case it comes back after that time, having incremented a counter, unless the
counter crossed a threshold, in which case it goes elsewhere." That sentence has four
branches and a clock in it, and you have to hold all of it. That's exactly why the lab injects
time as a parameter and makes you build the state machine — because once you've written tick()
and watched DeliveryCount climb and the message land in the DLQ deterministically, the clock
stops being scary. You've seen the gears. You can debug it now, because you built it.
One career thing, and it's the real one. The engineers who own the event platform in a company have quiet, enormous leverage, because everything flows through them. Orders, payments, notifications, telemetry — it's all messages, and when it breaks, it breaks loudly and visibly and across teams. If you're the person who designed it so that a crash is a redelivery and not a data-loss, so that a poison message is a DLQ alert and not a 3 a.m. outage, so that a duplicate is a no-op and not a double-charge — you're not just an engineer, you're the person the org's reliability rests on. That's principal. Not because you knew Service Bus had sessions. Because you understood, deeply, what happens to a message when things go wrong, and you built for it on purpose.
Go build the broker. Write the tick() function, watch the message get redelivered and land in
the DLQ, and feel the moment the state machine clicks. Then come find me in Phase 11 — that's
where these messages start triggering serverless compute, and we make the scale controller and
Durable replay click the same way.
— your brother 👨🏻
Lab 01 — Messaging Broker (Service Bus peek-lock + Event Grid retry)
Phase: 10 — Event-Driven: Event Grid, Service Bus & Logic Apps Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Every "we lost a message" and every "we processed it twice" incident is one of two mechanisms misunderstood: the peek-lock state machine (lock → process → complete/abandon/dead-letter, with lock-expiry redelivery and a max-delivery poison guard) or at-least-once push delivery with backoff retry (Event Grid). This lab builds both engines — deterministically, offline, with time injected — so the redelivery off-by-one, the FIFO-session lock, the dedup window, and the dead-letter transition stop being folklore and become code you can defend in an interview and reason about at 2 a.m.
What you build
Message(body, message_id, session_id, scheduled_for)— the unit the broker locks, counts, and dead-letters; carries mutabledelivery_countanddead_letter_reason.Broker— a single Service Bus queue (or one topic-subscription) with the full peek-lock machine:send(msg, now)— honors duplicate detection (a repeatmessage_idinsidededup_windowis silently dropped) and scheduled messages (scheduled_forin the future is not receivable yet).receive(now) -> (msg, lock_token) | None— peek-lock: the message goes invisible untilnow + lock_duration; FIFO, and FIFO-per-session with a single active consumer when sessions are enabled.complete/abandon/dead_letter— settle (remove), release the lock now (redeliverable,delivery_countalready climbed), or move to the DLQ with a reason.tick(now)— when a lock expires (now >= lock_until), redeliver and incrementdelivery_count; once a message has been deliveredmax_delivery_counttimes it goes to the dead-letter queue instead of being redelivered.dlq()— the dead-letter queue accessor.
deliver_with_retry(handler, event, *, schedule, max_retries, now) -> Delivery— the Event Grid push path: callhandler(event), and on failure (falsy return or a raised exception) retry after fixed exponential-backoff intervals; aftermax_retriesare exhausted, dead-letter. Returns the timestamps of every attempt and the outcome.classify_delivery(retries, idempotent_consumer)—at-most-once/at-least-once/exactly-once-effect.
Key concepts
| Concept | What to understand |
|---|---|
| Peek-lock | receive locks a message invisible; you must settle it or the lock expires |
| Lock expiry → redelivery | now >= lock_until ⇒ redelivered, DeliveryCount++ (the off-by-one) |
| Max-delivery → DLQ | delivered at most max_delivery_count times, then dead-lettered |
| Abandon vs lock-expiry | abandon redelivers now; expiry redelivers after the lock — both ++ |
| Duplicate detection | a repeat MessageId inside the window is dropped (idempotent producer) |
| Scheduled messages | enqueued but not receivable until scheduled_for |
| Sessions | FIFO per session_id, one active consumer per session |
| Event Grid retry | at-least-once + fixed exponential backoff, then dead-letter to storage |
| Delivery semantics | at-most / at-least / exactly-once-effect (the only one that exists) |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs a six-part worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example
Success criteria
- All tests pass against your
lab.pyand againstsolution.py. - You can explain why
test_lock_expiry_at_exact_boundary_is_expiredtreatsnow == lock_untilas expired (the lock has elapsed, so the message is back). - You can explain why, with
max_delivery_count=2, the poison message intest_max_delivery_count_moves_to_dlq_via_abandonreaches the DLQ atdelivery_count == 2— "delivered at most twice," not "twice then one more." - You can explain why
test_session_single_active_consumerskips to session B instead of draining the rest of session A (one active consumer per session = the FIFO guarantee). - You can explain why
deliver_with_retrytreats a raised exception identically to aFalsereturn, and why a duplicate at-least-once delivery is harmless iff the consumer is idempotent (classify_delivery).
How this maps to real Azure
| In the lab | In Azure Service Bus / Event Grid |
|---|---|
Broker(lock_duration=…) | a queue/subscription's LockDuration (max 5 min); the receiver renews or loses the lock |
receive returning a lock token | PeekLock receive mode (vs ReceiveAndDelete = at-most-once) |
complete / abandon / dead_letter | CompleteMessageAsync / AbandonMessageAsync / DeadLetterMessageAsync |
tick redelivery on lock expiry | the broker's lock timeout → automatic redelivery, DeliveryCount++ |
max_delivery_count → DLQ | MaxDeliveryCount (default 10) → the entity's $DeadLetterQueue |
dedup_window | RequiresDuplicateDetection + DuplicateDetectionHistoryTimeWindow |
scheduled_for | scheduled messages (ScheduledEnqueueTimeUtc / ScheduleMessageAsync) |
sessions_enabled / session_id | RequiresSession + SessionId (FIFO, single accepted-session consumer) |
deliver_with_retry schedule + dead-letter | Event Grid at-least-once push, exponential-backoff retry policy (to ~24 h / max attempts), then dead-letter to a storage account |
classify_delivery | the guarantee you actually engineer: at-least-once + an idempotent consumer = "exactly-once" |
Limits worth memorizing (Service Bus Standard/Premium): LockDuration ≤ 5 min;
MaxDeliveryCount default 10; dedup window up to 7 days; message TTL default 14 days;
Standard message size 256 KB, Premium up to 100 MB; sessions and transactions are
Premium-friendly features. Event Grid: at-least-once, retry with backoff up to 24 h,
dead-letter to a blob container. (The lab models the mechanism; the exact numbers
move with tier and are in the phase HITCHHIKERS-GUIDE.md.)
What the lab deliberately simplifies
- No lock renewal — real receivers call
RenewMessageLockAsyncto extend a lock for a long-running handler. The lab models the consequence of not renewing (expiry → redeliver). - No deferral / transactions —
Defer/ReceiveDeferredMessageand atomic send-and-complete transactions are described inWARMUP.mdbut not implemented (an extension below). - Logical time only — Azure uses UTC wall-clock with lock-duration arithmetic; the lab
injects integer
nowso every test is deterministic. The arithmetic is identical.
Extensions
- Lock renewal: add
renew_lock(token, now, extend_by)that pusheslock_untilforward (and rejects renewal of an already-expired lock) — model a long-running handler. - Deferred messages: add
defer(token)+receive_deferred(sequence_number, now)so a consumer can set a message aside and fetch it by number later (out-of-order processing). - Topics & subscriptions with filters: wrap N
Brokersubscriptions behind aTopicthat fans a published message to every subscription whose SQL/correlation filter matches — the Event GrideventType/subjectfilter, but pull-based. - Transactions: a
transaction()context that makescomplete(in) + send(out)atomic (the classic "process and forward exactly once within the broker" pattern). - Real Azure (your subscription): provision a namespace, queue, and Event Grid topic
with
az servicebus/az eventgrid(one-liners in the phase guide), publish a CloudEvent, and watchDeliveryCount, the DLQ, and the dead-letter blob fill up — then map each back to a function in this file.
Interview / resume bullets
- "Implemented a Service Bus-style broker with peek-lock receive, lock-expiry redelivery
and
DeliveryCounttracking, duplicate detection, scheduled messages, FIFO sessions with a single active consumer, and a max-delivery dead-letter queue." - "Built an Event Grid-style at-least-once push-delivery engine with deterministic exponential-backoff retry and dead-lettering, and a delivery-semantics classifier (at-most / at-least / exactly-once-effect)."
Phase 11 — Serverless: Functions, Triggers/Bindings & Durable
Difficulty: ⭐⭐⭐☆☆ (the model) → ⭐⭐⭐⭐⭐ (the judgment about scale, cold start, and determinism) Estimated Time: 1 week (12–18 hours) Prerequisites: Phase 10 (Event Grid / Service Bus — the event sources that trigger functions and whose backlog the scale controller reads), Phase 03 (Entra — Managed Identity authenticates a function's outbound calls, P12), and Phase 00 (control plane vs data plane; quantify the tradeoff). No Azure subscription required for the lab.
Why This Phase Exists
Phase 10 built the event plumbing — Service Bus queues, Event Grid delivery. This phase builds the thing on the other end of the wire: the compute that reacts to those events without a server you manage. "Serverless" is a marketing word; the engineering reality is two precise mechanisms, and a principal must own both.
The first is the scale controller. A senior engineer says "Functions autoscales." A principal can tell you how: the controller polls the trigger's event source, reads the backlog (queue depth, Service Bus message count, Event Hub lag), and computes a desired instance count from a per-instance target — then clamps it to the plan's ceiling and ramps it gradually so a burst doesn't stampede the source. And critically, on Consumption it scales to zero when the work drains, which is exactly where cold start comes from: there is no warm instance for the next event, so someone pays the startup latency. "Why is my first request after lunch slow?" is a scale-to-zero question, and the answer (Premium/Flex pre-warmed instances, or keep-alive) is a cost decision you make with numbers.
The second — and the star of this phase — is Durable Functions' replay model. This is
the single most misunderstood mechanism in Azure serverless, and the one that separates
people who've read about Durable from people who've operated it. A Durable orchestrator
is not a long-running process sitting in memory. It is a function that gets replayed
from its recorded history every time an event arrives — event sourcing applied to your
workflow code. Each replay re-executes the orchestrator from the top; already-completed
activities replay their recorded results, and only the next uncompleted step actually
runs. The shattering consequence: your orchestrator must be deterministic. A
DateTime.Now, a Guid.NewGuid(), a random(), an await httpClient.GetAsync() directly
in the orchestrator — anything that returns a different value on the next replay — corrupts
the workflow, because the replay no longer matches history. This is the bug that pages
people at 2 a.m. and the question that filters principals in interviews. This phase makes
you build the replay engine so the determinism rule stops being a warning in the docs
and becomes a mechanism you can explain line by line.
What "Principal-Level" Means Here
A senior engineer writes a function that works. A principal understands the runtime well enough to:
- Predict scale and cold start from the trigger. Given "a queue with bursty 10k-message spikes," they can say how many instances the controller will reach, how fast it ramps, where it caps, and whether the workload needs Premium's pre-warmed instances to avoid cold-start latency on the critical path — with numbers, before the load test.
- Pick the hosting plan on sight. Consumption (pay-per-execution, scale-to-zero, cold start) for spiky background work; Premium / Flex Consumption (pre-warmed instances, VNet integration, no cold start) for latency-sensitive or network-isolated work; Dedicated (App Service plan) when you already pay for the compute. Choosing wrong is either a cold- start incident or a bill for idle instances.
- Reason about Durable replay from first principles. They can explain why an
orchestrator is replayed, why that forces determinism, what a non-deterministic
orchestrator does to a running workflow, and exactly which APIs (
context.df.*,current_utc_datetime,new_guid) exist because the raw language primitives are unsafe under replay. - Choose the right Durable pattern. Function chaining (sequential steps), fan-out/fan-in (parallel work + aggregate), async HTTP (long job + polling status endpoint), monitor (recurring check with a durable timer), and human interaction (wait for an external event with a timeout) — each is a named pattern with a known shape, and naming the right one is the design answer.
- Wire identity and events correctly. A function authenticates outbound with a Managed Identity (P12), is triggered by Service Bus / Event Grid (P10), and is fronted by API Management (P09) when it's an HTTP API — the serverless tier is the integration point of the whole platform, not an island.
Concepts
- The Functions model: trigger → function → bindings. Every function has exactly one
trigger — the event that invokes it (
httpTrigger,queueTrigger,timerTrigger,eventGridTrigger,serviceBusTrigger,eventHubTrigger). Bindings declaratively wire inputs and outputs (read a blob in, write a Cosmos document out) so you don't write client code for the plumbing. The host/runtime loads your function, invokes it on each trigger event, and applies the bindings. - The scale controller. A separate component that watches the trigger's event source and
decides instance count. Target-based scaling (the modern default for queue, Service
Bus, Event Hub, Cosmos, Storage triggers) computes
desired = ceil(backlog / target_per_instance)— a per-instance work target — then clamps to the plan's max instances and ramps gradually (a bounded number added per decision) so it doesn't stampede the source. It scales to zero on Consumption when the backlog drains. - Cold start. Scale-to-zero's cost: when no instance is warm, the next event must spin one up (load the runtime, your code, dependencies), adding latency — typically hundreds of ms to a few seconds depending on language and package size. Premium and Flex Consumption keep pre-warmed instances ready to eliminate it (for a price).
- Hosting plans. Consumption — pay per execution (GB-seconds + executions), scale-to-zero, cold start, ~200 instance cap. Flex Consumption — newer: per-second billing, fast scale, always-ready instances option, VNet integration. Premium (Elastic) — pre-warmed instances, no cold start, VNet, longer runs. Dedicated (App Service) — runs on a plan you already pay for; no scale-to-zero. The plan is the cost/latency/isolation tradeoff.
- Durable Functions = stateful orchestration via event sourcing. An orchestrator
coordinates activity functions (the side-effecting units). The Durable extension
persists the orchestration's history (an append-only log of
TaskScheduled/TaskCompletedevents) and replays the orchestrator function from the top on every event — feeding recorded results for completed activities and executing only the next step. Because the orchestrator is re-run, it must be deterministic: no real clock, no randomness, no GUIDs, no I/O outside activities — use thecontextAPIs (call_activity,current_utc_datetime,new_guid, durable timers) instead. - The Durable patterns. Function chaining (
A → B → C, each feeding the next). Fan-out/fan-in (schedule N activities in parallel, await all, aggregate). Async HTTP API (a starter returns202with astatusQueryGetUri; the client polls). Monitor (a recurring check on a durable timer until a condition is met). Human interaction (wait for an external event, raced against a durable timer for the timeout). Knowing which to reach for is the design.
Labs
Lab 01 — Functions Runtime: Scale Controller + Durable Replay (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build a miniature Functions runtime: a target-based scale controller (backlog → desired instances with scale-to-zero, a max_instances clamp, and a per-step burst limit), a trigger/binding dispatcher routing an event to the matching function and collecting its output bindings, and — the centerpiece — a Durable replay engine that runs an orchestrator generator by replaying it against a recorded history (function chaining and fan-out/fan-in), serves a deterministic current_utc() from history, and raises DeterminismError on a divergent replay |
| Concepts | target-based scaling; scale-to-zero & cold start; max-instance clamp; gradual scale-out; trigger vs binding; orchestrator-as-generator; event-sourced replay; determinism; non-determinism detection; deterministic replay-safe time |
| Steps | 1. desired_instances / scale_decision (clamp + per-step limit + scale-to-zero); 2. Trigger/Binding/FunctionDef/dispatch; 3. run_orchestrator — the cursor-driven replay loop (replay recorded steps, execute the next, append to history); 4. fan-out/fan-in (yield a list → list of results); 5. deterministic current_utc() from history; 6. DeterminismError on divergence |
| How to Test | pytest test_lab.py -v — 22 tests: scale math incl. scale-to-zero, max clamp, and per-step burst limit; the scale-decision signal; dispatch routing + output bindings; chaining final result and identical call order on partial vs full replay; no re-execution of recorded activities; fan-out/fan-in aggregation; DeterminismError on a divergent orchestrator and on a changed input; deterministic time from history |
| Talking Points | "How does the scale controller decide instance count, and where does cold start come from?" / "Why must a Durable orchestrator be deterministic, and what breaks if it calls DateTime.Now?" / "Walk me through fan-out/fan-in and how replay rebuilds state without re-running completed activities." / "Consumption vs Premium vs Flex — when do you pay for pre-warmed instances?" |
| Resume bullet | Built a miniature Azure Functions runtime — a target-based scale controller (backlog→instances with scale-to-zero, max-instance clamp, and per-step burst limiting) and an event-sourced Durable Functions replay engine (chaining, fan-out/fan-in, replay-safe deterministic time, and non-determinism detection) |
→ Lab folder: lab-01-functions-runtime/
Integrated-Scenario Suggestions (carried through the whole track)
The phases compound. Keep these in mind as you build — each wires the serverless tier into the larger platform:
- Order-processing pipeline (chaining + fan-out). A
serviceBusTriggerfunction starts a Durable orchestrator that validates the order (activity), fans out to reserve inventory across warehouses in parallel (fan-out/fan-in), then charges payment and emits a confirmation event to Event Grid (P10). Every step is an idempotent activity so the orchestration is replay-safe. (→ P10, P12) - Async long-running job with polling. An HTTP-triggered starter kicks off a Durable
orchestration and returns
202 Acceptedwith a status URL; the client polls untilCompleted. API Management (P09) fronts both the start and the status endpoints and validates the caller's JWT (P03). This is the async HTTP API pattern done right — no held connections, no timeouts. (→ P09, P03) - Scheduled monitor with human approval. A
timerTriggerstarts a monitor orchestration that polls a resource on a durable timer; on a threshold breach it waits for an external approval event raced against a 72-hour timeout (human interaction). The function authenticates to Key Vault and the target API via Managed Identity (P12). (→ P12, P10) - Event-driven enrichment at scale. An
eventHubTriggerfunction processes telemetry; the scale controller reads partition lag and scales out (capped by partition count) to keep up, then back to zero overnight. You size the max instances, predict the cold-start behavior on the first morning event, and decide whether the latency SLA forces Premium pre-warmed instances. (→ P10, P00 latency budgets) - Secure serverless integration. Every function uses a Managed Identity (no stored secret) to read Key Vault (P12), is triggered by Service Bus / Event Grid (P10), and exposes HTTP through API Management (P09) — the serverless tier as the secure integration hub the JD describes ("integrate serverless applications with Azure security services"). (→ P09, P10, P12, P03)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- The scale controller turns a backlog into an instance count.
desired = ceil(backlog / target_per_instance), clamped to the plan max, ramped gradually. It scales to zero on Consumption — which is where cold start comes from. Premium/Flex pay for pre-warmed instances to remove it. This is arithmetic you do before the load test, not after the incident. - A trigger invokes; bindings wire. One trigger per function (the reason it runs); bindings are the declarative I/O that keeps plumbing out of your code. Reach for an output binding before you write a client SDK call.
- A Durable orchestrator is replayed, not run. It is re-executed from its event-sourced history on every event; completed activities replay their recorded results, only the next step runs. Internalize this and everything else about Durable follows.
- Replay forces determinism. No clock, no randomness, no GUIDs, no I/O in the
orchestrator — use the
contextAPIs (current_utc_datetime,new_guid,call_activity, durable timers) so each replay reproduces the same calls. A non- deterministic orchestrator silently corrupts a running workflow; Durable's non-determinism detection (yourDeterminismError) is the guardrail. - The patterns are the design vocabulary. Chaining, fan-out/fan-in, async HTTP, monitor, human interaction — name the right one and the architecture is half-drawn.
Deliverables Checklist
-
Lab 01 implemented; all 22 tests pass against
solution.pyand yourlab.py - You can compute the scale controller's desired instance count for a given backlog and explain the per-step ramp and the scale-to-zero → cold-start link
- You can pick Consumption vs Premium vs Flex for a workload and justify it with the latency/cost/isolation tradeoff
-
You can explain why a Durable orchestrator must be deterministic and what a
DateTime.Nowinside one does to a replay - You can name all five Durable patterns and the shape of each
- You can draw fan-out/fan-in and explain how replay rebuilds state without re-running completed activities
Warmup Guide — Serverless: Functions, Triggers/Bindings & Durable
Zero-to-principal primer for Phase 11: what "serverless" actually is under the hood, the Functions trigger/binding model and the host that runs it, the scale controller that turns an event-source backlog into an instance count (and where cold start comes from), the hosting plans and when each is correct, and — the centerpiece — Durable Functions' event-sourced replay model, why it forces determinism, and the patterns built on it. Every concept goes from what it is to why it exists to the mechanism under the hood (diagrams, tables, code) to production significance to the misconceptions that page people at 2 a.m.
Table of Contents
- Chapter 1: What "Serverless" Actually Means
- Chapter 2: The Functions Model — Trigger, Function, Bindings
- Chapter 3: The Scale Controller — Backlog to Instances
- Chapter 4: Cold Start and the Scale-to-Zero Tradeoff
- Chapter 5: Hosting Plans — Consumption, Flex, Premium, Dedicated
- Chapter 6: Durable Functions — Stateful Orchestration
- Chapter 7: The Replay Model and Why Determinism Is Mandatory
- Chapter 8: The Durable Patterns
- Chapter 9: Identity, Events, and Where Functions Sit in the Platform
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What "Serverless" Actually Means
From zero. "Serverless" does not mean "no servers." It means you don't manage, provision, or pay for idle servers — the platform runs your code in response to events, scales it for you, and bills you for what you actually use. There is very much a server; you just never see it, name it, patch it, or pay for it while it sits idle.
What it is. Azure Functions is Azure's event-driven serverless compute. The unit is a function: a small piece of code with exactly one trigger (the event that runs it) and zero or more bindings (declarative I/O). You write the function; the host (the Functions runtime) loads it, invokes it on each event, and a separate scale controller decides how many copies (instances) to run.
Why it exists / production significance. Two reasons. Cost: for spiky or intermittent workloads — process an upload, react to a queue message, run a nightly job — paying for an always-on VM is waste; serverless bills per execution and scales to zero. Operability: no OS to patch, no autoscaler to tune by hand, no capacity to pre-provision. The tradeoff is less control (you don't pick the machine), cold start (Chapter 4), and execution limits (a Consumption function has a default 5-minute, max 10-minute timeout). A principal frames serverless as "the platform owns capacity and scaling; I own the function and its idempotency."
Misconception. "Serverless is always cheaper." No — it's cheaper for spiky and low- duty-cycle workloads. A function pegged at 100% all day is often more expensive than a right-sized VM, because per-execution billing assumes you're idle a lot. The cost model is a shape decision, not a default.
Chapter 2: The Functions Model — Trigger, Function, Bindings
From zero. Three nouns, and getting them straight is half of understanding Functions.
- A trigger is the one event that invokes a function. Every function has exactly
one. The trigger type is the function's reason to exist: an HTTP request
(
httpTrigger), a new queue message (queueTrigger), a schedule (timerTrigger), an Event Grid event (eventGridTrigger), a Service Bus message (serviceBusTrigger), an Event Hub batch (eventHubTrigger). - A binding is a declarative connection to a resource. An input binding reads data in before your code runs (e.g. fetch the blob this event refers to); an output binding writes your return value out (e.g. drop a message on another queue) — without you writing any client SDK code. The trigger is itself a special "trigger binding."
- The host / runtime is the process that hosts your functions, watches the triggers, resolves the bindings, and invokes your code.
Under the hood — the shape of a function. In the modern model a function declares its
trigger and bindings in code (decorators / attributes) or in a function.json:
┌─────────────── the function host ───────────────┐
queue "uploads" │ trigger: queueTrigger(queue="uploads") │
──────msg───────►│ input : blobInput(path="images/{msg.name}") │
│ YOUR CODE: thumbnail = resize(blob) │
│ output : queueOutput(queue="thumbnails") ──────┼──► queue "thumbnails"
└─────────────────────────────────────────────────┘
The host reads the trigger, hands your code the message (and the bound input blob), runs it,
and writes the return value to the output binding. You wrote resize(blob); the host wrote
all the plumbing.
Production significance. Bindings are an operability feature: less client code means
less to get wrong, less to test, less to leak credentials in. The principal habit: reach for
a binding before an SDK call. The lab's dispatch models exactly this — route the event to
the matching function, then fan its return value out to its declared output bindings.
Misconception. "A function can have many triggers." No — one trigger per function. If you need a function to react to two sources, you write two functions (or one trigger plus input bindings). The single-trigger rule is what makes "what invoked this?" always have one answer.
Chapter 3: The Scale Controller — Backlog to Instances
This is the first mechanism a principal must own. "Functions autoscales" is the senior answer; how is the principal answer.
From zero. Separate from the host that runs your code, there is a scale controller. Its job: watch the trigger's event source, measure the backlog of unprocessed work, and decide how many instances (copies of your function app) should be running.
Under the hood — target-based scaling. The modern controller (for queue, Service Bus, Event Hub, Cosmos DB, and Storage triggers) uses target-based scaling: it aims to keep a fixed amount of work per instance. The arithmetic the lab implements:
$$ \text{desired} = \left\lceil \frac{\text{backlog}}{\text{target_per_instance}} \right\rceil $$
Then two clamps and a ramp:
- Clamp to the plan ceiling:
desired = min(desired, max_instances)(and≥ 0). You can never exceed the plan's instance cap (Consumption defaults to ~200). - Scale to zero: if
backlog == 0,desired = 0. No work, no instances, no bill. - Gradual ramp: the controller changes the instance count by at most a bounded amount
per decision (per ~second), so a sudden burst doesn't instantly become hundreds of
instances that stampede the queue and your downstream database. The lab's
max_scale_out_stepmodels this.
backlog over time desired (target_per_instance=1, max=100, step=5)
0 ──────────────────► 0 (scaled to zero)
3 ──────────────────► 3 (ceil(3/1)=3, within step from 0)
1000 (from current 0) ──► 5 (ideal 1000, but +5/decision: gradual)
1000 (from current 95)──► 100 (ideal 1000, clamped to max=100)
Production significance. This is the model behind "how many instances will my 10k-message
burst reach, and how fast?" The answer is: it ramps +step per decision toward ceil(10000 / target), capped at max_instances. Sizing max_instances is a blast-radius control — it
caps how hard your functions can hammer a shared database during a spike. The gradual ramp is
why you don't see 0→500 instances in one second.
Event Hub / Service Bus nuance. For partitioned sources, the controller is also bounded by partition/session count — you can't usefully run more instances than there are partitions to read. So "scale out" has two ceilings:
max_instancesand the source's partition count. This is the serverless echo of Phase 10's "partitions are your parallelism unit."
Misconception. "Functions scales on CPU." For event-driven triggers it scales on backlog (the event source's queue depth / lag), not CPU. CPU-based scaling is the VM/AKS model; the Functions controller reads the source, which is why an idle-CPU-but-deep-queue function still scales out.
Chapter 4: Cold Start and the Scale-to-Zero Tradeoff
From zero. Cold start is the latency added when an event arrives and there is no warm instance ready to handle it: the platform must allocate an instance, start the runtime, load your code and dependencies, and then run your function. Scale-to-zero (Chapter 3) is the direct cause: when the backlog drains, the controller removes every instance, so the next event hits a cold platform.
Under the hood — the cost. A cold start is roughly:
allocate instance + start worker + load runtime + load YOUR code/deps + JIT → first invocation
└──────────────── 100s of ms to several seconds (language- and size-dependent) ──────────────┘
The dominant variable is usually your dependency footprint (a fat package set, a heavy framework, a large container image). .NET/Java pay JIT/JVM warmup; Python/Node pay import time; everyone pays for big dependency trees. Warm invocations skip all of it — the instance is already up.
Production significance — this is the question behind a real incident. "Why is the first request after a quiet period slow?" → scale-to-zero + cold start. The fixes are cost decisions:
| Lever | What it does | Cost |
|---|---|---|
| Premium / Flex always-ready | keep N pre-warmed instances; first event is never cold | you pay for the warm instances |
| Smaller deployment | fewer/lighter dependencies → faster load | engineering time |
| Keep-alive / warmup trigger | ping the function to keep one instance warm | a little compute |
| Move latency-critical paths off scale-to-zero | put the hot path on Premium, keep batch on Consumption | split the workload |
The principal move: decide cold start with a number. If the SLA is "p99 background job within 5 minutes," cold start is noise — stay on Consumption and save the money. If it's "p99 user-facing API under 200 ms," scale-to-zero is unacceptable on the hot path — pay for pre-warmed Premium/Flex. This is a Phase 00 latency-budget decision applied to compute.
Misconception. "Cold start is a bug." It's a tradeoff: the price of scale-to-zero (you pay nothing while idle). You don't "fix" it; you decide whether the workload can tolerate it, and pay for pre-warmed instances only where it can't.
Chapter 5: Hosting Plans — Consumption, Flex, Premium, Dedicated
From zero. The hosting plan is the cost/latency/isolation tradeoff dial for Functions. Picking it is a design decision, not a checkbox.
| Plan | Billing | Scale-to-zero / cold start | Pre-warmed | VNet | Use it for |
|---|---|---|---|---|---|
| Consumption | per execution (GB-s + count) | yes / yes | no | limited | spiky/intermittent background work; cheapest when idle |
| Flex Consumption | per second + always-ready | yes / mitigated | always-ready instances | yes | the modern default: fast scale, optional warm instances, network isolation |
| Premium (Elastic) | per pre-warmed + per used | no (min instances) / no | yes | yes | latency-sensitive, long-running, VNet-bound workloads |
| Dedicated (App Service) | the App Service plan you already pay for | no scale-to-zero | n/a (always on) | yes | when you already run an App Service plan with spare capacity |
Under the hood — the decision. Two questions resolve almost every case:
Does the hot path tolerate cold start (100s of ms–seconds occasionally)?
yes ──► Consumption (cheapest; scale-to-zero is fine)
no ──► need pre-warmed instances ──► Flex (always-ready) or Premium
Do I need VNet integration / private endpoints / long runs?
yes ──► Flex or Premium (Consumption can't, or is limited)
Production significance. "We're getting cold-start latency on a user-facing endpoint" → move it to Premium/Flex with always-ready instances. "Our bill is high but the functions are idle most of the day" → you're probably on Dedicated/Premium when Consumption would scale to zero. "The function can't reach our private database" → you need VNet integration, so Flex or Premium, not classic Consumption. The plan is the lever for each.
Misconception. "Always use Premium to avoid cold start." Premium has a minimum number of always-on instances you pay for 24/7. For a function that runs twice an hour, that's burning money to dodge a cold start nobody notices. Match the plan to the duty cycle and the latency SLA, not to fear of cold start.
Chapter 6: Durable Functions — Stateful Orchestration
From zero. Plain functions are stateless and short: an event comes in, the function runs, it ends. But real workflows are stateful and long: "validate the order, then reserve inventory across five warehouses in parallel, then charge payment, then wait up to 72 hours for manager approval, then ship." You cannot hold that in a single function invocation (timeouts, cost, crashes). Durable Functions is the extension that makes stateful, long-running, reliable orchestrations possible on top of stateless Functions.
What it is — two function types.
- An orchestrator function is the coordinator. It describes the workflow: which activities to call, in what order, in parallel or sequence, with what waits. It is deterministic and does no I/O directly (Chapter 7 explains why).
- An activity function is the worker: the unit that actually does side-effecting work (call an API, write a row, send an email). Activities are where all the I/O, randomness, and clock-reading lives — because they run once and their results are recorded.
orchestrator (deterministic coordinator)
│ result = yield ctx.call_activity("Validate", order)
│ reserved = yield [ctx.call_activity("Reserve", w) for w in warehouses] # fan-out
│ yield ctx.call_activity("Charge", order)
▼
activities (the side-effecting workers — run once, results recorded)
Validate Reserve×5 Charge
Under the hood — durability. The orchestration's state is not held in memory; it's an
append-only history (an event-sourced log: OrchestratorStarted, TaskScheduled,
TaskCompleted, …) persisted in a storage provider (Azure Storage, Netherite, or MSSQL).
That history is the workflow's memory — which is what makes it survive a process crash, a
restart, or a 72-hour wait: the orchestrator can be reconstructed from its history at any
time. How it's reconstructed is the next chapter, and it's the whole game.
Production significance. Durable is how you do "long-running, reliable, stateful" on serverless without standing up a workflow engine. It's the answer to "coordinate these ten steps with retries, parallelism, and a human approval, and survive a crash in the middle."
Misconception. "An orchestrator is a long-lived process waiting in memory." It is not. It is a function that gets replayed from history; between steps it isn't running at all. Internalize this now — everything about Durable follows from it.
Chapter 7: The Replay Model and Why Determinism Is Mandatory
This is the chapter the whole lab exists for. It is the single most misunderstood — and most interview-probed — mechanism in Azure serverless.
From zero — the replay loop. When an event for an orchestration arrives (an activity
finished, a timer fired), the Durable extension does not resume a suspended process. It
re-runs the orchestrator function from the very first line, feeding it the recorded
history. As the replayed code hits each yield ctx.call_activity(...):
- if that activity is already in the history (it completed on a prior turn), the engine immediately returns the recorded result — it does not re-run the activity;
- when the code reaches an activity that is not yet in history (the next step), the engine schedules/executes it, records the result, and the turn ends.
So each "turn" advances the orchestration by replaying everything done so far (fast, from memory) and doing exactly one new thing.
TURN 1 replay from top → reach Validate (not in history) → run it, record result, stop
TURN 2 replay from top → Validate REPLAYS recorded result → reach Reserve (new) → run, record, stop
TURN 3 replay from top → Validate, Reserve REPLAY → reach Charge (new) → run, record, stop
... every turn re-executes the orchestrator from line 1; only the NEXT step is new
The lab's run_orchestrator is exactly this loop: a cursor walks the history; a recorded
event replays its result, the next yielded call executes and appends. Replaying with a
partial history versus a full history yields the identical ordered sequence of activity
calls — that is the determinism guarantee, proven by
test_replay_partial_vs_full_same_call_order.
Why determinism is mandatory — under the hood. Because the orchestrator is re-executed from the top every turn, every line runs many times, once per turn. If any line produces a different value on a later replay, the replayed code takes a different path than the one history recorded — and the engine's history no longer matches the code. Concretely:
TURN 1 the orchestrator ran: if random() > 0.5: callActivity("A") # random()=0.7 → called A, recorded
TURN 2 replay re-runs that line: random()=0.3 → it now wants B # but history says A was called!
→ the replayed action DIVERGES from history → corruption
The platform detects this — it sees the new run schedule a different action than the
history records — and raises a non-determinism error. The lab models it precisely: when
the yielded CallActivity (name/input) doesn't match the recorded HistoryEvent at the
cursor, run_orchestrator raises DeterminismError (test_determinism_error_on_*).
The forbidden list, and the fix. Anything that returns a different value on the next replay is banned inside the orchestrator:
| Forbidden in the orchestrator | Why it breaks replay | Use instead |
|---|---|---|
DateTime.Now / datetime.utcnow() | different time each replay | ctx.current_utc_datetime (recorded once, replayed) |
Guid.NewGuid() / uuid4() | different id each replay | ctx.new_guid() (deterministic) |
random() | different branch each replay | compute randomness in an activity |
| HTTP / DB / file I/O directly | different result + side effects on every replay | call an activity (runs once, recorded) |
Task.Delay / sleep | not durable; lost on restart | ctx.create_timer(fire_at) (durable timer) |
The lab's deterministic current_utc() is exactly this: it reads a recorded timestamp from
history, so every replay sees the same "now" (test_current_utc_comes_from_history_on_replay).
The rule in one sentence: the orchestrator must be a pure function of its history; all
nondeterminism goes through ctx or into an activity.
Production significance. "The orchestration is stuck / behaving weirdly / threw a non-determinism exception" is, nine times out of ten, a clock, a GUID, a random, or an I/O call that snuck into the orchestrator — often added in a code change that altered the call sequence of in-flight orchestrations (changing orchestrator code while instances are running is itself a determinism hazard; you version orchestrators or drain them first). Being able to say "that's a determinism violation, here's the line, here's why replay breaks on it" is the principal signal.
Misconception. "Replay re-runs my activities, so they must be idempotent for that reason."
Replay does not re-run completed activities — it replays their recorded results
(test_replay_does_not_re_execute_recorded_activities). Activities still should be
idempotent (for the retry path and at-least-once delivery, Phase 10), but plain replay
doesn't re-execute them. The thing replay forces is orchestrator determinism, not activity
re-execution.
Chapter 8: The Durable Patterns
From zero. Durable's power is a small set of named patterns. Knowing which to reach for is the design answer.
-
Function chaining — run activities in sequence, each feeding the next:
a = yield ctx.call_activity("A", x) b = yield ctx.call_activity("B", a) c = yield ctx.call_activity("C", b) # A → B → CUse for: ordered multi-step processing. (The lab's chaining test.)
-
Fan-out / fan-in — schedule N activities in parallel, await all, aggregate:
tasks = [ctx.call_activity("Work", item) for item in items] results = yield tasks # all run in parallel (when_all) total = yield ctx.call_activity("Aggregate", results)Use for: parallel processing with a join (process 1000 files, then sum). The fan-in is the hard part to do reliably by hand — Durable records each completion in history so it survives crashes. (The lab's fan-out/fan-in test.)
-
Async HTTP API — start a long job and return immediately:
client.start_new("Orchestrator", input) → 202 Accepted + Location: .../status?id=... (statusQueryGetUri) client polls the status URL until CompletedUse for: any operation longer than an HTTP timeout. No held connections.
-
Monitor — a recurring check until a condition is met, on a durable timer:
while not done: status = yield ctx.call_activity("Check", resource) if status == "ready": break yield ctx.create_timer(ctx.current_utc_datetime + interval) # durable waitUse for: poll-until-ready, with waits that survive restarts (and can run for days).
-
Human interaction — wait for an external event, with a timeout:
approval = ctx.wait_for_external_event("Approval") timeout = ctx.create_timer(deadline) winner = yield ctx.when_any([approval, timeout]) # race the event vs the deadline if winner == timeout: escalate()Use for: approvals, manual gates — "approve within 72h or escalate." The durable timer is what lets it wait days without holding a thread.
Production significance. When an interviewer describes a workflow, the principal move is to name the pattern: "process-then-aggregate" → fan-out/fan-in; "long job a client waits on" → async HTTP; "approve-or-escalate" → human interaction with a timer. Naming it is most of the design.
Misconception. "Fan-out/fan-in needs me to track which parallel tasks finished." No — the history tracks it. Each parallel activity's completion is a recorded event; replay reconstructs "which of the N are done" from history, which is why the join survives a crash.
Chapter 9: Identity, Events, and Where Functions Sit in the Platform
From zero. Functions don't live alone — they're the integration tier that the JD's "integrate serverless applications with Azure security services" is about. Three wires matter:
- Triggered by events (P10). A function's trigger is usually a Service Bus message or an Event Grid event from Phase 10. The scale controller reads that source's backlog. So Functions is the consumer side of the event-driven architecture you built in P10.
- Authenticated by Managed Identity (P12). A function calling Key Vault, a database, or another API uses a Managed Identity — an Entra identity (P03) with no stored secret; it gets a token from IMDS (P12). "Where's the function's connection string?" → ideally nowhere; it uses its identity.
- Fronted by API Management (P09). When a function is an HTTP API, API Management (P09) sits in front: it validates the JWT (P03), enforces rate limits, and routes — so the function focuses on logic, not gatekeeping.
Event Grid / Service Bus (P10) ──trigger──► Function ──Managed Identity (P12)──► Key Vault / DB
▲
API Management (P09, JWT P03) for HTTP APIs
Production significance. A serverless design review checks all three: what triggers it (and how does it scale on that source), how does it authenticate outbound (identity, not secrets), and what fronts it (APIM for HTTP). Missing any one is a finding.
Misconception. "The function needs the database password in app settings." Prefer a Managed Identity with an RBAC grant on the resource — no secret to leak, no rotation, no connection string in config. Secrets in app settings are the old way; identity is the default now.
Lab Walkthrough Guidance
Lab 01 — Functions Runtime: Scale Controller + Durable Replay, suggested order:
desired_instances/scale_decision(Ch. 3–4) — validate inputs, computeideal = ceil(backlog / target_per_instance)(but0whenbacklog == 0— scale to zero), clamp to[0, max_instances], then move fromcurrentby at mostmax_scale_out_stepin either direction without overshooting.scale_decisioncompares the result tocurrent. Test scale-to-zero, the max clamp, and the per-step burst limit.Trigger/Binding/FunctionDef/dispatch(Ch. 2) —Trigger.matchesis same-type and (no filter or matching filter);Bindingvalidates direction;dispatchroutes to the one matching function, runs it, and fans the return value out to everyoutbinding. Test routing, output bindings, and the no-match error.run_orchestrator— the replay loop (Ch. 6–7) — walk acursorover a copy ofhistory. For each yieldedCallActivity: if a recorded event exists at the cursor, verify it matches (kind/name/input) — mismatch →DeterminismError— and return its recorded result; else executeactivities[name], append aHistoryEvent, return the fresh result. Drive the generator withnext/.send. Test chaining's final result and the identical call order on partial vs full replay.- Fan-out / fan-in (Ch. 8) — when the orchestrator yields a list of
CallActivity, resolve each element in order (each consumes one history slot) and.sendback a list of results. Test the aggregation and identical replay. - Deterministic
current_utc()(Ch. 7) — a yieldedCurrentUtcreplays a recorded timestamp from history, or (on a fresh step) pops the next provided deterministic timestamp and records it. Test that replay reproduces the same value. DeterminismError(Ch. 7) — confirm a divergent orchestrator (different name, or different input) raises it.
Run it red, make it green: pytest test_lab.py -v, then LAB_MODULE=solution pytest test_lab.py -v, then python solution.py for the worked scale table, chaining, fan-out/
fan-in, and a caught divergence.
Success Criteria
You are ready for Phase 12 when you can, from memory:
- Explain trigger vs binding, and why a function has exactly one trigger.
- Compute the scale controller's desired instance count for a backlog, and explain the max clamp, the per-step ramp, and the scale-to-zero rule.
- Explain precisely where cold start comes from (scale-to-zero) and name the levers (Premium/Flex pre-warmed, smaller deps, keep-alive) and when each is worth its cost.
- Pick Consumption vs Flex vs Premium vs Dedicated for a workload and justify it with the latency/cost/isolation/VNet tradeoff.
- Explain the Durable replay model — the orchestrator is re-run from history every turn, completed activities replay their recorded results, only the next step executes.
- State why an orchestrator must be deterministic and list four things forbidden inside
it (clock, GUID, random, I/O) and the
ctxAPI that replaces each. - Name all five Durable patterns and the shape of each, and explain how fan-out/fan-in's join survives a crash (history).
Interview Q&A
Q: How does the Azure Functions scale controller decide how many instances to run?
It watches the trigger's event source and reads the backlog — queue depth, Service
Bus message count, Event Hub lag — not CPU. With target-based scaling it computes desired = ceil(backlog / target_per_instance), clamps it to the plan's max instances, scales to
zero when the backlog is empty, and ramps gradually (a bounded number of instances
added per decision) so a burst doesn't stampede the source or downstream. For partitioned
sources it's also capped by partition count. So a 10k-message burst climbs toward ceil(10000 / target) a few instances per second, capped at max_instances.
Q: Where does cold start come from, and how do you deal with it? From scale-to-zero: when the backlog drains the controller removes every instance, so the next event has no warm instance and must allocate one, start the runtime, and load my code and dependencies — adding hundreds of ms to seconds, dominated by dependency size. I decide it with the latency budget: if the SLA is a 5-minute background job, cold start is noise — stay on Consumption and save money. If it's a sub-200 ms user-facing path, I move it to Premium or Flex with always-ready / pre-warmed instances (paying for warm capacity), shrink the deployment, or keep the hot path off scale-to-zero. Cold start is a tradeoff to decide, not a bug to fix.
Q: Consumption vs Premium vs Flex — when do you pick each? Consumption for spiky/intermittent background work — cheapest, scales to zero, accepts cold start. Premium (Elastic) when I need no cold start (pre-warmed instances), VNet integration, or long runs — but I pay for a minimum of always-on instances. Flex Consumption is the modern default: per-second billing, fast scale, optional always-ready instances, and VNet integration — Consumption's economics with Premium's capabilities. Dedicated (App Service) when I already run an App Service plan with spare capacity. The plan is the cost/latency/isolation dial.
Q: Why must a Durable orchestrator be deterministic? What breaks if it calls DateTime.Now?
Because Durable runs the orchestrator by replaying it from its event-sourced history on
every event — re-executing it from the first line each turn, replaying recorded results for
completed activities and executing only the next step. So every line runs many times. If a
line returns a different value on a later replay — DateTime.Now, Guid.NewGuid(),
random(), direct I/O — the replayed code takes a different path than history recorded, the
scheduled actions no longer match the log, and the orchestration is corrupted. The platform
detects this and throws a non-determinism error. So all nondeterminism goes through the
ctx APIs (current_utc_datetime, new_guid, durable timers) or into activities (which
run once and record their result). The orchestrator must be a pure function of its history.
Q: Walk me through fan-out/fan-in and how replay reconstructs state.
The orchestrator schedules N activities in parallel (yield a list of activity tasks), awaits
them all, then aggregates the results in a final activity. Each parallel activity's completion
is a recorded history event. On replay, the engine doesn't re-run the completed
activities — it replays their recorded results and reconstructs "which of the N are done"
straight from history. That's why the join survives a crash mid-fan-out: the durable history,
not in-memory state, tracks progress. The lab proves this — run_orchestrator resolves a
yielded list element-by-element against history and reproduces the identical call order on
replay.
Q: Replay re-runs the orchestrator — does it re-run my activities too? No. Replay re-executes the orchestrator code, but for any activity already in history it returns the recorded result — it does not re-invoke the activity. Only the next un-recorded activity actually runs. (The lab proves it: replaying with completed activities in history never calls them again.) Activities should still be idempotent for the retry path and at-least-once delivery, but plain replay doesn't re-execute them — what replay forces is orchestrator determinism, not activity re-execution.
Q: A function needs to read a secret database connection — how should it authenticate? With a Managed Identity (P12), not a stored secret. The function has an Entra identity that gets a token from IMDS with no credential to leak or rotate; I grant that identity RBAC (or a Key Vault access policy) on the resource. Connection strings in app settings are the old way — identity is the default. The function is triggered by Service Bus/Event Grid (P10), authenticates outbound by identity (P12), and if it's an HTTP API it's fronted by API Management (P09) validating the JWT (P03).
References
- Azure Functions — hosting options & plans (Consumption, Flex Consumption, Premium, Dedicated; scale-to-zero, pre-warmed instances) — https://learn.microsoft.com/azure/azure-functions/functions-scale
- Event-driven scaling & target-based scaling (how the scale controller reads the event source backlog and computes instances) — https://learn.microsoft.com/azure/azure-functions/event-driven-scaling and https://learn.microsoft.com/azure/azure-functions/functions-target-based-scaling
- Triggers and bindings (the declarative I/O model; the one-trigger rule) — https://learn.microsoft.com/azure/azure-functions/functions-triggers-bindings
- Cold start in Azure Functions (causes and mitigations) — https://learn.microsoft.com/azure/azure-functions/functions-scale#cold-start
- Durable Functions overview (orchestrator vs activity; storage providers) — https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview
- Durable Functions — orchestrator code constraints (the determinism rules: no
DateTime.Now, no GUIDs, no I/O; usecontextAPIs) — https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-code-constraints - Durable Functions — application patterns (chaining, fan-out/fan-in, async HTTP, monitor, human interaction) — https://learn.microsoft.com/azure/azure-functions/durable/durable-functions-overview#application-patterns
- The Durable Task Framework (the replay engine Durable Functions is built on) — https://github.com/Azure/durabletask
- Eric Evans / Greg Young — event sourcing; and Martin Fowler, Event Sourcing (2005) — the pattern Durable replay is an instance of — https://martinfowler.com/eaaDev/EventSourcing.html
- The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 11: Serverless, Functions, Triggers/Bindings & Durable
Read this if: you've written an Azure Function that worked, but couldn't explain how it scaled, where cold start comes from, or why a Durable orchestrator can't call
DateTime.Now. This is the compressed tour the WARMUP derives slowly. Skim it, read the WARMUP, then build the runtime.
0. The 30-second mental model
A trigger invokes your function; bindings wire its I/O; the host runs it; and a
separate scale controller decides how many copies to run by reading the trigger source's
backlog — desired = ceil(backlog / target_per_instance), capped at the plan max, ramped
gradually, and scaled to zero when idle (which is where cold start comes from).
For stateful workflows, Durable Functions runs your orchestrator by replaying it
from its recorded history on every event — completed activities replay their saved results,
only the next step runs. One sentence to tattoo: a Durable orchestrator is replayed, not
run — so it MUST be deterministic (no clock, no random, no GUID, no I/O — those go in
activities or through ctx).
1. Trigger / function / bindings in one breath
ONE trigger per function = the event that invokes it
httpTrigger | queueTrigger | timerTrigger | eventGridTrigger | serviceBusTrigger | eventHubTrigger
bindings = declarative I/O (no client SDK code)
input binding → data handed to your code (read this blob)
output binding → your return value written out (drop on this queue / write this Cosmos doc)
host = loads the function, watches triggers, resolves bindings, invokes your code
scale controller = SEPARATE; reads the trigger source backlog → instance count
2. The scale controller (the part "autoscales" hides)
desired = ceil(backlog / target_per_instance) # per-INSTANCE work target, not CPU
desired = min(desired, max_instances) # plan ceiling (Consumption ~200)
desired = 0 if backlog == 0 # scale to zero
move from current by ≤ step per decision # gradual ramp, no stampede
also capped by partition/session count for Event Hub / Service Bus sessions
It scales on backlog, never CPU, for event triggers. The gradual ramp is why a 10k burst
doesn't become 10k instances in one second — it climbs +step per decision toward the cap.
3. The numbers to tattoo on your arm
| Thing | Value |
|---|---|
| Consumption instance cap | ~200 (Windows; Linux ~100) |
| Consumption timeout | default 5 min, max 10 min |
| Premium/Dedicated timeout | up to unbounded (Premium default 30 min) |
| Scale | on backlog (queue depth / lag), not CPU |
| Scale to zero | Consumption + Flex (→ cold start) |
| Cold start | ~100s of ms to several seconds, dominated by dependency size |
| Pre-warmed / no cold start | Premium (always min instances), Flex (always-ready) |
| Event-trigger parallelism ceiling | also partition/session count of the source |
| Durable orchestrator rule | deterministic — no DateTime.Now/random/Guid/I/O |
| Durable state | event-sourced history in Azure Storage / Netherite / MSSQL |
| Replay-safe time | context.current_utc_datetime (NOT DateTime.Now) |
| Replay-safe id | context.new_guid() (NOT Guid.NewGuid()) |
| Replay-safe wait | context.create_timer() (NOT Task.Delay/sleep) |
4. Hosting plan in one glance
spiky / intermittent, cold start OK → Consumption (cheapest, scale-to-zero)
modern default: fast scale + warm + VNet → Flex Consumption (always-ready optional)
no cold start / long runs / VNet → Premium (Elastic) (pay for min warm instances)
already have spare App Service capacity → Dedicated
Decision: does the hot path tolerate cold start? No → pre-warmed (Flex/Premium). Need VNet/private endpoints? Yes → Flex/Premium.
5. The five Durable patterns (name the right one = the design)
chaining A → B → C, each feeds the next (sequential steps)
fan-out/fan-in N activities in parallel, await all, aggregate (parallel + join; history tracks completion)
async HTTP start → 202 + statusQueryGetUri, client polls (job longer than an HTTP timeout)
monitor poll on a durable timer until condition met (poll-until-ready, days-long)
human interaction wait_for_external_event raced vs a timer (approve within 72h or escalate)
6. The replay model in one picture
TURN 1 replay from top → first uncompleted activity runs, records result, STOP
TURN 2 replay from top → completed activity REPLAYS its recorded result → next runs, STOP
... orchestrator re-executes from line 1 EVERY turn; only the NEXT step is new
completed activities are NOT re-run — their results replay from history
non-determinism (clock/random/GUID/I/O) → later replay yields a DIFFERENT action
than history recorded → the platform throws a non-determinism error
7. func / az one-liners you'll actually type
# Scaffold a Functions project + an HTTP function (Core Tools)
func init MyFuncs --worker-runtime python
cd MyFuncs && func new --name HttpStarter --template "HTTP trigger"
func start # run locally
# Create a Consumption function app (needs a storage account + a plan)
az functionapp create -g rg -n my-func --consumption-plan-location eastus \
--runtime python --functions-version 4 --storage-account mystorage --os-type Linux
# Create a Flex Consumption app (modern default: fast scale, VNet, always-ready)
az functionapp create -g rg -n my-flex --flexconsumption-location eastus \
--runtime python --storage-account mystorage
# Premium plan (pre-warmed, no cold start) then an app on it
az functionapp plan create -g rg -n my-premium --location eastus --sku EP1 --min-instances 1
az functionapp create -g rg -n my-app --plan my-premium --runtime dotnet-isolated --storage-account mystorage
# Turn ON a Managed Identity for the function (so it authenticates with NO secret — P12)
az functionapp identity assign -g rg -n my-func
# Cap how hard it can scale out (blast-radius control on a shared DB)
az functionapp config set -g rg -n my-func --generic-configurations '{"functionAppScaleLimit": 50}'
# Deploy + tail logs
func azure functionapp publish my-func
az functionapp log tail -g rg -n my-func
# Inspect a Durable orchestration's status (the management API)
curl "https://my-func.azurewebsites.net/runtime/webhooks/durabletask/instances/<id>?code=<key>"
8. War story shapes you'll relive
- "First request after a quiet period is slow." → scale-to-zero + cold start. The next event hit a cold platform. Fix: Premium/Flex pre-warmed on the hot path only, or shrink deps — and only if the SLA actually needs it.
- "My burst stampeded the database." → the function scaled out hard and N instances all
hammered one DB. Cap
functionAppScaleLimit(blast radius) and/or use a connection pool / Service Bus to smooth it. The gradual ramp helps but the cap is the real control. - "The orchestration threw a non-determinism exception." → a
DateTime.Now,Guid,random(), or direct I/O snuck into the orchestrator. Move it to an activity or use thectxAPI. Classic: someone added a log line withDateTime.Now. - "I changed the orchestrator code and in-flight orchestrations broke." → changing the orchestrator's call sequence while instances are mid-replay diverges from their history. Version the orchestrator (new name) or drain before deploying.
- "It works on Event Hub but won't scale past 4 instances." → the source has 4
partitions; you can't usefully run more consumers than partitions. The ceiling is partition
count, not just
max_instances. - "My function 'lost' the long wait after a restart." → you used
Task.Delay/sleepinstead of a durable timer (create_timer). Plain sleeps don't survive a restart; durable timers are recorded in history.
9. Vocabulary that signals you've held the pager
- Scale controller — the component that turns event-source backlog into instance count.
- Target-based scaling —
ceil(backlog / target_per_instance); the per-instance work target. - Scale to zero — no work → no instances → the cause of cold start.
- Cold start — startup latency when no warm instance exists; dominated by dependency size.
- Pre-warmed / always-ready — Premium/Flex instances kept warm to kill cold start.
- Trigger vs binding — the one event that invokes vs the declarative I/O wires.
- Orchestrator vs activity — the deterministic coordinator vs the side-effecting worker.
- Replay / event sourcing — the orchestrator is re-run from its recorded history each event.
- Determinism / non-determinism error — the orchestrator must be a pure function of its history; the platform throws when a replay diverges.
- Fan-out/fan-in — parallel activities + a join the history tracks across crashes.
- Durable timer — a replay-safe, restart-surviving wait (
create_timer). continue_as_new— reset an eternal orchestration's history so it doesn't grow forever.
10. Beginner mistakes that mark you in interviews
- Saying "Functions autoscales" with no idea it scales on backlog, not CPU.
- Not knowing where cold start comes from (it's scale-to-zero), or "fixing" it everywhere with Premium when most paths don't need it (paying for idle warm instances).
- Calling a Durable orchestrator "a long-running process in memory" (it's replayed).
- Putting
DateTime.Now/Guid.NewGuid()/random()/ an HTTP call in the orchestrator — the determinism violation that corrupts replay. - Thinking replay re-runs completed activities (it replays their recorded results).
- Using
Task.Delay/sleepfor a long wait instead of a durable timer. - Forgetting the Event Hub / Service Bus parallelism ceiling is partition/session count.
- Storing the DB connection string in app settings instead of using a Managed Identity.
- Changing orchestrator code while orchestrations are in-flight (breaks their replay).
11. How this phase pays off later
- Managed Identity (P12) is how every function authenticates outbound with no secret — this phase is the compute, P12 removes the credential.
- Event Grid / Service Bus (P10) are the triggers whose backlog the scale controller reads — Functions is the consumer side of P10's event plumbing.
- API Management (P09) fronts HTTP functions, validating the JWT (P03) you already built.
- Latency budgets (P00) are how you decide whether cold start is acceptable — compute the budget, then pick Consumption vs pre-warmed.
- Reliability (P14) — Durable's retries, timers, and idempotent activities are the reliability primitives applied to workflows.
Now read the WARMUP slowly, then build the runtime. After this, "serverless" stops being a buzzword and becomes two mechanisms you can draw: backlog→instances, and replay→determinism.
👨🏻 Brother Talk — Phase 11, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. Almost everyone who's "done serverless" has written an Azure Function, watched it
fire when a message landed on a queue, and moved on feeling clever. And it is clever — right
up until the day it scales in a way you didn't expect, or someone asks you "so how does it
actually decide to add instances?" and you realize you've been saying the word "autoscale"
like it's an answer. It isn't. "Autoscale" is the noise you make when you don't know the
mechanism. The mechanism is a scale controller reading a backlog and doing
ceil(backlog / target). Once you can say that, out loud, with the clamp and the gradual
ramp and the scale-to-zero, you've stepped over a line most people never cross. That line is
this whole phase.
Here's the thing that took me too long to internalize, and it's the most important sentence in this phase: a Durable orchestrator is not running. It's being replayed. Sit with that, because it's so counterintuitive that even people who use Durable Functions every day don't truly hold it. You write what looks like normal sequential code — call this, await that, then this — and it reads like a process sitting there waiting. It is not. Between every step, your orchestrator isn't running at all; its entire state lives in an append-only history in a storage account. When the next thing happens, the platform re-runs your function from the very first line, fast-forwarding through everything already done by replaying recorded results, until it hits the one new thing to do. Every line of your orchestrator runs dozens of times over the life of the workflow. When that finally clicks — when you can feel the replay — Durable stops being magic and the determinism rule stops being a scary warning and becomes obvious.
And the determinism rule is the whole ballgame, so let me hammer it. Your orchestrator must
be a pure function of its history. No DateTime.Now. No Guid.NewGuid(). No random().
No calling an API, a database, a file, anything with a side effect — directly. Why? Because
all of those return a different value the next time the line runs — and the line runs again
on every replay. The first time, random() returns 0.7 and your code calls activity A and the
history records "A was called." The next replay, random() returns 0.3, your code wants to
call B, and now the running code disagrees with the recorded history. That's not a warning;
that's corruption, and the platform throws a non-determinism error to stop it. The fix is
beautiful in its simplicity: anything nondeterministic goes into an activity (which runs
once and has its result recorded) or through the ctx API (current_utc_datetime,
new_guid, durable timers — each one exists precisely because the raw primitive is unsafe
under replay). When you can explain in an interview why current_utc_datetime exists — "so
the clock read is recorded once and replayed, instead of returning a new time every replay" —
you've shown them you understand event sourcing, not just an API.
Now let me give you the things that actually bite people.
Cold start is a tradeoff, not a bug — and the level-up is treating it like one. Juniors panic about cold start and reach for Premium everywhere, and now they're paying for warm instances 24/7 to dodge a 600 ms hiccup nobody on a nightly batch job will ever notice. The principal move is to compute it: pull out the latency budget from Phase 00 and ask "does this path's SLA care about an occasional cold start?" If it's a background job with a 5-minute budget — Consumption, scale to zero, save the money, cold start is noise. If it's a user-facing API with a 200 ms p99 — yes, pay for Flex always-ready or Premium pre-warmed on that path. Same system, two answers, decided by a number. That's the whole game again: price the tradeoff, don't fear it.
The scale limit is a blast-radius control, and forgetting it is a 2 a.m. story. Here's
the one that gets people: the function scales beautifully, a burst comes in, 200 instances
spin up — and all 200 open connections to the same database, which falls over. The function
did its job too well. The fix isn't to make the function slower; it's to cap the scale
limit (functionAppScaleLimit) so the platform can't hammer a shared downstream harder than
it can take. This is Phase 00's blast-radius thinking applied to compute: a thing that scales
without a cap is a thing that can take down everything it talks to. A principal sets the cap
before the incident and can explain why the number is what it is.
Idempotency is still your friend, but for the right reason. People conflate two things here, so get this straight: plain replay does not re-run your completed activities — it replays their recorded results. So replay isn't why activities need to be idempotent. They need to be idempotent because of retries and at-least-once delivery (the Phase 10 stuff): an activity can fail partway, get retried, and run twice; the trigger source can deliver the same message twice. So "make activities idempotent" is true — just know it's the retry/delivery path that demands it, not replay. Saying that precisely in an interview is a quiet flex; it shows you actually understand both mechanisms instead of mushing them together.
The honest truth about this phase: the code is easy. A function is a few lines; Durable
patterns are a handful of yields. What makes this principal-level is that the two core
mechanisms — scale-from-backlog and replay-forces-determinism — are invisible until they
bite, and when they bite they bite in production, not in your local test. Nobody's local
test catches the cold start, the burst stampede, or the DateTime.Now that corrupts a replay
after the third event. So the work isn't writing the function; it's holding the mechanism in
your head clearly enough to predict those failures before they happen. That's why we build the
runtime in this lab — write the scale controller and the replay engine by hand, and the
mechanisms stop being invisible. You'll see the backlog become an instance count, and you'll
watch a divergent orchestrator throw DeterminismError. After that, you can't un-see it,
and you'll spot the bug in a code review before it ships.
And here's the career angle, brother to brother. Serverless is where the platform meets the product — it's the tier where the business logic actually runs, triggered by your events (P10), authenticated by your identities (P12), fronted by your gateway (P09). The people who own this tier are trusted with the part of the system that does the work, and they're the ones in the room when something scales wrong or a workflow gets stuck. If you can be the person who says "that's a determinism violation on line 14, here's why replay breaks on it, here's the fix" — calmly, in a code review or an incident — you're not a function-writer anymore. You're the person who understands the runtime. That person gets handed the gnarly distributed workflows, and gnarly distributed workflows are where principals are made.
Go build the runtime. Watch the backlog turn into instances. Put a random() in an
orchestrator and watch your engine reject it on replay. Then come find me in Phase 12 — that's
where the function you just scaled gets a Managed Identity and stops carrying secrets
entirely.
— your brother 👨🏻
Lab 01 — Functions Runtime: Scale Controller + Durable Replay
Phase: 11 — Serverless: Functions, Triggers/Bindings & Durable | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
Azure Functions looks like magic: you write a function, an event shows up, and somehow the right number of instances appear to process it. Pull the cover off and there are two mechanisms doing the magic — the scale controller that turns an event-source backlog into a desired instance count (and scales to zero when the work drains, which is where cold start comes from), and, for stateful workflows, the Durable replay engine that runs your orchestrator by re-executing it from its recorded history on every event — which is exactly why an orchestrator must be deterministic. This lab builds both: a target-based scale controller with a per-step burst clamp, a trigger/binding dispatcher, and — the centerpiece — a Durable orchestration replay engine that reproduces the same sequence of activity calls from a partial vs full history and raises
DeterminismErrorthe moment your orchestrator diverges.
What you build
desired_instances(...)/scale_decision(...)— the scale controller. Target-based:ideal = ceil(backlog / target_per_instance), clamped to[0, max_instances], and rate-limited to move at mostmax_scale_out_stepinstances fromcurrentper decision (gradual scale-out, no stampede). Backlog0→ 0 instances (scale-to-zero). Thescale_decisionreturns"scale_out"/"scale_in"/"hold".Trigger/Binding/FunctionDef/dispatch(...)— the trigger/binding model. ATrigger(type + optional filter) decides which function an event invokes; the function's declared output bindings collect its return value.dispatchroutes one event to the one matching function and returns the result plus its output bindings.- The Durable replay engine (
run_orchestrator,OrchestrationContext,CallActivity,CurrentUtc,HistoryEvent,DeterminismError) — an orchestrator is a Python generator thatyieldsCallActivityinstructions and receives results back. The engine replays the generator from the start against a recordedhistory: for each yielded call it either replays the recorded result (matched by sequence index) or — for the next un-recorded step — executes the activity, appends the result to history, and continues. Fan-out/fan-in (yielda list ofCallActivity→ a list of results) and a deterministiccurrent_utc()(served from history, never the wall clock) are built in. A divergent replay raisesDeterminismError.
Key concepts
| Concept | What to understand |
|---|---|
| Target-based scaling | desired = ceil(backlog / target_per_instance); the controller chases a per-instance work target, not CPU |
| Scale to zero | backlog 0 → 0 instances; the next event pays a cold start to spin one up (the Consumption tradeoff) |
| Per-step burst clamp | the runtime adds a bounded number of instances per decision — protects the source and downstream from a stampede |
| Max instances | the plan's hard ceiling (Consumption ~200, others configurable); ideal is clamped to it |
| Trigger vs binding | the trigger invokes the function (one per function); bindings declaratively wire inputs/outputs |
| Orchestrator = generator | it yields instructions and is replayed; it must be a pure function of its history |
| Event-sourced replay | the engine re-runs the orchestrator from history every turn; recorded steps replay, the next step executes |
| Determinism | no datetime.now, no random, no I/O outside activities — or the replay diverges and Durable throws |
DeterminismError | the yielded call no longer matches history (name/input/kind) → the orchestrator is non-deterministic |
| Deterministic time | ctx.current_utc() reads a recorded timestamp from history, so every replay sees the same "now" |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py runs a worked example (scale table, chaining, fan-out/fan-in, a caught divergence) |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 22 tests pass against your implementation.
- You can explain why
test_desired_scales_to_zero_on_empty_backlogis the line that creates cold start: scale-to-zero means there is no warm instance for the next event. - You can explain why
test_desired_burst_limited_per_stepmatters — a 1000-message burst doesn't instantly become 1000 instances; the controller climbs+max_scale_out_stepper decision so it doesn't stampede the queue or your database. - You can explain why
test_replay_partial_vs_full_same_call_orderis the whole point of the Durable engine: the orchestrator is a pure function of its history, so replaying it from a partial history reproduces the identical ordered call sequence as a full run. - You can explain why
test_replay_does_not_re_execute_recorded_activitiesis the efficiency and correctness guarantee: a recorded activity replays its result; only the next un-recorded activity actually runs. - You can explain why
test_determinism_error_on_divergent_replayexists — arandom()branch or adatetime.now()in the orchestrator makes a later replay yield a different call than history recorded, and Durable detects exactly that mismatch.
How this maps to real Azure (Azure Functions + Durable Functions)
| The lab | Real Azure |
|---|---|
desired_instances | the scale controller, which polls the trigger's event source and computes a target instance count; modern triggers use target-based scaling (a per-instance target) |
backlog | queue message count (Storage/Service Bus), Event Hub lag (unprocessed events per partition), the depth the controller reads |
scale to 0 | Consumption and Flex Consumption scale to zero when idle; the next trigger pays a cold start to spin an instance up |
max_instances | the plan's instance cap — Consumption defaults to ~200, Premium/Flex/Dedicated are configurable |
max_scale_out_step | the controller adds instances gradually (a bounded number per ~second), not all at once |
Trigger | the function's single trigger — queueTrigger, httpTrigger, timerTrigger, eventGridTrigger, serviceBusTrigger, eventHubTrigger |
Binding (out) | an output binding (queue, blob, cosmosDB, serviceBus) — the host writes the return value without you writing client code |
run_orchestrator | the Durable Task Framework replay loop inside the Durable extension |
CallActivity | context.df.callActivity(name, input) (JS) / context.call_activity(name, input) (Python) / context.CallActivityAsync (.NET) |
history | the orchestration's history table in the Durable storage provider (Azure Storage / Netherite / MSSQL) — the event-sourced log replayed on each event |
CurrentUtc | context.current_utc_datetime — the deterministic replay-safe clock |
DeterminismError | Durable's non-determinism detection — it logs/raises when replayed code produces a different action than history recorded |
| fan-out / fan-in | the canonical Durable pattern: yield a list of activity tasks, then aggregate (when_all / Task.WhenAll) |
What the miniature leaves out (and why it's fine): real Functions runs over HTTP/gRPC
to a language worker, persists history to a durable store with leases and partitioning,
checkpoints long orchestrations, supports durable timers, external events (human
interaction), sub-orchestrations, and eternal orchestrations (continue_as_new),
and the scale controller integrates with KEDA on Kubernetes. None of that changes the two
mechanisms this lab isolates — (1) a backlog becomes a clamped, step-limited instance
count, and (2) an orchestrator is replayed from history and therefore must be
deterministic. Those are exactly the parts interviewers probe and on-call incidents turn on.
Extensions (build these in your own subscription)
- Durable timers: add
CreateTimer(fire_at)— yield a timer instruction the engine resolves from history (a recorded fire time), so a "wait 1 hour then check" monitor replays deterministically. This is how the monitor pattern works. - External events / human interaction: add
WaitForExternalEvent(name)plus aRaiseEventAPI and a timeout (aCreateTimerraced against the event withwhen_any) — the human-interaction-with-timeout pattern (approve within 72h or escalate). - Sub-orchestrations: let an orchestrator
yield CallSubOrchestrator(fn, input)and have the engine recurse, recording the child's completion in the parent's history. continue_as_new: support an eternal orchestrator that resets its history to avoid unbounded growth (how a perpetual monitor keeps its history small).- Wire it to real Azure:
func init/func newan orchestrator + activity + HTTP starter; deploy withaz functionapp create; trigger it and watch the history table in the storage account fill withTaskScheduled/TaskCompletedevents as it replays. Then put arandom.random()in the orchestrator and watch Durable's non-determinism detection fire.
Interview / resume
- Talking points: "How does the Functions scale controller decide instance count, and
where does cold start come from?" / "Why must a Durable orchestrator be deterministic —
what exactly breaks if it calls
DateTime.Now?" / "Walk me through fan-out/fan-in and how replay reconstructs state without re-running completed activities." / "Consumption vs Premium vs Flex — when do you pay for pre-warmed instances?" - Resume bullet: Built a miniature Azure Functions runtime — a target-based scale controller (backlog→instances with scale-to-zero, max-instance clamp, and per-step burst limiting) and an event-sourced Durable Functions replay engine (function chaining, fan-out/fan-in, deterministic replay-safe time, and non-determinism detection) — modeling the Functions host and the Durable Task Framework.
Phase 12 — Secrets, Key Vault & Managed Identity
Difficulty: ⭐⭐⭐☆☆ (the mechanisms) → ⭐⭐⭐⭐⭐ (the judgment about secret-zero, blast radius, and rotation)
Estimated Time: 1 week (12–18 hours)
Prerequisites: Phase 03 (Entra — OAuth2 / OIDC / JWT; a managed-identity token is
a JWT, and IMDS is just another token endpoint), Phase 04 (RBAC — Key Vault's
recommended authorization model is the same Actions/DataActions evaluator, and reading
a secret is a data-plane action), and Phase 00 (control plane vs data plane; quantify
the tradeoff). No Azure subscription required for the lab.
Why This Phase Exists
Every breach post-mortem you will ever read has the same root cause near the top: a
secret in the wrong place. A connection string in appsettings.json committed to git. An
access key in an environment variable that leaked through a crash dump. A service principal
secret that expired at 3 a.m. and nobody knew where it was configured. The JD asks for an
engineer who can "integrate serverless applications with Azure security services, including
Azure Key Vault, Managed Identities, and Microsoft Entra ID." That sentence is the whole
game of this phase, and it is really one principle stated three ways:
The most secure secret is the one you never store.
This phase makes that principle mechanical. There are three mechanisms, and a principal must own all three down to the byte:
-
Managed Identity — the answer to "how does my code prove who it is without a credential in the code?" Azure manages an Entra service principal for the resource; the code asks the Instance Metadata Service (IMDS) at the non-routable link-local address
169.254.169.254for an access token, scoped to a target audience (the resource it wants to call), and the SDK caches that token until near expiry. There is no secret to rotate, leak, or expire-by-surprise, because there is no secret. A senior says "use a managed identity." A principal can tell you why the IP is non-routable (so only code on the resource can reach it), why the token is audience-scoped (so a leaked vault token can't read storage), and system-assigned vs user-assigned (lifecycle-bound to one resource vs standalone and attachable to many). -
Key Vault — for the secrets you can't eliminate (a third-party API key, a database password you don't control, a TLS certificate), a hardened, audited, RBAC-gated, versioned store. The recurring Azure distinction lives here in its sharpest form: control plane (manage the vault —
Owner,Contributor) is a different system from data plane (read the secret's value —Key Vault Secrets User). AnOwnerwho can delete the entire vault still gets a 403 reading a secret unless they hold a data-plane role. And the vault is recoverable by design: soft-delete makes a fat-fingereddeleteundoable for a retention window, and purge protection means not even a compromised admin can make a deletion permanent on demand. -
Envelope encryption — the mechanism behind every "encryption at rest with your own key" feature in Azure (Storage, SQL, Disk, Cosmos with customer-managed keys). You do not encrypt terabytes of data directly with a Key Vault key. You encrypt the data with a fast symmetric data encryption key (DEK), then wrap (encrypt) the small DEK with a key encryption key (KEK) that lives in the vault/HSM and never leaves it. You store
(ciphertext, wrapped_DEK)together. The payoff is rotation: to rotate the KEK you unwrap the DEK with the old key and rewrap it with the new — you touch a few bytes, not the petabyte. Key versioning means old wrapped DEKs still unwrap against the old key version, so rotation is non-breaking. This is the single most elegant idea in cloud security, and this phase makes you build it so it stops being a diagram and becomes a mechanism you can defend line by line.
What "Principal-Level" Means Here
A senior engineer uses Key Vault and managed identities correctly. A principal understands them well enough to:
- Eliminate secret-zero. They can take any "the app needs to read a database / a storage account / another API" requirement and produce a design with no stored credential: a managed identity for the Azure-native hops, Key Vault (read via the managed identity) only for the genuinely external secrets, and federation for cross-cloud / CI. They know the one irreducible secret ("secret-zero") and have made it a managed identity, not a string.
- Reason about the token flow from first principles. Given "my function calls Key Vault
in a tight loop and IMDS is throttling," they know it's a caching bug, not a scale
problem — the SDK should serve a cached token until near expiry. Given "the call returns
a token but Storage rejects it," they know it's an audience mismatch. They can draw
the
code → IMDS (169.254.169.254) → Entra → token (aud=vault) → vaultpath from memory. - Pick system- vs user-assigned on sight. System-assigned when the identity's lifecycle is the resource's and nothing else needs it; user-assigned when several resources share one identity (so you grant the RBAC role once), or when the identity must exist before the resource (to pre-assign roles and break a chicken-and-egg deploy ordering problem).
- Choose the authorization model and the rotation story. Azure RBAC over legacy
access policies (RBAC is auditable, deny-aware, and scoped through the same MG/Sub/RG
hierarchy as everything else). And a rotation design: short-lived where possible, Key
Vault references so apps never hold the secret, event-driven rotation
(
SecretNearExpiry→ Event Grid → a rotation function), and envelope encryption so the key rotates without re-encrypting the data. - Treat the vault as a blast-radius unit. They know a single vault is a failure domain and a throttling unit; they segment vaults by environment/blast-radius, enable soft-delete + purge protection on anything that matters, and never make the vault the single point of failure for a hot path (cache the secret, fail open/closed deliberately).
Concepts
- Managed Identity = an Azure-managed Entra service principal. The platform creates and
rotates the credential; your code never sees it. System-assigned: created with the
resource, deleted with it, exactly one per resource, identity = the resource's lifecycle.
User-assigned: a standalone Azure resource (
Microsoft.ManagedIdentity/userAssigned Identities) you attach to one or many resources and select byclient_id. - IMDS (Instance Metadata Service). A read-only REST endpoint at the link-local,
non-routable address
169.254.169.254, reachable only from the resource itself, that among other things issues OAuth2 access tokens for the resource's managed identity at/metadata/identity/oauth2/token?resource=<audience>. The credential SDK (DefaultAzureCredential/ManagedIdentityCredential) calls it and caches the token per(identity, audience)until shortly before expiry. - The audience (resource/scope). A token is minted for one target service — its
audclaim.https://vault.azure.netfor Key Vault,https://storage.azure.comfor Storage,https://management.azure.comfor ARM. A token is rejected by any service that is not its audience; reusing the wrong-audience token is a classic silent 401. - Workload identity federation (no secret at all). Beyond IMDS: AKS pods and GitHub Actions can present an external OIDC token, and Entra exchanges it for an access token via a federated credential trust — no client secret, no IMDS. The same "no stored secret" principle, extended across trust boundaries (you built the OIDC half in P03/P08).
- Key Vault objects: secrets, keys, certificates — all versioned. A secret is an
opaque value (string ≤ 25 KB). A key is an asymmetric/symmetric key you use without
exporting (
encrypt,decrypt,wrapKey,unwrapKey,sign,verify). A certificate bundles a key + an X.509 cert with a lifecycle (issuance, auto-rotation, CA policy). Every object is versioned: each write creates an immutable version with its own id/URL; omitting the version returns current. - Two authorization models: RBAC (recommended) vs access policies (legacy). Azure
RBAC uses the same role-assignment + inheritance engine as the rest of Azure
(
Key Vault Secrets User,Secrets Officer,Administrator,Crypto User, …), is auditable and deny-aware, and is scoped at vault or even individual-secret level. Access policies are a per-vault list of(principal → permitted operations)— simpler but not inheritable, not deny-aware, and harder to audit. New vaults should use RBAC. - Control plane vs data plane (the sharpest example in Azure). Managing the vault
resource (create, delete, configure firewall, set the authorization model) is control
plane via ARM/RBAC. Reading a secret's value, wrapping a key, signing — data
plane, gated by data-plane roles.
Owner(control plane) does not grant secret read (data plane). Confusing the two is the #1 Key Vault 403. - Soft-delete + purge protection. Deletes are soft (always on now): the object/vault is recoverable for a retention window (7–90 days, default 90). Purge protection, when enabled (irreversibly), means a soft-deleted object cannot be purged before retention elapses — not by an admin, not by an attacker. It's the guarantee that deletion cannot be weaponized.
- Envelope encryption. Encrypt data with a DEK; wrap the DEK with a KEK in
the vault/HSM; store
(ciphertext, wrapped_DEK). Rotate the KEK by unwrapping + rewrapping the DEK only —O(#DEKs), neverO(bytes). Key versioning keeps old wrapped DEKs decryptable. This is how Azure Storage/SQL/Disk customer-managed-key encryption works. - Managed HSM / FIPS framing. Standard Key Vault is multi-tenant, FIPS 140-2 Level 2. Azure Key Vault Managed HSM is a single-tenant, fully customer-controlled, FIPS 140-2 Level 3 HSM pool for regulated workloads and the highest key-isolation requirements.
Labs
Lab 01 — Key Vault & Managed Identity: Token Acquisition + Envelope Encryption (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build a miniature of Azure's secret-zero stack: an IMDS token cache (acquire_token(resource, identity, now) → audience-scoped AccessToken, cached per (identity, resource), refreshed only near expiry; system- vs user-assigned selectors; token_is_valid checks audience and expiry); envelope encryption (keystream/enc/dec, generate_dek, wrap/unwrap, envelope_encrypt/decrypt, and rotate_kek that rewraps the DEK only and leaves the data ciphertext byte-identical); and the Key Vault data model (versioned secrets, RBAC data-plane default-deny, and the soft-delete → recover → purge state machine where purge protection blocks an early purge) |
| Concepts | managed identity (system vs user-assigned); IMDS + token caching + refresh skew; audience scoping; envelope encryption (DEK/KEK, wrap/unwrap); rewrap-on-rotate (O(#DEKs), data untouched); key versioning; control plane vs data plane; RBAC default-deny; secret versioning; soft-delete & purge protection |
| Steps | 1. Identity + system_assigned/user_assigned; 2. InstanceMetadataService.acquire_token (cache + refresh skew) + token_is_valid; 3. keystream/enc/dec; 4. generate_dek/wrap_dek/unwrap_dek; 5. envelope_encrypt/envelope_decrypt/rotate_kek; 6. KeyVault versioning + RBAC; 7. soft-delete state machine |
| How to Test | pytest test_lab.py -v — 29 tests: token caching (same token before expiry, new after, refresh within skew), audience scoping, system/user separation, validity boundary; enc/dec & wrap/unwrap round-trips; envelope round-trip; KEK rotation keeps the data ciphertext identical and still decrypts; RBAC default-deny then allow (and Owner denied); versioning latest vs pinned; soft-delete→recover; recover-after-retention fails; purge blocked under purge protection and allowed when off |
| Talking Points | "Walk me through how a Managed Identity gets a token — IMDS, the audience, the cache." / "System- vs user-assigned: when each?" / "Why does an Owner of a vault get a 403 reading a secret?" / "Explain envelope encryption and why KEK rotation doesn't re-encrypt the data." / "What does purge protection protect you from?" |
| Resume bullet | Built a runnable model of Azure's secret-zero security stack — managed-identity IMDS token acquisition with audience scoping and per-identity caching, DEK/KEK envelope encryption with O(#DEKs) rewrap-only key rotation, and a Key Vault data model with RBAC data-plane default-deny, secret versioning, and a soft-delete / purge-protection state machine |
→ Lab folder: lab-01-keyvault-identity/
Integrated-Scenario Suggestions (carried through the whole track)
The phases compound. Keep these in mind as you build — each wires secrets and identity into the larger platform:
- Zero-secret serverless app. A Function App (P11) with a user-assigned managed
identity reads a third-party API key from Key Vault via a Key Vault reference
(
@Microsoft.KeyVault(SecretUri=...)) injected at startup, calls Storage with the same identity (no key), and is fronted by API Management (P09) validating the caller's JWT (P03). The only "credential" anywhere is the managed identity. (→ P11, P09, P03) - Customer-managed-key encryption at rest. A Storage account / SQL DB uses a CMK in Key Vault: the service holds a wrapped DEK and unwraps it via the vault using its managed identity. You model the rotation — a new KEK version, rewrap the DEK, data untouched — and the revocation path (disable the key → the data is cryptographically unreadable). (→ P00 control/data plane)
- Event-driven secret rotation. Key Vault emits a
SecretNearExpiryevent to Event Grid (P10); a Durable Function (P11) rotates the credential at the source (e.g. regenerates a storage key), writes the new version to the vault, and verifies before flipping traffic — all authenticated by managed identity, no human in the loop. (→ P10, P11) - CI/CD with no stored secret. A GitHub Actions / Azure DevOps pipeline (P08)
authenticates to Azure via workload identity federation (OIDC → Entra, no client
secret) and reads deployment secrets from Key Vault scoped to the pipeline's identity.
The federated subject (
repo:org/name:ref:...) is the trust. (→ P08, P03) - Regulated workload on Managed HSM. A workload requiring FIPS 140-2 Level 3 uses Azure Key Vault Managed HSM for its KEKs, with RBAC local roles, purge protection mandatory, and key material that never leaves the single-tenant HSM — verified by attestation. You reason about the cost/isolation tradeoff vs standard Key Vault. (→ P05 governance)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- The most secure secret is the one you never store. A Managed Identity removes
secret-zero for Azure-native hops: code asks IMDS (
169.254.169.254) for an audience-scoped, cached token with no credential in the code. System-assigned = tied to one resource; user-assigned = standalone and shareable. Federation extends "no secret" across trust boundaries. - Control plane ≠ data plane — Key Vault is the sharpest example. Managing the vault is
control plane (
Owner); reading a secret's value is data plane (Key Vault Secrets User). AnOwnerwho can delete the vault still can't read a secret without a data-plane role. Use Azure RBAC over legacy access policies. - Envelope encryption makes key rotation cheap. Encrypt data with a DEK, wrap the
DEK with a KEK in the vault/HSM, store both. Rotating the KEK rewraps the DEK only
—
O(#DEKs), neverO(bytes)— and key versioning keeps old data decryptable. This is the engine behind every CMK feature in Azure. - Deletes are recoverable on purpose. Soft-delete makes a fat-finger undoable for a retention window; purge protection means deletion can't be made permanent on demand — not by an admin, not by an attacker. Enable both on anything that matters.
- The vault is a blast-radius and throttling unit. Segment by environment, cache the secret on the hot path, and never let the vault be the single point of failure for a request you can't afford to fail.
Deliverables Checklist
-
Lab 01 implemented; all 29 tests pass against
solution.pyand yourlab.py -
You can draw the managed-identity token flow (
code → IMDS → token aud=vault → vault) from memory and explain the cache and the audience - You can state when to choose system- vs user-assigned, with a concrete reason for each
-
You can explain why an
Ownerof a Key Vault gets a 403 reading a secret - You can explain envelope encryption and why KEK rotation does not re-encrypt the data
- You can explain soft-delete vs purge, and exactly what purge protection guarantees
Warmup Guide — Secrets, Key Vault & Managed Identity
Zero-to-principal primer for Phase 12: the one principle that organizes all of cloud secret management (the most secure secret is the one you never store), how a Managed Identity turns that principle into a mechanism via IMDS, what a Key Vault actually is (a versioned, RBAC-gated, soft-deletable store where control plane is a different system from data plane), and the envelope encryption trick that makes key rotation cost a few bytes instead of a petabyte. Every concept goes from what it is to why it exists to the mechanism under the hood (diagrams, tables, code) to production significance to the misconceptions that cause breaches and 2 a.m. pages.
Table of Contents
- Chapter 1: The One Principle — Don't Store the Secret
- Chapter 2: Managed Identity — An Azure-Managed Service Principal
- Chapter 3: IMDS and the Token Flow
- Chapter 4: Key Vault — The Versioned, RBAC-Gated Store
- Chapter 5: Envelope Encryption — DEK, KEK, and the Wrap
- Chapter 6: Key Rotation and Versioning
- Chapter 7: Soft-Delete and Purge Protection
- Chapter 8: Managed HSM, FIPS, and Certificates
- Chapter 9: Where Secrets Sit in the Platform
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The One Principle — Don't Store the Secret
From zero. A secret is anything that, if an attacker has it, lets them act as you: a password, an API key, a connection string, a private key, a client secret. The entire discipline of secret management exists because secrets spread. You put a connection string in a config file; the config file goes into git; git gets cloned to ten laptops; one laptop gets a crash dump uploaded to a support portal; now your database password is in a vendor's ticketing system. Every link in that chain is a place the secret can leak, and you control none of them.
The principal-level reframing — the sentence that organizes this whole phase — is:
The most secure secret is the one you never store.
If there is no stored secret, there is nothing to leak, rotate, or expire by surprise. This is not a slogan; it is an engineering target, and Azure gives you the mechanisms to hit it:
- For one Azure resource calling another Azure service (a Function calling Storage, a VM calling Key Vault), you can have zero stored secrets — a Managed Identity (Ch. 2) gets a short-lived token from the platform on demand.
- For secrets you cannot eliminate (a third-party API key, a partner's database password, a TLS cert you must present), you store them in one hardened, audited, access- controlled place — Key Vault (Ch. 4) — and read them using the managed identity, so the only "credential" the app holds is, again, the managed identity (which isn't a stored secret).
- For encryption, you don't store the data's key with the data; you wrap it with a key that lives in the vault/HSM — envelope encryption (Ch. 5).
The reducible thing — the one credential you can't make disappear — is called secret-zero. The principal's job is to make secret-zero a managed identity (a platform-issued, auto- rotated, audience-scoped token) rather than a string in a file. Hold this principle; every mechanism below is in service of it.
Chapter 2: Managed Identity — An Azure-Managed Service Principal
What it is. A Managed Identity is a special kind of Entra (Azure AD) service principal whose credential is created, stored, and rotated by Azure itself. Your code never sees the credential. When the code needs to call an Azure service, it asks the platform for a short-lived access token (a JWT — you validated those in P03) and presents that. No password, no client secret, no certificate in your code or config.
Why it exists. Before managed identities, an app authenticated to Azure as an Entra app registration with a client secret or certificate — a real credential you had to generate, store somewhere (ironically, often in Key Vault, which you then needed another credential to reach), and rotate before it expired. Expiring client secrets are a perennial outage cause: the secret silently lapses at midnight and the app starts returning 401s with nobody knowing where the secret was even configured. Managed identities delete that entire class of problem by making Azure own the credential lifecycle.
The two kinds — this is an interview staple:
| System-assigned | User-assigned | |
|---|---|---|
| Lifecycle | created with the resource, deleted with it | a standalone Azure resource; independent lifecycle |
| Count | exactly one per resource | attach one identity to many resources |
| Identity = | the resource itself | a shareable identity object |
| Selected by | (implicit — the resource's own) | its client_id (or object id / resource id) |
| Use when | the identity's life is the resource's; nothing else needs it | several resources share one identity (grant RBAC once), or the identity must exist before the resource (break a deploy ordering chicken-and-egg) |
System-assigned: User-assigned:
┌───────────┐ ┌──────────────────┐
│ Function │── owns ──▶ Identity │ Identity (UAI) │
└───────────┘ (1:1, dies with it) └───────┬──────────┘
attached to ▼ ▼ ▼
Function VM AKS pod (1 identity → N resources)
The principal nuance. User-assigned wins more often than juniors expect, for two
reasons. (1) RBAC grant amplification: if 30 functions need Key Vault Secrets User,
30 system-assigned identities means 30 role assignments to manage; one user-assigned
identity means one. (2) Deploy ordering: a system-assigned identity doesn't exist
until the resource does, so you can't pre-assign its roles in the same template without a
two-phase deploy; a user-assigned identity you create first, grant roles to, then attach
— the deploy is clean and idempotent.
Chapter 3: IMDS and the Token Flow
What it is. The Instance Metadata Service (IMDS) is a read-only REST endpoint
available inside every Azure VM / App Service / Function / container at the link-local
address 169.254.169.254. Link-local means it is non-routable — packets to it never
leave the host; only code on that resource can reach it. Among other metadata, IMDS issues
OAuth2 access tokens for the resource's managed identity:
GET http://169.254.169.254/metadata/identity/oauth2/token
?api-version=2018-02-01
&resource=https://vault.azure.net ◀── the AUDIENCE you want a token for
Header: Metadata: true ◀── anti-SSRF guard (a browser can't set it trivially)
Why the IP is non-routable — and why that's the security model. Because
169.254.169.254 is reachable only from the host, possession of "I can call IMDS" is the
proof of identity. There is no secret to present because being able to ask is the
credential. (This is also why SSRF — tricking a server into making a request on your
behalf — is the canonical attack on IMDS: if you can make the app fetch a URL you control,
you make it fetch IMDS and steal its token. IMDS v2 mitigations and the Metadata: true
header requirement exist for exactly this.)
The flow, end to end:
your code IMDS (169.254.169.254) Entra Key Vault
│ get_token("vault") │ │ │
├───────────────────────────────▶│ "give me a token, │ │
│ │ aud=vault.azure.net" │ │
│ ├─────────────────────────▶│ mint JWT (aud, │
│ │◀─────────────────────────┤ exp, oid, tid) │
│◀───────────────────────────────┤ {access_token, expires_on, resource} │
│ GET /secrets/db-pwd (Bearer <token>) ───────────────────────────────────▶│
│◀──────────────────────────────────────────────────── 200 {value} (or 403) │
The audience (resource/scope) is load-bearing. The token's aud claim names the
one service it's valid for. A token minted for https://vault.azure.net is rejected by
https://storage.azure.com. The most common "it returns a token but the call 401s" bug is
asking IMDS for the wrong audience. In the lab, token_is_valid(token, resource, now) checks
both the audience match and the expiry — because those are the two independent ways a
token silently fails.
Caching — the production-critical part. A function that reads Key Vault in a hot loop
must not call IMDS on every iteration; that would hammer the metadata endpoint and add
latency to every call. The credential SDK (DefaultAzureCredential /
ManagedIdentityCredential) caches the token per (identity, audience) and only
refetches when it's near expiry. The lab models exactly this:
key = (identity.principal_key, resource)
cached = self._cache.get(key)
if cached is not None and token_is_valid(cached, resource, now, skew_s=self.refresh_skew_s):
return cached # ◀── cache hit: SAME token, no IMDS call
# else mint, cache, count an imds hit
The refresh skew (refresh_skew_s) makes the SDK refetch before the hard expiry — so a
request never races a token expiring mid-flight. In the lab, imds_calls counts real
fetches; three acquire_token calls (before expiry, before expiry, after expiry) produce
exactly two IMDS hits. That assertion is the caching contract.
Misconception to kill: "Managed identity = no token." No — there is very much a token (a
full JWT with aud, exp, oid, tid, xms_mirid). What's missing is a stored
credential. The token is short-lived, audience-scoped, and fetched on demand; it just isn't
a secret you saved anywhere.
Beyond IMDS — workload identity federation (no secret at all). For AKS pods and CI
runners (GitHub Actions, Azure DevOps), there's an even cleaner pattern: the workload
presents an external OIDC token (issued by Kubernetes or GitHub), and Entra exchanges it
for an Azure access token via a configured federated credential trust — no client
secret, no IMDS. The trust is the OIDC issuer + subject (e.g.
repo:contoso/app:ref:refs/heads/main). It's the same "don't store the secret" principle
projected across a trust boundary; you built the OIDC validation half in P03/P08.
Chapter 4: Key Vault — The Versioned, RBAC-Gated Store
What it is. Azure Key Vault is a managed service for storing and controlling access to three kinds of object, all versioned:
| Object | What it is | Key data-plane ops |
|---|---|---|
| Secret | an opaque value (string, ≤ 25 KB) — API keys, connection strings | get, set, list, delete |
| Key | an asymmetric/symmetric key you use without exporting | encrypt/decrypt, wrapKey/unwrapKey, sign/verify |
| Certificate | an X.509 cert + its key, with a lifecycle | create, import, auto-rotate, CA policy |
The critical word is versioned. Every write creates a new immutable version with its own id and URL:
https://myvault.vault.azure.net/secrets/db-password/9f3c... ◀── a specific version
https://myvault.vault.azure.net/secrets/db-password ◀── "current" (latest)
Old versions stay readable. This is what makes rotation non-breaking: a client pinned to
version 9f3c… keeps working while you set a new current version, and you flip consumers
over deliberately.
Why it exists. Centralizing secrets into one hardened, audited, access-controlled service is the only way to make the chaos of Chapter 1 tractable: one place to rotate, one audit log of who read what, one firewall/private-endpoint boundary, one backup. The alternative — secrets scattered across config files, pipelines, and env vars — is unauditable and unrotatable.
The two authorization models. This is a real design decision, not trivia:
- Azure RBAC (recommended). The same role-assignment engine as the rest of Azure (you
built it in P04). Roles like
Key Vault Secrets User(read secrets),Key Vault Secrets Officer(read+write),Key Vault Administrator,Key Vault Crypto User. Assignments inherit down the MG → Sub → RG → vault → individual secret hierarchy, are deny-aware, and are auditable like everything else. Default-deny: no matching assignment → no access. - Access policies (legacy). A per-vault list of
(principal → allowed operations). Simpler to reason about for one vault, but not inheritable, not deny-aware, capped in count, and harder to audit at scale. New vaults should use RBAC.
Control plane vs data plane — Key Vault is the sharpest example in all of Azure. Burn this in:
CONTROL PLANE (ARM + RBAC) DATA PLANE (vault endpoint + data-plane RBAC)
──────────────────────────── ─────────────────────────────────────────────
create / delete the vault read a secret's VALUE
configure firewall, networking wrap / unwrap / sign with a key
SET the authorization model list secret names
roles: Owner, Contributor roles: Key Vault Secrets User, Crypto User
An Owner of the vault can delete the entire thing but gets a 403 reading a single
secret, because reading a value is a data-plane action and Owner is a control-plane
role. This trips up nearly everyone once. In the lab:
owner_only = [RoleAssignment(principal=app, role="Owner")]
kv.get_secret("api-key", principal=app, role_assignments=owner_only) # ▶ PermissionError
reader = [RoleAssignment(principal=app, role="Key Vault Secrets User")]
kv.get_secret("api-key", principal=app, role_assignments=reader) # ▶ ok
(There is a deliberate exception in real Azure: with RBAC, a control-plane Owner can
grant themselves a data-plane role — but holding Owner alone does not read secrets.)
Chapter 5: Envelope Encryption — DEK, KEK, and the Wrap
The problem. You want "encryption at rest with a key you control" for a 50 TB storage account. The naive approach — encrypt all 50 TB directly with a Key Vault key — fails for two reasons: (1) Key Vault keys (especially RSA) are slow and rate-limited; you can't push 50 TB through them, and (2) when you rotate the key you'd have to re-encrypt all 50 TB, which is absurd.
The solution — envelope encryption. Use two keys:
- A DEK (data encryption key) — a fast symmetric key (AES) that actually encrypts the data. It can be per-object, generated fresh from a CSPRNG.
- A KEK (key encryption key) — a key that lives in Key Vault / the HSM and never leaves it. You use it only to wrap (encrypt) the tiny DEK.
┌─────────────── stored together at rest ───────────────┐
data ──▶ [ enc with DEK ] ──▶ ciphertext │
DEK ──▶ [ wrap with KEK in the vault/HSM ] ──▶ wrapped_DEK │
└────────────────────────────────────────────────────────┘
KEK stays in the vault — only its wrap/unwrap operations are called over the wire.
To decrypt: send wrapped_DEK to the vault, get the DEK back (the vault unwrapKeys it
with the KEK that never left), then decrypt the ciphertext locally with the DEK. The lab's
envelope_encrypt/envelope_decrypt are exactly this:
def envelope_encrypt(kek, plaintext, dek):
ciphertext = enc(dek, plaintext) # fast symmetric over the data
wrapped = wrap_dek(kek, dek) # encrypt the small DEK under the KEK
return ciphertext, wrapped # persist BOTH
def envelope_decrypt(kek, ciphertext, wrapped_dek):
dek = unwrap_dek(kek, wrapped_dek) # KEK unwraps the DEK
return dec(dek, ciphertext) # DEK decrypts the data
⚠️ The lab's cipher is a TEACHING cipher — SHA-256 counter-mode keystream XORed into the plaintext, with no authentication, no nonce, and keystream reuse. It is structurally identical to real envelope encryption (DEK/KEK, wrap/unwrap, rewrap-on- rotate), which is the entire lesson, but it must never encrypt anything real. Azure uses AES-GCM for the data and RSA-OAEP / AES-KW for the wrap, inside FIPS-validated HSMs.
Chapter 6: Key Rotation and Versioning
Why envelope encryption is worth it: rotation becomes cheap. Here is the payoff that makes the whole construction worthwhile. To rotate the KEK (because it's old, or suspected compromised, or policy says rotate every 90 days), you do not re-encrypt the data. You:
def rotate_kek(old_kek, new_kek, wrapped_dek):
dek = unwrap_dek(old_kek, wrapped_dek) # 1. unwrap the DEK with the old KEK
return wrap_dek(new_kek, dek) # 2. rewrap it with the new KEK
# the DATA ciphertext is NEVER touched.
The cost of rotating the KEK is O(number of DEKs), not O(bytes of data). Rotating the
key for a 50 TB account rewraps a handful of DEKs — milliseconds — and the 50 TB of ciphertext
is byte-for-byte unchanged. The lab proves this with an assertion that the data ciphertext
object is identical before and after rotation:
ciphertext_before = bytes(ciphertext)
new_wrapped = rotate_kek(old_kek, new_kek, wrapped)
assert ciphertext == ciphertext_before # ◀── the whole point
assert envelope_decrypt(new_kek, ciphertext, new_wrapped) == plaintext
Key versioning makes rotation non-breaking. When you rotate, the new KEK is a new version of the same key object. Old wrapped DEKs were wrapped against the old version, which still exists, so they still unwrap — there's no "rotate and everything that hasn't been rewrapped breaks" cliff. You rewrap lazily or on a schedule. The same versioning logic that keeps a pinned secret version readable (Ch. 4) keeps old key versions usable for decryption.
Production significance. This is the literal engine behind customer-managed keys (CMK) for Azure Storage, SQL, Disk, and Cosmos: the service stores a wrapped DEK, calls your Key Vault to unwrap it via its managed identity, and when you rotate your KEK it rewraps — your data is never re-encrypted, and if you disable the KEK, the wrapped DEKs can no longer be unwrapped and the data is cryptographically unreadable (the revocation / "crypto-shredding" story). Rotation is a few bytes; revocation is instant; neither touches the data. That is the elegance.
Chapter 7: Soft-Delete and Purge Protection
The problem. Secrets and keys get deleted — by accident (a bad script, a fat finger), by malice (a disgruntled admin, ransomware), or as collateral in a wrong cleanup. If a delete is immediate and permanent, a single mistake or a single compromised credential can destroy the keys protecting your data — which, with envelope encryption, means destroying the data. That is an unacceptable single point of catastrophe.
Soft-delete (always on now). A delete does not remove the bytes; it moves the object to
a deleted-but-recoverable state for a retention window (configurable 7–90 days, default
90). Within that window you can recover it. The state machine the lab implements:
set_secret delete_secret(now) recover_secret(now < purge_on)
(none) ──────────▶ [ active ] ───────────────────▶ [ soft-deleted ] ──────────────────────▶ [ active ]
▲ │ recoverable until
│ │ scheduled_purge_on = deleted_on+retention
└──────────────────────────────┘
│ purge_secret(now)
▼
[ gone ] (permanent)
def delete_secret(self, name, now):
entry.deleted = True
entry.scheduled_purge_on = now + self.retention_s # recoverable until here
While soft-deleted, the secret is not readable (get_secret raises) but its bytes are
not gone. After retention elapses it is auto-purged and recover fails.
Purge protection — the security guarantee. Soft-delete alone isn't enough against a
determined attacker: if they can delete and purge, they make the deletion permanent
immediately. Purge protection, when enabled (and it's irreversible once on), means a
soft-deleted object cannot be purged before the retention window elapses — by anyone,
including a Global Admin, including the attacker. The lab:
def purge_secret(self, name, now):
if self.purge_protection and now < entry.scheduled_purge_on:
raise PermissionError("purge protection: cannot purge until retention elapses")
del self._secrets[name]
So even a fully compromised admin credential cannot make the deletion permanent — you have a guaranteed window to detect the deletion (it fires an event/alert) and recover. Enable soft-delete and purge protection on any vault that protects something you can't afford to lose. The tradeoff: you cannot reuse a vault/secret name until retention elapses, which occasionally annoys CI that creates-and-destroys vaults — a small price.
Chapter 8: Managed HSM, FIPS, and Certificates
Standard Key Vault vs Managed HSM. Standard Key Vault is multi-tenant and validated to FIPS 140-2 Level 2 (software-protected keys; HSM-backed keys in the Premium SKU). Azure Key Vault Managed HSM is a single-tenant, fully customer-controlled pool of FIPS 140-2 Level 3 hardware HSMs — the key material is generated and used only inside the HSM, never extractable, with cryptographic attestation. You reach for Managed HSM for regulated workloads (PCI, FIPS L3 mandates), the highest key-isolation requirements, or when you need a security-domain you alone control. It costs meaningfully more and has its own local RBAC roles — the cost/isolation tradeoff is a principal decision, not a default.
| Key Vault (Standard/Premium) | Managed HSM | |
|---|---|---|
| Tenancy | multi-tenant | single-tenant |
| FIPS 140-2 | Level 2 (Premium: HSM-backed keys) | Level 3 |
| Key extractable | no (Premium HSM keys) | never |
| Use when | most apps, secrets, certs | regulated / highest isolation |
Certificates. A Key Vault certificate bundles a private key + an X.509 cert and adds a lifecycle: issuance (self-signed, or via an integrated CA like DigiCert), an issuance policy, and auto-rotation (Key Vault renews the cert a configured number of days before expiry, generating a new version — the same versioning model). The principal value is the same as everywhere in this phase: the renewal is automated and the private key never leaves the vault, so a cert expiring at midnight stops being an outage.
Chapter 9: Where Secrets Sit in the Platform
Secrets and identity are not a corner of the platform — they are the connective tissue that makes every other phase secure:
- P03 (Entra/JWT) — a managed-identity token is a JWT; IMDS is a token endpoint; the
target service validates
aud/exp/signature with the validator you built. - P04 (RBAC) — Key Vault's recommended authorization is the same role + inheritance +
default-deny engine; "read a secret" is a
DataAction. - P11 (Functions/Durable) — a function authenticates outbound (to Storage, to Key Vault, to another API) with its managed identity; Key Vault references inject secrets into app settings, resolved via that identity, so the code holds nothing.
- P10 (Event Grid/Service Bus) — Key Vault emits
SecretNearExpiry/SecretExpiredevents; an event-driven rotation function reacts with no human in the loop. - P08 (CI/CD) — pipelines authenticate via workload identity federation (OIDC → Entra, no stored secret) and read deploy-time secrets from a scoped vault.
- P00 (control vs data plane) — Key Vault is the canonical example, and the lesson
("
Owner≠ secret reader") is one you'll apply to Storage, Service Bus, and everything else.
The mental model: identity (a managed identity) replaces the credential; Key Vault holds the irreducible secrets; envelope encryption protects the data; RBAC gates the data plane; and soft-delete/purge-protection make the whole thing recoverable. Those five sentences are the phase.
Lab Walkthrough Guidance
Lab 01 — Key Vault & Managed Identity, suggested order (matches lab.py top-to-bottom):
Identity+system_assigned/user_assigned(Ch. 2) — the validation is the lesson: system-assigned has noclient_id; user-assigned requires one.principal_keyis the stable cache key.InstanceMetadataService.acquire_token+token_is_valid(Ch. 3) — the centerpiece of Part 1. Build the cache: look up(identity, resource); return the cached token if still valid with the refresh skew; else mint (expires_on = now + ttl), cache, bumpimds_calls.token_is_validchecks audience and expiry. Maketest_token_cached_before_expiry_same_tokenand..._refreshed_after_expiry_...pass — that pair is the caching contract.keystream/enc/dec(Ch. 5) — SHA-256 counter mode;decis justenc. Get the round-trip green first.generate_dek/wrap_dek/unwrap_dek— thin wrappers overenc/dec; the naming is the teaching (a DEK wrapped under a KEK).envelope_encrypt/envelope_decrypt/rotate_kek(Ch. 5–6) — the payoff. Maketest_kek_rotation_data_ciphertext_object_unchangedpass and understand why it's the whole point: rotation rewraps the DEK only.KeyVaultversioning + RBAC (Ch. 4) —set_secretappends a version;get_secretreturns latest or a pinned version and denies by default unless the principal holds a data-plane role (Owneris not one).- Soft-delete state machine (Ch. 7) —
delete→recover(within retention) →purge(blocked by purge protection inside retention). Make the purge-protection test pass and you own the security guarantee.
Run it red, make it green: pytest test_lab.py -v, then
LAB_MODULE=solution pytest test_lab.py -v, then python solution.py.
Success Criteria
You are ready for the next phase when you can, from memory:
- State the one principle ("don't store the secret") and name secret-zero.
- Draw the managed-identity token flow
code → IMDS (169.254.169.254) → Entra → token (aud=vault) → vault, and explain why the IP is non-routable and why the token is cached. - Pick system- vs user-assigned with a concrete reason for each (RBAC grant amplification; deploy ordering).
- Explain why an
Ownerof a Key Vault gets a 403 reading a secret (control vs data plane). - Choose RBAC over access policies and say why (inheritable, deny-aware, auditable).
- Explain envelope encryption (DEK/KEK/wrap) and why KEK rotation is
O(#DEKs), notO(bytes)— and never re-encrypts the data. - Explain soft-delete vs purge, and exactly what purge protection guarantees against a compromised admin.
Interview Q&A
Q: "My app needs to read a database password and call a storage account. Walk me through a
design with no stored secrets."
The storage hop has zero stored secrets: give the app a managed identity and a
data-plane role (Storage Blob Data Reader); it gets an audience-scoped token from IMDS on
demand. The database password I can't eliminate (the DB isn't Entra-native, say), so it goes
in Key Vault — but the app reads it using the same managed identity (granted Key Vault Secrets User), so the only "credential" anywhere is the managed identity, which isn't a stored
secret. If the app is a Function, I'd use a Key Vault reference so the secret is injected as
an app setting resolved via the identity, and the code never even touches the vault SDK. Net:
secret-zero is a managed identity, not a string in a file.
Q: "I gave my service principal Owner on the Key Vault and it still can't read the secret.
Why?"
Because reading a secret's value is a data-plane action and Owner is a control-plane
role. Owner/Contributor let you manage the vault resource — create it, delete it, set its
firewall, even change its authorization model — but not read what's inside. You need a
data-plane role like Key Vault Secrets User. This control-plane-vs-data-plane split is the
single most common Key Vault 403 and shows up identically across Azure (an account Owner
isn't a Blob Data Reader either).
Q: "Explain envelope encryption and why you'd use it for encryption at rest."
You encrypt the data with a fast symmetric DEK, then wrap (encrypt) the small DEK with
a KEK that lives in Key Vault / an HSM and never leaves it; you store the ciphertext and
the wrapped DEK together. Two payoffs. Performance: the bulk crypto is local AES, not slow
rate-limited vault calls. Rotation: to rotate the KEK you only unwrap the DEK with the old
key and rewrap with the new — O(#DEKs), never re-encrypting the data — and key versioning
keeps old wrapped DEKs decryptable so rotation is non-breaking. It's the engine behind Azure's
customer-managed-key encryption, and it also gives you instant revocation: disable the KEK
and every wrapped DEK becomes unusable, so the data is cryptographically unreadable without
ever touching it.
Q: "What does soft-delete with purge protection actually protect you from?"
Soft-delete makes a delete recoverable for a retention window (default 90 days) — it defends
against accidents. Purge protection defends against malice: when it's on (irreversibly), a
soft-deleted object cannot be purged before retention elapses by anyone, including a Global
Admin or a fully compromised credential. So an attacker who deletes your keys can't make it
permanent on demand; you get a guaranteed window — during which the deletion fires an alert —
to detect and recover. Without purge protection, delete + purge is an instant, irreversible
loss; with it, deletion can't be weaponized. I enable both on anything I can't afford to lose.
Q: "System-assigned or user-assigned managed identity — how do you choose?" System-assigned when the identity's lifecycle is the resource's and nothing else needs that identity — it's created and destroyed with the resource, one-to-one, nothing to clean up. User-assigned when (a) several resources should share one identity so I grant the RBAC role once instead of N times, or (b) the identity must exist before the resource so I can pre-assign its roles and avoid a two-phase deploy (a system-assigned identity doesn't exist until its resource does, which creates a chicken-and-egg in IaC). At scale I lean user-assigned for the RBAC-grant economy and clean deploy ordering.
Q: "How does a CI pipeline authenticate to Azure without a stored secret?"
Workload identity federation. The pipeline (GitHub Actions / Azure DevOps) presents the
OIDC token its platform issues; I configure a federated credential on an Entra app /
user-assigned identity that trusts that issuer + subject (e.g.
repo:org/app:ref:refs/heads/main); Entra exchanges the external OIDC token for an Azure access
token. No client secret to store, rotate, or leak. It's the same "don't store the secret"
principle as a managed identity, extended across the cloud boundary — and it's why
secretless OIDC deploys are now the standard over the old service-principal-with-a-secret
pattern.
References
- Microsoft Learn — What are managed identities for Azure resources? (https://learn.microsoft.com/azure/active-directory/managed-identities-azure-resources/overview)
- Microsoft Learn — Azure Instance Metadata Service (IMDS) and the identity token endpoint (https://learn.microsoft.com/azure/virtual-machines/instance-metadata-service)
- Microsoft Learn — Azure Key Vault basic concepts and Authentication / RBAC vs access policies (https://learn.microsoft.com/azure/key-vault/general/basic-concepts)
- Microsoft Learn — Key Vault soft-delete overview and purge protection (https://learn.microsoft.com/azure/key-vault/general/soft-delete-overview)
- Microsoft Learn — Azure Storage encryption / envelope encryption with customer-managed keys (https://learn.microsoft.com/azure/storage/common/storage-service-encryption)
- Microsoft Learn — Azure Key Vault Managed HSM and FIPS 140-2 Level 3 (https://learn.microsoft.com/azure/key-vault/managed-hsm/overview)
- Microsoft Learn — Workload identity federation (https://learn.microsoft.com/azure/active-directory/workload-identities/workload-identity-federation)
- RFC 6749 (OAuth 2.0) and RFC 7519 (JSON Web Token) — the token the lab models is a JWT (you validate it in P03)
- The track's own CHEATSHEET.md and GLOSSARY.md
🛸 Hitchhiker's Guide — Phase 12: Secrets, Key Vault & Managed Identity
Read this if: you've used Key Vault from the portal but "how does a managed identity actually get a token?" and "why does my
Ownerget a 403?" still feel fuzzy. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the lab.
0. The 30-second mental model
The most secure secret is the one you never store. A Managed Identity removes the
stored credential for Azure-native hops — your code asks IMDS (169.254.169.254) for a
short-lived, audience-scoped, cached token, no secret in the code. For the secrets you
can't eliminate, Key Vault is the one hardened, versioned, RBAC-gated store — and
reading a secret is a data-plane action, so Owner (control plane) gets a 403. For
encryption, envelope encryption wraps a data key (DEK) with a vault key (KEK), so rotating
the key costs a few bytes, not the petabyte. One sentence to tattoo: identity replaces the
credential; the vault holds the irreducible secret; envelope encryption protects the data.
1. The token flow, one breath
your code ──get_token("vault")──▶ IMDS 169.254.169.254 ──▶ Entra ──▶ JWT {aud, exp, oid}
│ (link-local, non-routable: only this box can ask)
└── Bearer <token> ──▶ https://vault.azure.net/secrets/db-pwd ──▶ 200 {value} | 403
The SDK caches the token per (identity, audience) and refetches only near expiry. The
audience (aud) is load-bearing: a vault.azure.net token is rejected by
storage.azure.com.
2. System- vs user-assigned (an interview staple)
| System-assigned | User-assigned | |
|---|---|---|
| Lifecycle | born & dies with the resource | standalone resource |
| Count | 1 per resource | 1 identity → many resources |
| Selected by | implicit | its client_id |
| Pick when | identity == resource's life | share an identity (grant RBAC once); or it must exist before the resource (deploy ordering) |
Default-junior answer: "system-assigned, it's simpler." Principal answer: "user-assigned when 30 functions need the same role — one assignment, not thirty — or when IaC ordering needs the identity first."
3. Control plane vs data plane (the #1 Key Vault 403)
CONTROL PLANE (ARM/RBAC) DATA PLANE (vault endpoint)
create/delete/configure vault READ a secret's value, wrap/unwrap/sign
Owner, Contributor Key Vault Secrets User, Crypto User
Owner can delete the whole vault but can't read one secret. Reading a value is a
DataAction. Burn this in — it shows up identically in Storage (Owner ≠ Blob Data Reader).
4. The numbers you'll actually use
| Thing | Number |
|---|---|
| IMDS address | 169.254.169.254 (link-local, non-routable) |
| Token endpoint | /metadata/identity/oauth2/token?resource=<aud> + header Metadata: true |
| Managed-identity token TTL | ~24h; SDK refreshes before expiry (skew) |
| Secret max size | 25 KB |
| Soft-delete retention | 7–90 days (default 90) |
| Purge protection | irreversible once on; blocks purge until retention elapses |
| RBAC roles | Key Vault Secrets User (read), Secrets Officer (read/write), Administrator, Crypto User |
| FIPS | Key Vault 140-2 L2 (Premium HSM-backed keys); Managed HSM 140-2 L3 |
| KEK rotation cost | O(#DEKs), never O(bytes) — data ciphertext untouched |
5. Envelope encryption in one diagram
data ──enc(DEK)──▶ ciphertext ┐ store both
DEK ──wrap(KEK)─▶ wrapped_DEK ┘ (KEK never leaves the vault/HSM)
rotate KEK: DEK = unwrap(old_KEK, wrapped) ; wrapped' = wrap(new_KEK, DEK)
▲ the DATA ciphertext is NEVER touched — that's the whole point
revoke: disable the KEK → wrapped_DEK can't be unwrapped → data unreadable (crypto-shred)
6. az / SDK one-liners
# user-assigned identity, attach to a function app, grant data-plane read (RBAC)
az identity create -g rg -n app-uai
az functionapp identity assign -g rg -n myfunc --identities <uai-resource-id>
az role assignment create --assignee <uai-client-id> \
--role "Key Vault Secrets User" --scope <vault-resource-id>
# vault with the security defaults ON
az keyvault create -g rg -n myvault --enable-rbac-authorization true \
--enable-purge-protection true --retention-days 90
# secrets are versioned; soft-delete is recoverable
az keyvault secret set --vault-name myvault -n db-pwd --value 'hunter2'
az keyvault secret delete --vault-name myvault -n db-pwd # soft-delete
az keyvault secret recover --vault-name myvault -n db-pwd # undo
az keyvault secret purge --vault-name myvault -n db-pwd # REFUSED if purge-protection + in retention
# key rotation (new version) — the wrapped DEKs rewrap; data untouched
az keyvault key rotate --vault-name myvault -n my-kek
# no stored secret anywhere — DefaultAzureCredential uses the managed identity via IMDS
from azure.identity import DefaultAzureCredential
from azure.keyvault.secrets import SecretClient
cred = DefaultAzureCredential() # caches the token
kv = SecretClient("https://myvault.vault.azure.net", cred)
pwd = kv.get_secret("db-pwd").value # needs Key Vault Secrets User
# Key Vault reference in app settings — platform resolves it via the app's MI
app_settings = { DB_PWD = "@Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/db-pwd/)" }
7. War story shapes you'll relive
- "It returns a token but the call 401s." → wrong audience. You asked IMDS for
vault.azure.netand called Storage. A token is scoped to one resource. (Lab:test_token_audience_scoping.) - "My
Ownergets 403 reading the secret." → control vs data plane. Grant a data-plane role. Not a bug; the model. - "IMDS is throttling my function." → you're fetching a token every call. It's a caching bug — the SDK should serve a cached token until near expiry, not a scale problem.
- "We rotated the encryption key and re-encrypted 40 TB over a weekend." → you didn't use envelope encryption. Rotation should rewrap the DEK (milliseconds), not the data.
- "An admin deleted the prod vault and we lost the keys." → soft-delete + purge protection would have made it recoverable and un-purgeable. Enable both.
- "The client secret expired at midnight and prod went down." → that's exactly what a managed identity eliminates. There's no secret to expire.
8. Vocabulary that signals you've held the pager
- Secret-zero — the one irreducible credential; make it a managed identity, not a string.
- IMDS — the non-routable
169.254.169.254token endpoint; possession of access is the credential. - Audience /
aud— the one service a token is valid for. - DEK / KEK / wrap / unwrap — data key, key-encryption key, and the envelope.
- Rewrap-on-rotate — KEK rotation rewraps the DEK only; data untouched.
- Control plane vs data plane — manage the vault vs read the secret; different roles.
- Soft-delete / purge protection — recoverable delete; un-purgeable until retention.
- Workload identity federation — OIDC → Entra token, no secret at all (CI, AKS).
- Crypto-shredding — revoke the KEK and the data is unreadable without touching it.
9. Beginner mistakes that mark you in interviews
- Saying "managed identity = no token" (there is a token — a JWT; what's gone is the stored secret).
- Defaulting to system-assigned and never reaching for user-assigned's RBAC-grant economy.
- Granting
Owner/Contributorand expecting to read secrets (data plane needs its own role). - Re-encrypting data to rotate a key (the whole point of envelope encryption is you don't).
- Putting a secret in an env var / config / git instead of Key Vault + a reference.
- Shipping a vault without soft-delete + purge protection on anything that matters.
- Forgetting the audience and reusing one token across services.
10. How this phase pays off later
- The token flow (P03 JWT + this) is how every Azure-to-Azure call authenticates.
- Control vs data plane (P00) is sharpest here and reused everywhere.
- RBAC (P04) is Key Vault's recommended authorization model — same engine.
- Envelope encryption is the engine behind CMK for Storage/SQL/Disk/Cosmos.
- The secret-zero habit makes your Functions (P11), CI/CD (P08), and event-driven (P10) designs secure by construction — no stored credentials anywhere.
Now read the WARMUP slowly, then build the lab. The token cache, the rewrap-on-rotate, and the purge-protection block are the three mechanisms an interviewer will actually push on.
👨🏻 Brother Talk — Phase 12, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. Of all the phases in this track, this is the one where the judgment is worth more than the mechanism — and it's also the one where a single sentence in an interview either marks you as senior or as principal. So let me tell you the things that actually matter.
The whole phase is one idea, and you should be able to say it in your sleep: don't store the secret. Everything else — managed identities, IMDS, Key Vault, envelope encryption — is a tactic in service of that one strategy. When someone asks you to "secure the app's database access," the junior reaches for "we'll put the password in Key Vault." The principal asks a better question first: does this even need a stored secret? Half the time the answer is no — it's an Azure-native service, give it a managed identity, done, secret-zero eliminated. The other half, yes, there's a third-party key you can't make disappear — then Key Vault, read via the managed identity. The move that makes you sound senior is not knowing Key Vault. It's asking whether the secret needs to exist at all.
Here's the thing nobody tells you: the "Owner gets a 403" moment will happen to you, and
how you react reveals your level. Every engineer, the first time, files a ticket: "Key Vault
is broken, I'm Owner and I can't read the secret." The senior eventually learns the magic
words "data-plane role" and moves on. The principal internalized control-plane-vs-data-plane
as a law of Azure — they expected the 403, because reading a value was always going to be a
different system from owning the resource. That distinction is the single most reused idea in
the whole cloud: Owner of a storage account isn't a Blob Data Reader; Contributor on a
Service Bus namespace can't necessarily send a message. Once it clicks here, it clicks
everywhere, and you stop being surprised by a whole category of "permissions are weird" tickets.
Fall in love with envelope encryption, because it's genuinely beautiful and it's a flex.
Most security is grim — you're defending against bad things. Envelope encryption is one of the
few places where the engineering is just elegant. You encrypt the data once with a fast key,
wrap that key with a key in the vault, and now rotating your encryption key — the thing
compliance nags you about every 90 days — costs milliseconds and touches zero bytes of
data. The first time you explain in a design review "no, we don't need a maintenance window to
rotate the encryption key, we rewrap the data keys and the data is untouched," and you watch
the room realize they've been planning weekend re-encryption jobs for years — that's the
moment. And the revocation story is even cooler: disable the key and the data is instantly,
cryptographically unreadable without anyone touching it. Crypto-shredding. Learn to draw the
DEK/KEK diagram from memory; it's worth more in an interview than any az command.
The expired-client-secret outage is real, and avoiding it is a career skill. Somewhere right now, a team has a client secret expiring in 47 days that nobody has on their calendar, configured in a place nobody remembers, and one night prod is going to throw 401s and a panicked on-call is going to spend three hours discovering that the fix is "regenerate a secret nobody documented." Managed identities exist to delete that entire night from your life. When you push to replace stored secrets with managed identities, you're not gold-plating — you're removing a guaranteed-future-incident. The best incident is the one that can't happen because the thing that fails doesn't exist.
On purge protection — this is where you show you think like an attacker, not just a builder.
Soft-delete is obvious; everyone gets "oops, undo." Purge protection is subtle, and it's the
tell. The question behind it is: what happens when the admin credential is the attacker? If a
compromised admin can delete and then purge, your "recoverable" deletes are theater — they
make it permanent in two API calls. Purge protection says: nobody, not even a Global Admin,
can make this permanent until the retention window elapses — which means you always have a
window to detect (it fires an alert) and recover. When you enable purge protection on a vault
and can explain it's specifically a defense against a compromised admin, the security people
in the room relax, because you're clearly one of them.
The honest truth about this phase's difficulty: the mechanisms are not hard. IMDS is an
HTTP GET. Wrap/unwrap is enc/dec. Soft-delete is a state machine with four states. You
could learn all of it in an afternoon. What's hard — and what the lab forces — is holding the
distinctions precisely under pressure: token caching vs refetch, audience scoping, control vs
data plane, rewrap vs re-encrypt, soft-delete vs purge. Those are the exact lines an interviewer
walks you onto to see if you actually understand or just memorized the happy path. The lab makes
you implement each distinction as a test that fails if you get it wrong — which is why building
it beats reading about it ten times.
One last thing, the most important. In security, the principal is the person who is calm because they removed the thing that scares everyone else. The room is anxious about leaked credentials? You removed the credential — there's a managed identity now, nothing to leak. The room is anxious about a key rotation outage? You used envelope encryption — rotation is a few bytes, no outage. The room is anxious about an admin deleting prod? Purge protection — it can't be made permanent. You're not the smartest person in the room. You're the one who turned three vague fears into "that can't happen anymore, here's why." That is what security leadership actually is, and it's completely learnable. It starts with the lab in this phase — go build the token cache, the rewrap, and the purge block, and feel the distinctions become muscle memory.
Go build it. Then come find me in Phase 13 — observability, where we make the platform tell you what it's doing before the customer does.
— your brother 👨🏻
Lab 01 — Key Vault & Managed Identity: Token Acquisition + Envelope Encryption
Phase: 12 — Secrets, Key Vault & Managed Identity | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
The most secure secret is the one you never store. A Managed Identity is how code proves who it is to Azure with no credential in the code — it asks the non-routable IMDS endpoint (
169.254.169.254) for a token, audience-scoped and cached. A Key Vault then stores the secrets you can't avoid (third-party API keys, connection strings) as versioned objects behind RBAC, with soft-delete so a fat-fingerdeleteis recoverable and purge-protection so it can't be made permanent on demand. And envelope encryption is the trick that lets you rotate the key protecting terabytes of data by re-encrypting only a few bytes. This lab builds all three as a runnable, deterministic miniature: an IMDS token cache, the DEK/KEK envelope with rewrap-on- rotate, and the vault's versioning + RBAC default-deny + soft-delete state machine.
⚠️ The Part-2 cipher is a TEACHING cipher (SHA-256 counter-mode XOR), not for production — it has no authentication, no nonce, and reuses a keystream. It exists to make the envelope-encryption mechanism (DEK, KEK, wrap, unwrap, rewrap-on-rotate) legible without an external crypto dependency. Real Azure uses AES-GCM / RSA-OAEP in FIPS 140-2 validated HSMs. The header in
lab.py/solution.pysays so loudly. Never ship it.
What you build
- Managed-identity token flow —
Identity(system-assigned vs user-assigned selectors),InstanceMetadataService.acquire_token(resource, identity, now)returning anAccessTokenwithexpires_on = now + ttl, scoped to aresource(audience), and a per-(identity, resource)cache so a second call before expiry returns the same token and a call after expiry (or within the refresh skew) mints a new one.token_is_valid(token, resource, now)checks audience and expiry — the two ways a token silently fails. - Envelope encryption —
keystream(key, nbytes)(SHA-256 counter mode),enc/dec(XOR, symmetric),generate_dek(seed),wrap_dek/unwrap_dek(encrypt the DEK under the KEK),envelope_encrypt/envelope_decrypt, and the centerpiecerotate_kek(old, new, wrapped_dek)— unwrap with the old KEK, rewrap with the new, and the data ciphertext is never touched. A test asserts the ciphertext bytes are identical before and after rotation and still decrypt. - The Key Vault data model —
KeyVaultwith versioned secrets (set_secretappends a version;get_secretreturns latest or a pinned version), RBAC default-deny (get_secret(..., principal, role_assignments)raises unless the principal holds a data-plane read role —Ownerdoes not count), and the soft-delete state machine:delete_secret→ recoverable untilnow + retention;recover_secretrestores;purge_secretpermanently removes but raises when purge protection is on inside the retention window.
Key concepts
| Concept | What to understand |
|---|---|
| Managed Identity | an Entra service principal Azure manages; code holds no secret and gets a token from IMDS |
| System vs user-assigned | system-assigned is tied to one resource's lifecycle (no client_id); user-assigned is standalone and attachable to many (selected by client_id) |
| IMDS | the link-local 169.254.169.254 metadata endpoint that issues the token; the SDK caches it per (identity, resource) |
| Audience (resource) | a token is scoped to one target service; a vault.azure.net token is rejected by storage.azure.com |
| Token cache + skew | refetch only near expiry (a refresh_skew_s before expires_on) — a hot loop must not DoS IMDS |
| Control plane vs data plane | managing the vault (Owner) is control plane; reading a secret is data plane and needs a data-plane role |
| RBAC default-deny | no matching role assignment → deny, not error; least privilege is the default posture |
| Versioned secrets | each set creates an immutable version; old versions stay readable so in-flight clients survive a rotation |
| Envelope encryption | encrypt data with a fast DEK; wrap the DEK with a KEK in the vault/HSM; store (ciphertext, wrapped_dek) |
| Rewrap-on-rotate | rotating the KEK only unwraps+rewraps the DEK — O(#DEKs), not O(bytes); the data ciphertext is untouched |
| Soft-delete | a delete is recoverable for a retention window; the bytes are not gone yet |
| Purge protection | inside retention, nobody (not even an admin) can force-purge — the anti-ransomware/insider guarantee |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs a worked example (token caching, envelope + rotation, RBAC deny→allow, soft-delete→recover, blocked purge) |
test_lab.py | the proof — run it red, make it green (29 tests) |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 29 tests pass against your implementation.
- You can explain why
test_token_cached_before_expiry_same_tokenis the line that keeps a hot loop from DoS-ing IMDS: the SDK serves the cached token until it is near expiry; onlytest_token_refreshed_after_expiry_new_tokenandtest_token_refreshed_within_skew_windowactually hit the endpoint again. - You can explain why
test_token_audience_scopingmatters: a token is scoped to one resource; reusing avault.azure.nettoken againststorage.azure.comreturns a token the target service rejects — a silent failure that looks like a 401 with no obvious cause. - You can explain why
test_kek_rotation_data_ciphertext_object_unchangedis the whole point of envelope encryption: rotating the KEK rewraps only the DEK, so key rotation costs the same whether you are protecting 1 KB or 1 PB. - You can explain why
test_rbac_default_deny_then_allowdenies even anOwner: reading a secret is a data-plane action, and control-plane roles do not grant it. - You can explain why
test_purge_blocked_under_purge_protectionis a security feature, not a bug: purge protection means a compromised admin (or ransomware) cannot make a deletion permanent until the retention window elapses.
How this maps to real Azure (Managed Identity + Key Vault)
| The lab | Real Azure |
|---|---|
InstanceMetadataService | the Azure Instance Metadata Service at http://169.254.169.254/metadata/identity/oauth2/token (a non-routable link-local address reachable only from the resource) |
Identity(kind="system") | a system-assigned managed identity — created with the resource, deleted with it, one per resource |
Identity(kind="user", client_id=…) | a user-assigned managed identity — a standalone Azure resource you attach to one or many resources; selected by client_id (or object_id/mi_res_id) |
acquire_token(resource, identity, now) | DefaultAzureCredential / ManagedIdentityCredential.get_token(scope) → the SDK calls IMDS and caches the token |
resource (audience) | the ?resource= / scope, e.g. https://vault.azure.net, https://storage.azure.com, https://management.azure.com — the token's aud claim |
expires_on + refresh skew | IMDS tokens live ~24h; the SDK refreshes before expiry to avoid a race (the AAD MSI extension also caches at the host) |
token (opaque hash) | a real JWT access token; the target service validates its signature, iss, aud, and exp (you built that validator in P03) |
KeyVault + RBAC roles | Azure Key Vault with Azure RBAC authorization (recommended over legacy access policies); Key Vault Secrets User = read, Secrets Officer = read/write, Administrator = full data-plane |
Owner does not read secrets | the recurring control-plane vs data-plane split: Owner/Contributor manage the vault resource; a data-plane role is required to read a secret's value |
versioned SecretVersion | every secret/key/cert is versioned; a version has its own immutable id and URL (.../secrets/{name}/{version}); omitting the version returns current |
wrap_dek / unwrap_dek | Key Vault wrapKey/unwrapKey (and encrypt/decrypt) — the KEK private key never leaves the vault/HSM; you send the DEK, get back the wrapped DEK |
rotate_kek (rewrap only) | key rotation with a new key version; Azure Storage/SQL/Disk encryption (CMK) rewrap their DEK against the new KEK version — terabytes of data are never re-encrypted |
generate_dek | in real envelope encryption the DEK is fresh CSPRNG bytes per object; here it's seed-derived only so tests are deterministic |
soft-delete / recover / purge | Key Vault soft-delete (now always on; default 90-day retention) and purge (az keyvault secret purge) |
purge_protection blocks purge | purge protection — when on, soft-deleted objects (and the vault) cannot be purged until retention elapses; irreversible once enabled |
| Managed HSM framing | for FIPS 140-2 Level 3 single-tenant HSMs, Azure Key Vault Managed HSM; certs have their own lifecycle (issuance, auto-rotation, CA integration) |
What the miniature leaves out (and why it's fine): real IMDS returns a signed JWT
validated against Entra's JWKS; the token has a full claim set (tid, oid, xms_mirid);
workload identity federation (AKS/GitHub Actions) swaps a federated OIDC token for an
Entra token with no secret and no IMDS at all; Key Vault enforces network ACLs/private
endpoints, throttles the data plane, and runs keys inside HSMs with attestation; and
certificates add CSR/CA/auto-rotation flows. None of that changes the three mechanisms
this lab isolates — (1) a managed identity gets an audience-scoped, cached token from
IMDS with no stored secret, (2) envelope encryption makes KEK rotation cost
O(#DEKs) by rewrapping the DEK and never the data, and (3) Key Vault is a
versioned, RBAC-default-deny, soft-deletable store where control plane ≠ data plane.
Those are exactly the parts interviewers probe and on-call incidents turn on.
Extensions (build these in your own subscription)
- Workload identity federation: add a
FederatedCredentialpath — exchange an external OIDC token (subject =repo:org/repo:ref:refs/heads/main) for an Entra token with no secret and no IMDS. This is how GitHub Actions and AKS pods authenticate (you touched the OIDC side in P03/P08). - JWT-real tokens: replace the opaque hash with a real signed JWT (HMAC or the RSA
validator from P03) so
token_is_validchecksiss/aud/exp/signature, not just a string compare. - Key Vault references in config: model App Service / Functions Key Vault
references (
@Microsoft.KeyVault(SecretUri=...)) — the platform resolves the secret using the app's managed identity and injects it as an env var; add a resolver that needs the MI token and the data-plane role. - Certificate lifecycle: add a
Certificateobject withissue,auto_rotate(issue a new version N days before expiry), and CA-policy validation. - Wire it to real Azure:
az identity create(user-assigned), assign it to a Function App,az role assignment create --role "Key Vault Secrets User"at the vault scope, and read a secret withDefaultAzureCredential+SecretClient— no secret in the code. Thenaz keyvault secret delete/recover/ trypurgewith purge protection on and watch it refuse.
Interview / resume
- Talking points: "How does a Managed Identity get a token with no stored secret — walk
me through IMDS, the audience, and the cache." / "System-assigned vs user-assigned —
when do you pick which?" / "Why does an
Ownerof a Key Vault get a 403 reading a secret?" / "Explain envelope encryption and why rotating the KEK doesn't re-encrypt the data." / "What does purge protection actually protect you from?" - Resume bullet: Built a runnable model of Azure's secret-zero security stack — managed- identity IMDS token acquisition with audience scoping and per-identity caching, DEK/KEK envelope encryption with O(#DEKs) key rotation (rewrap-only, data untouched), and a Key Vault data model with RBAC data-plane default-deny, secret versioning, and a soft-delete / purge-protection state machine.
Phase 13 — Observability, Azure Monitor & KQL
Difficulty: ⭐⭐⭐☆☆ (the query engine) → ⭐⭐⭐⭐⭐ (operating it at scale without going broke)
Estimated Time: 1 week (12–18 hours)
Prerequisites: Phase 00 (the five forces, latency budgets), Phase 03 (JWT/operation_Id
correlation intuition), Phase 11 (the distributed services you'll be tracing). Phase 14
(reliability) consumes the SLO/error-budget thinking you build here.
Why This Phase Exists
Every prior phase built something — a deployment graph, a token validator, an NSG evaluator, a message broker, a Functions runtime. This phase is how you find out, at 2 a.m., which of them just broke and why. The JD is explicit: "Monitor, troubleshoot, and optimize cloud infrastructure to ensure high availability, security, and performance" and calls out Azure Monitor / Log Analytics / KQL and Application Insights / distributed tracing by name. That is not a "nice to have" bullet — it is the difference between an engineer who operates a platform and one who merely deploys it.
Here is the uncomfortable truth most "set up a dashboard" tutorials hide: observability is
a query problem, not a UI problem. The portal's pretty charts are auto-generated KQL. When
the chart doesn't show what you need — and in a real incident it never does — you drop into
the Logs blade and write the query, joining requests to dependencies on
operation_Id, summarizing p95 latency by 5-minute bin and service, and dcount-ing
distinct failing users to gauge blast radius. The principal is the person who writes that
query in 90 seconds while everyone else is clicking. And it is also a cost problem:
logs are billed by the GB ingested and retained, traces by the sample, and a single
high-cardinality dimension (userId, requestId) can 10× your bill or your query latency.
So this phase makes you build the KQL engine — the read pipeline of tabular operators
and aggregations — and the alert evaluator that pages you, because once you've
implemented summarize ... by bin() and a window-vs-threshold alert by hand, you stop
reciting query syntax and start reasoning about what the query costs and where it lies.
Concepts
- The Azure Monitor umbrella — one brand over several distinct data planes: Metrics
(pre-aggregated numeric time series, cheap, 1-minute granularity, near-real-time),
Logs (a Log Analytics workspace of typed rows you query with KQL, expensive,
flexible), Alerts (rules over metrics or logs → an action group), and
Application Insights (APM: requests/dependencies/traces/exceptions correlated by
operation_Id). Knowing which store answers which question is half the skill — a metric for "is it up and how loaded," a log for "what exactly happened to this request." - KQL — the read pipeline —
where | project | extend | summarize ... by bin() | join | top | order by | count | take. Each operator is a pure transform over a table; queries compose left-to-right. The semantics ofsummarize(group-and-reduce) andbin(floor onto an absolute time grid) are the load-bearing two. - Aggregations & cardinality —
count/sum/avg/min/max/dcount/percentile. How a percentile is computed (nearest-rank vs interpolation vs Kusto's T-Digest approximation), whydcountis approximate (HyperLogLog) at scale, and why a high-cardinalitybydimension blows up both cost and query latency. - Distributed tracing — a trace is a tree of spans; Application Insights
correlates them with
operation_Id(the trace id) andoperation_ParentId(the parent span), so ajoinonoperation_Idreassembles a request's path across five services and shows you which hop ate the latency. Sampling (head vs tail) trades ingestion cost for fidelity. - Alert rules —
aggregate(metric over a lookback window) <operator> threshold→ action group. The window/lookback, the aggregation granularity, the operator boundary (gtvsge), and thefor/debounce clause are where the signal-vs-noise battle is won or lost. A flapping alert is worse than no alert; a missed breach is worse than both. - SLO / SLI / error budget — the Google-SRE frame the JD's "high availability" implies: define a Service Level Indicator (a query), set a Service Level Objective (a target on it), and spend the resulting error budget on velocity — so reliability is a number you manage, not a vibe. Developed fully in Phase 14; rooted here.
Concept Map — How the Pieces Fit
your services emit telemetry
│
▼
┌─────────────────────────────────────────────────────────────┐
│ METRICS (numeric TS) LOGS (Log Analytics, KQL) │
│ cheap, 1-min, "is it up" $/GB, flexible, "what happened" │
│ │ │ │
│ │ ▼ │
│ │ where | extend | summarize ... by bin() │
│ │ | join (operation_Id) | top │
│ │ │ │
│ ▼ ▼ │
│ ALERT RULE: agg( signal over [now-window, now] ) op thr │
│ │ │
│ ▼ │
│ ACTION GROUP → human / webhook / runbook│
└─────────────────────────────────────────────────────────────┘
▲
│ APPLICATION INSIGHTS (APM): requests/dependencies/traces/
│ exceptions, all stamped operation_Id → reassemble the trace
The whole phase is "build the middle of that picture": the KQL operators that turn raw
log rows into a number, and the alert rule that turns that number into a page. Metrics
sit beside logs as the cheap "is it up" channel; Application Insights feeds the log tables and
adds the operation_Id correlation that makes distributed tracing possible.
Labs
Lab 01 — KQL Query Engine + Alert Evaluator (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the read pipeline behind Azure Monitor Logs: the KQL tabular operators (where/project/extend/summarize ... by bin()/join/top/order by/take/count), the aggregations (count/sum/avg/min/max/dcount/nearest-rank percentile), and an alert-rule evaluator that aggregates a metric over a lookback window and fires when it breaches a threshold |
| Concepts | KQL as a pipeline of pure functions over rows; summarize group-and-reduce; bin() time bucketing onto an absolute grid; percentile computation; cardinality (dcount); inner vs leftouter join; window-vs-threshold alerting and the gt/ge boundary |
| Steps | 1. row-shaping (where/project/extend); 2. ordering/top/take/count; 3. bin_time + nearest-rank percentile; 4. summarize group-and-reduce with deterministic group order; 5. inner/leftouter join; 6. AlertRule + evaluate_alert over a window |
| How to Test | pytest test_lab.py -v — 35 tests: operator purity, project rename/missing-column, percentile known answers, bin grid + negatives, summarize determinism + every agg, inner/leftouter join, and the alert boundary (gt vs ge) and empty-window cases |
| Talking Points | "Find the p95 regression by service and 5-min bin." "What does T-Digest cost you?" "Why does per-userId dcount blow up?" "Metric vs log alert, and how do you stop it flapping?" |
| Resume bullet | Built a KQL query engine (operators + aggregations) and a window-aggregation alert evaluator modeling Azure Monitor Logs, Log Analytics, and Application Insights alerting |
→ Lab folder: lab-01-kql-engine/
Integrated-Scenario Suggestions (carried through the whole track)
These tie this phase's observability layer to the platform you've been building across the track — each is a real query/alert you'd own as the principal:
- The latency-regression hunt —
requests | summarize p95=percentile(DurationMs, 95), n=count() by bin(TimeGenerated, 5m), Service, thenrender timechart. When a release regresses p95, this is the query that names the service and the minute. (Lab 01 directly.) - The distributed-trace reassembly — a slow checkout spans the API gateway (P09),
Service Bus (P10), a Function (P11), and Key Vault (P12).
join requeststodependenciesonoperation_Idto attribute the latency to the exact hop — the query that ends the "it's not my service" standoff. - Blast-radius gauge —
exceptions | where TimeGenerated > ago(15m) | summarize dcount(user_Id), dcount(operation_Id) by ProblemId— how many distinct users/operations a failure touched. The cardinality is the severity. - The cost autopsy —
Usage | summarize GB=sum(Quantity) by DataTypeto find which table is eating the ingestion bill, then a sampling/retention/transform decision — the FinOps half of observability (P15 capstone). - SLO + error budget — define an availability SLI as a KQL ratio
(
good_requests / total), set a 99.9% SLO, and a burn-rate alert that fires when you're spending error budget too fast — the bridge into Phase 14 reliability.
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; KQL operators
and
az monitorone-liners to memorize; read first - WARMUP.md — the full primer: each concept's mechanism under the hood, the percentile/sampling math, the lab walkthrough, interview Q&A, references; read slowly
- BROTHER-TALK.md — the candid version: what observability really costs you and your career
Key Takeaways
- Observability is a query problem. The chart is auto-KQL; the principal writes the query the chart can't. Build the engine and the syntax becomes mechanism.
- Know which store answers which question — a metric for "is it up and how loaded," a log for "what happened to this request," a trace for "which hop was slow."
- Cardinality is the cost.
dcounton a high-cardinality dimension (userId, requestId) is what 10×'s your bill and your query latency; aggregate first, sample deliberately. - An alert is
agg(window) <op> threshold→ action group. The window, the boundary (gtvsge), and the debounce decide whether it's a signal or a 3 a.m. lie. operation_Idis how a request becomes a story across services; ajoinon it is the whole of distributed tracing.
Cross-Cutting Themes (the principal's lenses for this phase)
- Control plane vs data plane, again. Azure Monitor config (workspaces, alert rules,
action groups) is control-plane ARM/RBAC — the same idempotent
PUTand role model from P00–P04. The telemetry itself (the rows you query, the metrics you read) is a data plane with its own access model (Log Analytics Reader), its own throttling, and its own bill. AReaderon the resource is not a reader of its logs. - Cardinality is the new "$/TB scanned." Phase 00 taught that layout is a cost feature;
here the layout that costs money is dimensional cardinality. Every high-cardinality field
you ingest or
summarize ... byis a line item — aggregate first, sample deliberately, never group by a GUID. - Approximate by design.
percentile(T-Digest) anddcount(HyperLogLog) are not exact at scale, and that's the correct engineering trade — bounded memory and distributed mergeability beat an exact answer you can't afford to compute. Knowing this is a principal signal; assuming exactness is a junior tell. - An alert is a promise to your future self. Signal-vs-noise is not a tuning afterthought;
it's the core design problem. The window, the
gt/geboundary, thefor/debounce, and the no-data policy decide whether the on-call channel stays trusted or becomes muted noise. - Correlation is causation's only evidence.
operation_Idpropagated through every hop is what turns five services' separate logs into one request's story. If the correlation id doesn't flow, you cannot trace, and you're back to forty-minute blame calls.
Deliverables Checklist
-
Lab 01 implemented; all 35 tests pass against
solution.pyand yourlab.py - You can write, from memory, a KQL query that computes p95 latency + count by 5-minute bin and service, and explain each operator's effect on the row set
- You can explain nearest-rank vs interpolation vs T-Digest percentile, and the cost/fidelity trade Kusto makes
-
You can explain why a high-cardinality
dcountdimension is expensive and what you do instead -
You can describe a metric alert vs a log (scheduled-query) alert, the
for/debounce knob, and thegt-vs-geboundary trap -
You can draw the
operation_Id/operation_ParentIdcorrelation and explain head vs tail sampling
Warmup Guide — Observability, Azure Monitor & KQL
Zero-to-principal primer for Phase 13: what the Azure Monitor umbrella actually is (and which store answers which question), the KQL read pipeline operator by operator and the mechanism under each one, how percentiles and distinct-counts are really computed at scale (and why they're approximate), how distributed tracing stitches a request back together with
operation_Id, the cost/fidelity math of sampling, and how an alert rule turns an aggregation over a window into a 2 a.m. page. Read it slowly; build the lab as you go.
Table of Contents
- Chapter 1: What "Observability" Actually Means
- Chapter 2: The Azure Monitor Umbrella — Four Stores, Four Questions
- Chapter 3: Metrics vs Logs — The Pre-Aggregation Tradeoff
- Chapter 4: KQL — The Read Pipeline, Operator by Operator
- Chapter 5: summarize and bin — Group-and-Reduce Over Time
- Chapter 6: Aggregations, Percentiles & the Math
- Chapter 7: Cardinality — Why dcount Blows Up the Bill
- Chapter 8: join — Correlating Tables
- Chapter 9: Distributed Tracing — Spans, operation_Id & Sampling
- Chapter 10: Alert Rules — Aggregation, Window, Threshold, Action
- Chapter 11: SLO, SLI & the Error Budget
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What "Observability" Actually Means
From zero. Monitoring answers questions you knew to ask in advance — "is CPU above 80%?" Observability is the property that lets you answer questions you didn't anticipate — "why are exactly the users in the EU West region who hit the checkout API after the 14:02 deploy getting 503s?" — without shipping new code, purely by interrogating the telemetry you already emit. The distinction matters because production never fails the way you planned for; the failures that page you are the ones no pre-built dashboard anticipated.
The classic frame is the three pillars:
| Pillar | What it is | The question it answers | Azure store |
|---|---|---|---|
| Metrics | numeric time series (a number sampled over time) | "is it up? how loaded? trending where?" | Azure Monitor Metrics |
| Logs | timestamped structured records (rows with fields) | "what exactly happened to this request/resource?" | Log Analytics workspace (queried with KQL) |
| Traces | causally-linked spans across services | "which hop in the distributed call was slow/failed?" | Application Insights (requests/dependencies) |
A principal's first move in any incident is to pick the right pillar: a metric tells you that something is wrong and roughly how bad; a log tells you what happened to a specific entity; a trace tells you where in a multi-service path it happened. Reaching for the wrong one — grepping logs to answer "is the service up" — is how you lose the first fifteen minutes of an incident.
Chapter 2: The Azure Monitor Umbrella — Four Stores, Four Questions
"Azure Monitor" is a brand over several genuinely different data planes. Conflating them is the single most common source of both wrong answers and surprise bills.
┌──────────────────── Azure Monitor ───────────────────┐
resources, apps ──► │ METRICS LOGS ALERTS APP INSIGHTS │
(emit telemetry) │ (numeric TS) (Log Analytics (rules → (APM: req/dep │
│ pre-aggregated workspace, action /trace/exc, │
│ ~1-min, cheap) KQL, flexible) group) operation_Id)│
└───────────────────────────────────────────────────────┘
│ │ │ │
near-real-time rich queries page someone trace trees
- Metrics — a numeric value (CPU%, request count, queue depth) sampled at a fixed cadence (typically 1 minute), pre-aggregated at ingestion, retained cheaply, queried with a lightweight metrics API (and charted instantly). Near-real-time; low cardinality by design.
- Logs — a Log Analytics workspace: many tables (
requests,traces,AzureDiagnostics,Heartbeat,SigninLogs, …), each a stream of typed rows. You query them with KQL. Flexible and powerful; billed by GB ingested and retention. - Alerts — rules evaluated on a schedule over either a metric or a log query; breaching fires an action group (email/SMS/push/webhook/Logic App/runbook).
- Application Insights — an APM layer that writes into a Log Analytics workspace
(workspace-based AI) the tables
requests,dependencies,traces,exceptions,customEvents,pageViews— all stamped withoperation_Idso a trace can be reassembled (Chapter 9).
The seam failures live exactly here: putting a high-cardinality dimension into a metric (metrics aren't built for it → silent split or rejection), or trying to alert on a log with a too-short window (no data → flaps), or assuming Application Insights data is a metric when it's a log table with its own cost.
Chapter 3: Metrics vs Logs — The Pre-Aggregation Tradeoff
Why two stores at all? Because there is a fundamental tradeoff between pre-aggregation (cheap, fast, but you must choose the dimensions in advance) and raw retention (flexible, queryable any way later, but expensive).
| Metric | Log (Log Analytics) | |
|---|---|---|
| stored as | pre-aggregated time series | raw typed rows |
| granularity | ~1 minute, fixed | per-event |
| cardinality | low (a few dimensions) | high (any field) |
| query | fast, limited | KQL, arbitrary |
| latency to query | seconds (near-real-time) | seconds–minutes (ingestion lag) |
| cost | cheap | $/GB ingested + retention |
| use it for | "is it up / how loaded / trending" | "what happened to this one" |
The principal rule: alert on metrics, investigate in logs. A metric alert is cheap to evaluate every minute and catches the that; once it fires, you pivot to a KQL query for the what and why. Building both pipelines (the lab does) makes the asymmetry concrete — the alert evaluator aggregates over a window like a metric rule; the operators run a full KQL query like a log investigation.
Chapter 4: KQL — The Read Pipeline, Operator by Operator
From zero. A KQL query is a table piped left-to-right through tabular operators, each of which takes a table and returns a table:
requests
| where Success == false // filter rows
| extend Bucket = bin(TimeGenerated, 5m) // add a computed column
| summarize n = count() by Service, Bucket // group-and-reduce
| where n > 100 // filter the aggregate
| top 10 by n desc // the 10 worst
| project Service, Bucket, n // pick columns
Read it as a Unix pipe: the table flows down, each | is one transform. Order matters and
is a performance decision — a where early shrinks the row set every later stage touches.
In the lab, each operator is a pure function f(rows) -> rows that returns a new list
and never mutates its input, which is precisely why a KQL query is safe to re-run, cache, and
reason about.
| Operator | What it does | Lab function |
|---|---|---|
where | keep rows matching a predicate | where(rows, predicate) |
project | keep / rename a subset of columns | project(rows, columns) |
extend | add computed columns | extend(rows, **computed) |
order by / sort by | stable sort by a column | order_by(rows, key, desc) |
top n by | the n largest/smallest by a column | top(rows, n, by, desc) |
take / limit | the first n rows | take(rows, n) |
count | number of rows (a scalar) | count(rows) |
summarize ... by | group-and-reduce | summarize(rows, aggs, by) |
join | correlate two tables on a key | join(left, right, on, kind) |
A subtlety the lab encodes faithfully: in extend NewCol = expr, every expr sees the
original row — computed columns within one extend do not see each other. If you need
column B to depend on column A you just added, you chain a second extend. That's Kusto's
real semantics, and it's why the lab's extend callables each receive the un-extended row.
Chapter 5: summarize and bin — Group-and-Reduce Over Time
summarize is the heart of KQL — it's GROUP BY with the aggregation built in.
requests
| summarize p95 = percentile(DurationMs, 95), n = count()
by Service, bin(TimeGenerated, 5m)
Mechanism, exactly as the lab implements it:
- Compute the group key for each row — the tuple of the
bycolumns. Withby Service, bin(TimeGenerated, 5m)the key is(Service, 5-min-bucket). - Bucket rows by that key (a hash map: key → list of rows).
- Reduce each bucket: apply every aggregation to that bucket's column values.
- Emit one row per group: the by-columns + the aggregates.
Real Kusto does not promise group order; the lab sorts groups by key so output is deterministic and testable — a small, deliberate divergence from production for the sake of assertable tests.
bin(value, width) is the function that makes time-series rollups work:
$$\text{bin}(t, w) = \left\lfloor \frac{t}{w} \right\rfloor \cdot w$$
It floors t onto an absolute grid of w-sized buckets — not relative to your query's
start time. That absoluteness is the whole point: two independent queries, two services, two
days apart, with the same bin(TimeGenerated, 5m) land on the same bucket boundaries, so
their series line up and can be joined or overlaid. A consequence the lab tests: with epoch
base t0 = 1_700_000_000 and w = 300, the first bucket floors to t0 - 200s — below
t0 — because the grid doesn't care where your data starts. Negative inputs floor toward
$-\infty$ (so bin(-1, 300) = -300), matching math.floor and Kusto. Internally, that's
why the lab's bin_time is math.floor(value / width) * width.
Chapter 6: Aggregations, Percentiles & the Math
The fold functions a principal recites: count, sum, avg, min, max, dcount
(distinct count), and percentile. The first five are obvious. The interesting two are
percentile (subtle) and dcount (expensive — Chapter 7).
Why percentiles, not averages. The average hides the tail, and the tail is the user
experience. A service with avg latency 80 ms can still have a p99 of 4 s — and that p99 is a
real cohort of real users having a bad time. "Real-time" means a number and a percentile
(Phase 00's banned-word lesson). So you summarize on percentile(Duration, 95) and
percentile(Duration, 99), not avg.
How a percentile is computed. There are several definitions; the lab uses nearest-rank because it returns an observed value and is therefore exactly assertable. For $n$ sorted values and percentile $p$:
$$\text{rank} = \lceil \tfrac{p}{100} \cdot n \rceil \quad (\text{1-based}), \qquad P_p = \text{value at that rank}$$
Worked example — the values $1, 2, \dots, 100$ (so $n = 100$):
$$\text{rank}{95} = \lceil 0.95 \cdot 100 \rceil = 95 ;\Rightarrow; P{95} = 95$$
That is why the lab asserts percentile([1..100], 95) == 95 exactly — no interpolation, no
ambiguity. The contrast is linear interpolation (the "percentile.inc" / R-7 method),
which returns a value between two observations when the rank isn't an integer; it's smoother
but never an observed point. Both are legitimate; the lab documents and tests nearest-rank so
the answer is unambiguous.
What Kusto actually does, and why it's approximate. At billions of rows you cannot sort
the whole column to find a percentile — that's $O(n \log n)$ over petabytes. So Kusto's
percentile() uses a T-Digest: a compact, mergeable sketch that keeps fine-grained
centroids near the tails (where percentiles matter) and coarse ones in the dense middle,
giving a bounded-memory, distributed-friendly approximation with a small, bounded error.
The principal point for an interview: percentile at scale is approximate by design, and
that's the right trade — an exact p95 that takes 40 s and 8 GB of RAM is worse than a 0.1%-off
p95 in 200 ms. The lab uses exact nearest-rank only so its tests have known answers.
Chapter 7: Cardinality — Why dcount Blows Up the Bill
Cardinality is the number of distinct values a column takes. It is the single most important cost lever in observability, and the one juniors most consistently miss.
dcount(X) answers "how many distinct X?" — distinct failing users, distinct affected
operations. It's the blast-radius gauge: 3 distinct users hitting an error is a bug
report; 30,000 is a Sev-1. But computing exact distinctness requires remembering every value
seen, which at scale is impossible — so Kusto's dcount uses HyperLogLog, a probabilistic
sketch that estimates cardinality in kilobytes regardless of how many billions of values
pass through, with a tunable accuracy (~1.6% standard error by default). Approximate, by
necessity, just like percentile.
The cost trap is using a high-cardinality column as a by dimension:
// CHEAP: a handful of groups
requests | summarize count() by Service, ResultCode
// EXPENSIVE: one group per distinct user → millions of groups
requests | summarize count() by user_Id
Grouping by user_Id creates one output row per distinct user — millions of groups, huge
intermediate state, slow query, big result. The same cardinality explosion hits metrics:
a metric dimension with high cardinality multiplies the stored time series (cost) or gets
rejected/split. The principal habits:
- Aggregate to a low-cardinality dimension first (
by Service, bin(...)), thendcountthe high-cardinality thing inside the group as a measure, not a key. - Never put
requestId/user_Id/ full URLs (with query strings) into a metric dimension or asummarize ... by. - Use sampling (Chapter 9) to cap the raw volume before it's even ingested.
This is the "$/TB scanned, layout is a cost feature" law (Phase 00) reappearing in the observability layer: cardinality is the layout that costs money.
Chapter 8: join — Correlating Tables
A single table rarely tells the whole story. join correlates two on a shared key — most
importantly requests ⋈ dependencies on operation_Id to attribute a request's latency to its
downstream calls.
requests
| join kind=inner (dependencies) on operation_Id
| summarize req_p95 = percentile(DurationMs, 95),
dep_time = sum(dep_DurationMs) by operation_Id
The two kinds the lab implements (Kusto has more):
inner— emit a merged row for every (left, right) pair sharing the key. Rows with no match on either side vanish. Use when you only care about correlated pairs.leftouter— keep every left row at least once; when a left row has no right match, the right columns are null. Use when the gaps are the signal — "which requests had no downstream dependency call?" An inner join would silently hide exactly those rows.
Mechanism (the lab's, and the real engine's default strategy): hash join — index the right side by key into a hash map, then stream the left side looking each key up. $O(n + m)$ rather than $O(n \cdot m)$. The lab emits in left order then matching-right order so the result is deterministic, and right-side columns win on a name collision (documented; real Kusto keeps both with a suffix).
The inner-vs-leftouter choice is a classic correctness trap: investigating "why are some
requests slow" with an inner join to dependencies will drop the requests that made no
dependency call — which might be exactly the ones stuck somewhere else. Default to
leftouter when you're hunting for what's missing.
Chapter 9: Distributed Tracing — Spans, operation_Id & Sampling
From zero. In a microservice system one user action fans out across services: the API gateway calls a Function, which reads Service Bus, which calls Key Vault, which calls a database. A trace is the tree of all that work; each unit of work is a span (an operation with a start, a duration, a status, and a parent). Application Insights records them in tables and ties them together with two fields:
operation_Id— the trace id: the same value on every span of one user action, across every service. This is the join key that reassembles the request.operation_ParentId— the parent span id: which span caused this one, so the flat list of spans becomes a tree.
operation_Id = abc123 (one user request, all services)
request span (gateway) parent=∅
└─ dependency span (→ func) parent=gateway
└─ dependency (→ bus) parent=func
└─ dependency (→ vault) parent=func
A query that joins requests to dependencies on operation_Id and walks
operation_ParentId rebuilds that tree and shows you which hop spent the time — ending the
"it's not my service" standoff with data. That correlation id is propagated between services
via W3C Trace Context headers (traceparent), which is why the same operation_Id shows
up in five different services' telemetry.
Sampling — the cost/fidelity dial. Tracing every request at high volume is expensive (every span is a billed log row). Sampling keeps a fraction:
- Head-based sampling decides at ingress, before the request runs, to keep or drop the whole trace — typically with a fixed probability $r$ (the "sampling rate"). Cheap and simple; the cost is predictable ($\approx r \cdot$ volume). The flaw: it's a coin flip that doesn't know the outcome, so it drops error and slow traces at the same rate as boring ones — exactly the traces you wanted.
- Tail-based sampling decides after the trace completes, when you know it errored or was slow, and keeps the interesting ones (all errors, all p99-slow, a sample of the rest). Far higher fidelity for the same budget; the cost is operational complexity (you must buffer spans until the trace finishes).
The math you'd recite: if you head-sample at rate $r$ and a true population statistic over the kept traces is $\hat{x}$, your estimate of the population total is $\hat{x}/r$ — but the variance of that estimate grows as $r$ shrinks, and rare events (errors at rate $\varepsilon$) survive at expected count $\varepsilon \cdot r \cdot N$, so at $r = 0.01$ you keep only 1% of an already-rare error class and your error dashboards go blind. Application Insights' default adaptive sampling is a head sampler that varies $r$ to hit a target items-per-second, which controls cost but inherits the "drops errors too" flaw — which is why serious shops add tail sampling (often via an OpenTelemetry Collector) for the error/slow paths. Naming this trade out loud is a principal-level signal.
Chapter 10: Alert Rules — Aggregation, Window, Threshold, Action
An alert is the automation that turns telemetry into a human being woken up. Its anatomy,
which the lab's AlertRule + evaluate_alert implement exactly:
fire iff AGG( metric over [now − window, now] ) OP threshold → action group
└──── aggregation ────┘ └── lookback ──┘ └ gt/lt/ge/le ┘
- Signal — the metric or the result column of a log query.
- Aggregation —
avg/min/max/count/sum/percentileover the window. - Lookback window — only data in
[now - window, now]is considered. Too short → noisy and gappy; too long → slow to detect. - Operator + threshold —
gt/lt/ge/leagainst the threshold. The boundary is a real bug surface: a value of exactly300against threshold300fires undergebut not undergt. Pick wrong and you either miss the breach or page on the boundary forever (the lab tests this). - Action group — who/what gets notified: email/SMS/push, a webhook (PagerDuty/ITSM), a Logic App, an Automation runbook.
Two production refinements the lab's structure points at:
- No-data handling. An empty window has no metric to breach, so the lab returns
False(no false page). In Azure you separately decide whether "no data" is itself an alert — for a heartbeat it absolutely is (silence means the thing died); for a low-traffic endpoint it isn't. - Debounce (
for/ number-of-violations). A single noisy sample shouldn't page. Real rules require the threshold be breached for N consecutive evaluations (theforwindow) before firing. This is the single most effective noise-reduction knob, and the extension the lab README suggests.
Metric alert vs log (scheduled-query) alert: a metric alert evaluates a cheap pre-aggregated series every minute (low latency, low cost, low flexibility); a log alert runs a full KQL query on a schedule (any logic you can express, higher latency and cost). Use a metric alert for "CPU/availability/latency from the platform metric"; a log alert for "p95 of my custom field by my dimension" — exactly the metric-vs-log split from Chapter 3.
Chapter 11: SLO, SLI & the Error Budget
The JD's "ensure high availability" is, done rigorously, the Google-SRE error-budget loop — and observability is what makes it measurable:
- SLI (Indicator) — a query: e.g. availability $= \dfrac{\text{good requests}}{\text{total requests}}$, expressed as a KQL ratio over a window.
- SLO (Objective) — a target on the SLI: "99.9% of requests succeed over 28 days."
- Error budget — the allowed failure: $1 - \text{SLO}$. At 99.9% that is 0.1% of requests — about 43 minutes of full outage per 30 days, or the equivalent spread as errors.
The budget is the point. It converts reliability from a vibe ("be more careful") into a number you spend: while you have budget left, ship fast; when you're burning it too quickly, freeze risky changes. A burn-rate alert fires when you're consuming budget faster than the window allows (e.g. spending an hour's worth of budget in five minutes) — far better than a raw threshold because it scales with traffic and ties the page to business impact. This is the bridge into Phase 14 (Reliability), and the reason this phase comes right before it: you cannot manage an error budget you cannot query.
Lab Walkthrough Guidance
Lab 01 — KQL Query Engine + Alert Evaluator, suggested order (matches lab.py top to
bottom):
- Row-shaping —
where(rows, predicate)(validate callable; return copies),project(rows, columns)(list or{new: old}; raise on a missing column),extend(rows, **computed)(each callable sees the original row). These are Ch. 4. - Ordering/windowing —
order_by(stable sort),top(=order_by[:n], default desc),take,count. Validaten >= 0. bin_time(value, width)—math.floor(value/width)*width; validatewidth > 0. Test the grid (299 → 0,300 → 300) and the negative case (Ch. 5).percentile(values, p)— nearest-rank:rank = ceil(p/100·n), clamp to[1, n], return the value at that rank;p=0→ min,p=100→ max. Validate non-empty and0 ≤ p ≤ 100(Ch. 6).summarize(rows, aggregations, by)— bucket by theby-tuple, reduce each group, emit in sorted key order. Supportcount/sum/avg/min/max/dcount/percentile;by=[]→ one global row. Validate aggregations non-empty, funcs known, by-columns exist (Ch. 5–6).join(left, right, on, kind)— hash-index the right by key; for each left row emit merged rows for matches (inner), or a null-padded row ifleftouterand no match. Validatekindand key presence (Ch. 8).AlertRule+evaluate_alert(rows, rule, *, now)— validate operator/window/agg in__post_init__; window-filter to[now - window, now], aggregate the metric, compare with the operator. Empty window →False(Ch. 10).- Make
Tableconstruct (defensive copy, reject non-dicts) so the fluent tests pass.
Run red, make green: pytest test_lab.py -v, then LAB_MODULE=solution pytest test_lab.py -v, then python solution.py for the worked example.
Success Criteria
You are ready for Phase 14 when you can, from memory:
- Name the three pillars (metrics/logs/traces), the Azure store for each, and the question each answers — and state when you reach for which.
- Write a KQL query that computes p95 latency + count by 5-minute bin and service, and explain each operator's effect on the row set.
- Compute a nearest-rank percentile by hand, contrast it with interpolation, and explain why Kusto uses an approximate T-Digest at scale.
- Explain why
dcountand high-cardinalitybydimensions blow up cost, and what you do instead. - Explain inner vs leftouter join and the correctness trap of using inner when you're hunting for what's missing.
- Draw the
operation_Id/operation_ParentIdcorrelation and contrast head vs tail sampling with the cost/fidelity argument. - State the alert anatomy (
agg(window) <op> threshold → action group), thegt-vs-geboundary trap, and thefor/debounce knob. - Define SLI/SLO/error-budget and explain a burn-rate alert.
Interview Q&A
Q: "The checkout API got slow after a deploy. Walk me through finding the cause." First I pick the pillar: a metric confirms that p95 latency rose and when (correlate with the deploy time). Then I move to logs for the what: `requests | where TimeGenerated
ago(30m) | summarize p95=percentile(DurationMs, 95), n=count() by Service, bin(TimeGenerated, 5m)
andrender timechartto see which service and which minute. Then **traces** for the *where*:joinrequeststodependenciesonoperation_Idandsummarize sum(dependency duration)` to attribute the latency to the exact downstream hop — is it the database, Key Vault, Service Bus? The query names the culprit in three steps; I never guess.
Q: How does percentile scale to billions of rows, and what does that cost you?
It can't sort the column — that's $O(n \log n)$ over petabytes. Kusto computes percentiles
from a T-Digest sketch: mergeable, bounded-memory, fine-grained near the tails where
percentiles live, coarse in the dense middle. So the answer is approximate by design, with
a small bounded error — which is the right trade, because an exact p95 that takes 40 s is worse
than a 0.1%-off p95 in 200 ms. dcount is the same story with HyperLogLog (~1.6% standard
error). If I genuinely need exact, I aggregate a small filtered subset; at scale I accept the
sketch.
Q: A team wants to summarize ... by user_Id. What do you tell them?
That user_Id is high-cardinality, so it produces one output row per distinct user — millions
of groups, a slow query, a huge result, and if it's a metric dimension, a multiplied bill or a
rejected metric. What they almost always want is dcount(user_Id) as a measure inside a
low-cardinality group (by Service, bin(...)) — "how many distinct users were affected per
service per 5 minutes," which is the blast-radius number. Cardinality is the layout that costs
money; you aggregate the high-cardinality thing, you don't group by it.
Q: Metric alert vs log alert — when each, and how do you stop it flapping?
A metric alert evaluates a cheap pre-aggregated series every minute — use it for
platform-emitted CPU/availability/latency; low cost, low latency, low flexibility. A log
(scheduled-query) alert runs a full KQL query on a schedule — use it when the condition needs
your fields, your dimensions, or a join; more flexible, more expensive, slightly higher
latency. To stop flapping: a debounce (for window / "fire after N consecutive violations"),
a sensible lookback window (not so short it's gappy), and explicit no-data handling. And I
watch the boundary: gt vs ge against the threshold decides whether an exactly-at-the-
line value pages — I pick deliberately, because the off-by-one there is a real false-page bug.
Q: Explain head vs tail sampling and when you'd pay for tail. Head sampling decides at ingress with a fixed probability — cheap and predictable, but it's a coin flip that doesn't know the outcome, so it drops error and slow traces at the same rate as boring ones, blinding your error dashboards exactly when you need them. Tail sampling decides after the trace completes, keeping all errors and slow traces plus a sample of the rest — far higher fidelity per dollar, at the cost of buffering spans until the trace finishes (usually an OpenTelemetry Collector). I'd run adaptive head sampling for baseline volume and add tail sampling for the error/slow paths once the trace volume — or the cost of missing a rare failure — justifies the operational complexity.
Q: What's an error budget and why does it change behavior? It's $1 - \text{SLO}$: at a 99.9% SLO, 0.1% of requests (≈43 minutes of full outage per 30 days) is allowed to fail. It turns reliability into a number you spend — ship fast while budget remains, freeze risky changes when you're burning it. A burn-rate alert (you're spending budget faster than the window permits) beats a raw threshold because it scales with traffic and ties the page to actual user impact instead of an arbitrary number.
References
- KQL language reference — Microsoft Learn, Kusto Query Language (KQL) overview and the
per-operator pages (
where,project,extend,summarize,join,bin,top): https://learn.microsoft.com/azure/data-explorer/kusto/query/ - Aggregation functions —
percentile/percentiles,dcount,count,avg: https://learn.microsoft.com/azure/data-explorer/kusto/query/aggregation-functions - Azure Monitor overview (metrics vs logs vs alerts, the data platform): https://learn.microsoft.com/azure/azure-monitor/overview
- Log Analytics workspace & data structure: https://learn.microsoft.com/azure/azure-monitor/logs/log-analytics-workspace-overview
- Azure Monitor alerts (metric vs log alerts, action groups, the
for/debounce): https://learn.microsoft.com/azure/azure-monitor/alerts/alerts-overview - Application Insights distributed tracing & correlation (
operation_Id,operation_ParentId, W3C Trace Context): https://learn.microsoft.com/azure/azure-monitor/app/distributed-tracing-telemetry-correlation - Application Insights sampling (adaptive/fixed-rate head sampling and the tradeoff): https://learn.microsoft.com/azure/azure-monitor/app/sampling
- T-Digest — Dunning & Ertl, Computing Extremely Accurate Quantiles Using t-Digests
(the algorithm behind
percentileat scale). - HyperLogLog — Flajolet et al., HyperLogLog: the analysis of a near-optimal cardinality
estimation algorithm (behind
dcount). - Google SRE Book — Service Level Objectives and Monitoring Distributed Systems chapters (SLI/SLO/error-budget, the four golden signals).
- W3C Trace Context — https://www.w3.org/TR/trace-context/ (the
traceparentpropagation standard). - The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 13: Observability, Azure Monitor & KQL
Read this if: you can read a dashboard but "the chart doesn't show what I need, drop into Logs and write the query" still feels like someone else's job. It's yours now. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the engine.
0. The 30-second mental model
Observability is a query problem, not a UI problem. The portal's charts are auto-generated
KQL; the principal is the one who writes the query the chart can't. A KQL query is a table
piped left-to-right through pure operators (where | extend | summarize ... by bin() | join)
until the signal falls out. An alert is just aggregate(metric over a window) <op> threshold → page someone. One sentence to tattoo: metrics tell you that, logs tell you what,
traces tell you where — pick the right one before you start clicking.
1. The three pillars (pick one before you panic)
| Pillar | Store | Answers | Reach for it when |
|---|---|---|---|
| Metrics | Azure Monitor Metrics | "is it up / how loaded / trending?" | confirming that something's wrong, fast & cheap |
| Logs | Log Analytics (KQL) | "what happened to this request?" | investigating the what and why |
| Traces | App Insights | "which hop was slow/failed?" | a multi-service request and "it's not my service" |
Alert on metrics, investigate in logs, attribute in traces. Wrong pillar = lost first 15 minutes.
2. The KQL operators to memorize (the pipe)
TableName
| where Col == x and Time > ago(1h) // filter EARLY — shrinks every later stage
| extend NewCol = expr // add a computed column (sees the ORIGINAL row)
| project Keep, Renamed = Old // pick / rename columns
| summarize Agg = f(Col) by Dim, bin(T,5m)// group-and-reduce (the heart of KQL)
| join kind=leftouter (Other) on Key // correlate two tables
| order by Agg desc // sort
| top 10 by Agg // the N biggest (default desc)
| take 100 // first N (no order guarantee in real KQL)
| count // scalar: number of rows
| render timechart // chart it (portal only)
summarize and bin are the two that carry the weight. bin(t, w) = floor(t/w)*w —
absolute grid, not relative to your data; that's why series from different queries line up.
3. The aggregations + the gotchas
| Agg | Does | Gotcha |
|---|---|---|
count() | rows in group | — |
sum/avg/min/max(Col) | the obvious fold | avg hides the tail — use percentiles for latency |
dcount(Col) | distinct count (HyperLogLog) | approximate (~1.6% err); high-cardinality by it blows up cost |
percentile(Col, 95) | p95 (T-Digest) | approximate at scale; "real-time" = number + percentile |
percentiles(Col,50,95,99) | several at once | cheaper than three separate percentile calls |
Nearest-rank by hand: rank = ceil(p/100 · n). Over 1..100, p95 → rank 95 → value 95.
4. The numbers to tattoo on your arm
| Thing | Number |
|---|---|
| Metrics granularity | 1 minute, pre-aggregated, near-real-time, cheap |
| Logs billing | $/GB ingested + retention → cardinality is the bill |
dcount accuracy | ~1.6% standard error (HyperLogLog, default) |
percentile | T-Digest approximation; exact doesn't scale |
| 99.9% SLO error budget | 0.1% ≈ 43 min outage / 30 days |
| 99.99% SLO | ≈ 4.3 min / 30 days (one extra nine = 10× stricter) |
ago() units | 1m 1h 1d (and 5m for the canonical bin) |
| Adaptive sampling default | head sampling to a target items/sec (drops errors too) |
5. az monitor one-liners + the KQL that pays the rent
# create a Log Analytics workspace
az monitor log-analytics workspace create -g rg -n my-ws
# run a KQL query from the CLI (great for runbooks / CI checks)
az monitor log-analytics query -w <workspace-id> \
--analytics-query "requests | summarize p95=percentile(DurationMs,95) by Service" -o table
# list metric definitions for a resource, then pull a metric
az monitor metrics list-definitions --resource <res-id>
az monitor metrics list --resource <res-id> --metric "Http5xx" --interval PT1M
# a scheduled-query (log) alert on a KQL condition
az monitor scheduled-query create -g rg -n high-p95 --scopes <ws-id> \
--condition "count 'q' > 0" \
--condition-query q="requests | summarize p95=percentile(DurationMs,95) by bin(TimeGenerated,5m) | where p95 > 1000" \
--action-groups <ag-id>
# an action group (who gets paged)
az monitor action-group create -g rg -n oncall --action email me me@corp.com
The four KQL queries you'll write a thousand times:
// 1. latency regression by service & 5-min bin
requests | summarize p95=percentile(DurationMs,95), n=count() by Service, bin(TimeGenerated,5m)
// 2. error rate by result code
requests | summarize total=count(), errors=countif(Success==false) by bin(TimeGenerated,5m)
| extend rate = 100.0*errors/total
// 3. blast radius — distinct users/operations hit by a failure
exceptions | where TimeGenerated > ago(15m) | summarize dcount(user_Id), dcount(operation_Id) by ProblemId
// 4. trace reassembly — attribute latency to downstream hops
requests | join kind=leftouter (dependencies) on operation_Id
| summarize req_p95=percentile(DurationMs,95), dep=sum(dep_DurationMs) by operation_Id
6. War story shapes you'll relive
- "The dashboard says we're fine but customers are screaming." → the chart averages.
avgis 80 ms; p99 is 4 s. Switch topercentile(Duration, 99)and the tail appears. The average is the liar. - "Our Log Analytics bill 5×'d overnight." → someone started logging full URLs (with
query strings) or a per-request id as a dimension. High cardinality = GB ingested = $$.
Drop the field or sample it; aggregate it, don't
byit. - "The alert pages every few minutes and we've started ignoring it." → window too short,
no debounce, or
gtflapping right at the boundary. Add afor/N-violations debounce and a sane lookback. An ignored alert is worse than no alert. - "It's not my service." (said by all five teams) →
join requeststodependenciesonoperation_Id,summarizethe downstream time per hop. The trace ends the standoff with a number. - "We sample 1%, so why are our error dashboards empty?" → head sampling dropped 99% of an already-rare error class. You need tail sampling for the error/slow paths.
- "No data" alert never fired when the service died. → an empty window can't breach a threshold, so silence ≠ a page unless you explicitly alert on no-data (for a heartbeat, you must).
7. Vocabulary that signals you've held the pager
- Cardinality — distinct values of a column; the thing that costs money in observability.
operation_Id/operation_ParentId— trace id / parent span id; how a request becomes a tree across services.- Span / trace — a unit of work / the whole tree of them for one user action.
- Head vs tail sampling — decide at ingress (cheap, drops errors) vs after completion (keeps errors, costs complexity).
- T-Digest / HyperLogLog — the sketches behind approximate
percentile/dcount. - SLI / SLO / error budget — the indicator (a query), the target, and the allowed failure you spend.
- Burn rate — how fast you're spending error budget; the alert that scales with traffic.
- Action group — the who/what that gets notified when a rule fires.
- Metric alert vs log alert — cheap pre-aggregated series vs full KQL on a schedule.
- The four golden signals — latency, traffic, errors, saturation.
8. Beginner mistakes that mark you in interviews
- Reaching for logs to answer "is it up" (that's a metric) or a metric to debug "this one request" (that's a log).
- Reporting
avglatency instead of a percentile — instantly junior. summarize ... bya high-cardinality column (userId, requestId, raw URL) and being surprised by the cost and the slow query.- Saying
percentile/dcountare exact — they're approximate at scale by design. - Building an alert with no debounce/
forand no no-data policy, then drowning in pages. - Ignoring the
gt-vs-geboundary, so a value exactly at the threshold either never pages or always does. innerjoin when hunting for what's missing (it silently drops the very rows you wanted).- Sampling at a flat head rate and then wondering why rare errors vanished from the data.
9. How this phase pays off later
- The KQL engine (the lab) is literally how you investigate every incident in P14, and how you measure the SLOs P14 manages.
operation_Idcorrelation ties together everything you traced through P09 (gateway), P10 (Service Bus), P11 (Functions), P12 (Key Vault) — one request, one story.- Cardinality/cost thinking is the FinOps half of the P15 capstone's combined scorer.
- The alert evaluator is the operational backbone of "high availability" the JD demands — and the difference between deploying a platform and running one.
Now read the WARMUP slowly, then build the KQL engine. After that, the portal's Logs blade stops being a mystery box and starts being a REPL you own.
👨🏻 Brother Talk — Phase 13, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview.
Listen. Here's a thing nobody tells you about observability until you're three years in and
holding the pager: the person who can write the query is the person who runs the incident.
Not the person with the most context, not the loudest in the bridge call, not even the one who
wrote the broken service. The one who, while everyone else is clicking through dashboards and
saying "hm, that looks weird," quietly types a KQL query, hits run, and says "it's the
dependency call to Key Vault, p99 went from 40ms to 3 seconds at 14:02, here's the
operation_Id of a failing trace." That person just became the most senior engineer in the
room, regardless of their title. I have watched it happen dozens of times. The query is the
authority.
So let me give you the mindset shifts, brother to brother.
The dashboard is a lie you build for yourself, and the query is the truth. Dashboards are great — for the questions you already knew to ask. But production never breaks the way your dashboard anticipated. The failure that pages you is always the one no chart shows, and the moment you accept that, you stop treating "drop into the Logs blade and write KQL" as a last resort and start treating it as the main event. The principals I respect barely look at dashboards during an incident. They open the query window. Make that your reflex. The portal's pretty charts? Those are auto-generated KQL — you're just learning to write what the portal writes for you, except better, because you know what you're actually looking for.
Stop reporting averages. I mean it, it's banned. Same energy as Phase 00's "real-time" ban. The average latency is the single most misleading number in our field, because it hides the exact thing that matters: the tail. A service can have an 80ms average and a 4-second p99, and that p99 is a real cohort of real humans having a genuinely bad time — and they're the ones who churn, who tweet, who file the ticket your VP sees. The moment you say "p95" and "p99" instead of "average," you sound three levels more senior, because you've revealed you know that the tail is the product. Averages are for people who haven't been paged yet.
Cardinality is where the money quietly bleeds out, and you will be the one who finds it.
Here's a career-making move that looks like janitorial work: every few months, run Usage | summarize GB=sum(Quantity) by DataType and find out what's actually eating the bill. Nine
times out of ten it's one team logging full URLs with query strings, or stamping a per-request
GUID as a metric dimension, and it's 5×'ing the ingestion cost for zero analytical value. You
find it, you fix it, you save the org real money, and you do it with a one-line query. Nobody
asked you to. That's exactly why it gets noticed. The principals who get promoted aren't only
the ones who ship features — they're the ones who quietly make the platform cost less and can
prove it with a number.
Alerts are a relationship with your future self at 3 a.m. — don't betray him. Every alert
you create is a promise: "this is worth waking a human for." Break that promise — page on noise,
flap on a boundary, fire on a transient blip — and the deadliest thing in operations happens:
people start ignoring the alert. An ignored alert is worse than no alert, because it gives
you the illusion of coverage while the real incident scrolls past muted. So tune them like
your sleep depends on it, because someone's does. Debounce (for / N violations). Pick gt
vs ge on purpose. Decide explicitly whether "no data" is an emergency or a Tuesday. The
engineer who keeps the alert channel trustworthy is worth more than the one who adds fifty
new alerts nobody reads.
Tracing is the superpower that ends the blame game. You will sit in incident calls where
five teams each insist it's not their service, and it'll go in circles for forty minutes
because everyone's looking at their own slice. Then you join requests to dependencies on
operation_Id, attribute the latency to the exact hop, and the argument is over — not because
you won it, but because there's nothing left to argue about. Data ended it. The first time you
do that, people start treating you like a principal. So everywhere you build, ask: is the
correlation id propagated? If operation_Id doesn't flow through every hop, you can't
reassemble the trace, and you're back to forty-minute blame calls. Protect the correlation id
the way Phase 00 told you to protect replay.
The honest truth about this phase: the engine you build is small — a few dozen lines of pure functions over lists of dicts. The judgment around it is the whole job. Knowing which pillar to reach for, knowing that percentile is approximate and being at peace with it, knowing that cardinality is the cost, knowing that an alert is a promise — none of that is in the syntax. It's in having been paged enough times to have opinions. You don't have those scars yet, and that's fine; this lab is how you get the intuition before the pager gives you the scars.
One more thing, and it's the most important. Observability is where you stop being someone who
deploys the platform and become someone who operates it — and that transition is the entire
difference between senior and principal. Anyone can terraform apply. The principal is the one
who's still standing, calm, querying, when the thing they applied is on fire at 2 a.m. — turning
a wall of red alarms into one sentence: "it's the dependency on the regional cache, here's the
blast radius, here's what I'm doing about it." That calm isn't a personality trait. It comes from
the query. From knowing that whatever is wrong, you can ask the data and it will answer. That's
not magic. That's this phase. Build the engine, and the calm follows.
Go build the KQL engine. Then come find me in Phase 14 — that's where we turn all this querying into actual reliability: SLOs, error budgets, retries, circuit breakers, and the math that lets you promise an availability number and mean it.
— your brother 👨🏻
Lab 01 — KQL Query Engine + Alert Evaluator
Phase: 13 — Observability, Azure Monitor & KQL | Difficulty: ⭐⭐⭐☆☆ | Time: 4–6 hours
When a service degrades at 2 a.m., the portal is not where you find the cause — the query is. Azure Monitor stores billions of log rows in a Log Analytics workspace, and you interrogate them with KQL (Kusto Query Language): a left-to-right pipeline of tabular operators (
where | extend | summarize ... by bin() | join | top) that filters, buckets, aggregates, and correlates until the signal falls out. Pull the cover off and KQL is just a sequence of pure functions over a list of rows, each returning a new list — which is exactly why a query is safe to re-run and reason about. This lab builds that engine: the operators, the aggregations a principal recites in their sleep (count/sum/avg/min/max/dcount/percentile), thebin()that turns continuous time into 5-minute buckets, an inner/leftouterjoin, and — the thing that actually pages you — an alert-rule evaluator that aggregates a metric over a lookback window and compares it to a threshold.
What you build
Table(rows)— a fluent wrapper sot.where(...).summarize(...).top(...)reads like the KQL pipe (|). Every method returns a newTable(pure; nothing is mutated). Underneath are the real engine — the operator functions, usable directly on anylist[dict].- Row-shaping operators —
where(rows, predicate)(filter),project(rows, columns)(keep/rename columns; takes a list or a{new: old}dict),extend(rows, **computed)(add columns fromcallable(row) -> value). - Ordering / windowing —
order_by(rows, key, desc)(stable sort),top(rows, n, by, desc)(the n biggest/smallest),take(rows, n),count(rows). bin_time(value, width)— thebin()function:floor(value / width) * width, flooring continuous timestamps onto an absolute grid so rows in the same interval group together insidesummarize.percentile(values, p)— the nearest-rank percentile (rank = ceil(p/100 · n)), chosen because it returns an observed value so a p95 is exactly assertable.summarize(rows, aggregations, by)— the heart of KQL: group rows by thebycolumns and reduce each group withcount/sum/avg/min/max/dcount/percentile. Deterministic group order (sorted by the group key).by=[]gives one global summary row.join(left, right, on, kind)— a hash join on a single key,innerandleftouter.AlertRule+evaluate_alert(rows, rule, *, now)— the alert: window-filter rows to[now - window, now], aggregate the metric, compare to the threshold withgt/lt/ge/le. Empty window → does not fire (no data cannot breach).
Key concepts
| Concept | What to understand |
|---|---|
| KQL is a pipeline of pure functions | each operator takes rows, returns new rows; left-to-right ` |
where / project / extend | filter rows / pick-and-rename columns / add computed columns. extend callables see the original row (chain a second extend for dependent cols — KQL's actual semantics) |
summarize ... by | group by the by tuple, one output row per group; the by-columns + the aggregates. This is GROUP BY, KQL-style |
bin(t, w) | floor(t/w)*w — buckets onto an absolute grid, not relative to your data's start; that's why a bucket can floor below t0 |
count/sum/avg/min/max | the obvious folds over a group's column values |
dcount | distinct count — cardinality. The dimension that, when high (userId, requestId), blows up both query cost and storage |
percentile | nearest-rank here (rank = ceil(p/100·n)); real Kusto approximates with T-Digest for scale — same answer shape, different cost/fidelity |
join kind=inner vs leftouter | inner emits only matched pairs; leftouter keeps every left row (right columns null when unmatched) — the difference between "drop unknowns" and "see the gaps" |
| Alert = aggregation over a window vs threshold | agg(metric over [now-window, now]) <op> threshold. The window/lookback and the operator boundary (gt vs ge) are where false pages live |
| Determinism | sorted group order, stable sort, nearest-rank percentile, copies not mutations — same input → same bytes, so tests are exact |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs a worked example over a request log |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 35 tests pass against your implementation.
- You can explain why
test_percentile_nearest_rank_known_answersassertspercentile([1..100], 95) == 95exactly: nearest-rank takesrank = ceil(0.95·100) = 95, the 95th-smallest value — an observed point, never an interpolated one. (Then explain why real Kusto's T-Digest answer might be off by a hair, and why that's an acceptable cost/fidelity trade at billions of rows.) - You can explain why
test_bin_time_buckets_to_gridfloors299 → 0and300 → 300:bin()lands rows on an absolute grid ofwidth-sized buckets, which is why two queries with the samebin(Timestamp, 5m)line up even though they never coordinated. - You can explain why
test_summarize_count_by_group_is_deterministicmatters — real Kusto doesn't promise group order, so a teaching engine that does (sorted by key) is testable and reproducible. - You can explain why
test_alert_boundary_gt_vs_geis the off-by-one interviewers probe: a metric of exactly300against threshold300does not fire undergtbut does underge. Pick the wrong operator and you either miss the breach or page on the boundary forever. - You can explain why
test_alert_empty_window_does_not_fireis deliberate: an empty window has no metric to breach, so it returnsFalse— modeling Azure Monitor's "no data" behavior rather than a false page (and why "no data" is sometimes itself worth a separate alert).
How this maps to real Azure (Azure Monitor + Log Analytics + Application Insights)
| The lab | Real Azure |
|---|---|
a Table (list[dict]) | a table in a Log Analytics workspace — requests, traces, dependencies, exceptions, AzureDiagnostics, Heartbeat |
the operators (where/project/extend/summarize/join/top/order by/take/count) | the KQL tabular operators, piped with ` |
bin_time(t, w) | the KQL bin(Timestamp, 5m) (a.k.a. floor) used in summarize ... by bin(...) for time-series rollups and the x-axis of every time chart |
percentile(vals, p) | KQL percentile(Duration, 95) / percentiles(...) — but the engine uses a T-Digest approximation (bounded memory, slight error) instead of exact nearest-rank |
dcount | KQL dcount(...) — an HyperLogLog approximate distinct count at scale; exact count(distinct ...) doesn't scale to billions of rows |
join kind=inner / leftouter | the KQL join kind=inner / kind=leftouter operators (Kusto also has innerunique, rightouter, fullouter, leftanti, …) |
AlertRule + evaluate_alert | a scheduled-query (log) alert or a metric alert rule: a query/aggregation evaluated on a schedule over a lookback window, breaching when aggregate <op> threshold |
window (the lookback) | the alert's aggregation granularity + lookback (evaluation) window — and for / number-of-violations to debounce flapping |
the now parameter | the alert's evaluation time; Azure runs the rule on a schedule and supplies it |
| firing → (in real life) | the rule's action group: email/SMS/push, an ITSM/PagerDuty webhook, a Logic App, an Automation runbook |
operation_Id correlation (taught in WARMUP) | Application Insights distributed tracing — every request/dependency/trace row carries operation_Id + operation_ParentId, and a join on operation_Id reassembles the call tree across services |
What the miniature leaves out (and why it's fine): real Kusto is a columnar,
distributed, indexed engine that runs over petabytes with extent caching, a cost
model, ingestion-time transforms, materialized views, cross-workspace/cross-cluster
queries, make-series / time-series functions, mv-expand, parse, and dozens more
operators; percentile/dcount are approximate (T-Digest / HyperLogLog) precisely
because exact answers don't scale. None of that changes the two mechanisms this lab
isolates — (1) a query is a left-to-right pipeline of pure operators that culminates
in a summarize-style group-and-reduce, and (2) an alert is an aggregation over a
lookback window compared to a threshold. Those are exactly the parts an interview probes
and an incident turns on.
Extensions (build these in your own workspace)
make-series/ gap-fill: add amake_series(rows, agg, on_time, step, range)that emits a dense time series (one bucket perstepacross the whole range, zero-filled), the way KQLmake-seriesdoes for charting —summarize ... by bin()leaves gaps where a bucket had no rows, and gaps lie to the eye.- Tail-based sampling: model
sample_traces(traces, head_rate)(head sampling: keep each trace with fixed probability, decided at ingress) vs a tail sampler that keeps a trace iff it contains an error or a slow span (decided after the trace completes) — then reason about the cost/fidelity trade in the WARMUP. - Multi-table correlation: build
requests+dependenciestables sharingoperation_Id, thenjointhem to compute, per request, the sum of downstream dependency time — the classic "where did the latency go?" trace query. - Alert debounce (
forwindow): extendevaluate_alertto require the threshold be breached for N consecutive evaluations before firing (Azure's "number of violations" /forclause) — the single most effective noise-reduction knob. - Wire it to real Azure:
az monitor log-analytics workspace create; send a few custom logs; run your KQL in the portal's Logs blade (requests | summarize p95=percentile( DurationMs,95) by bin(TimeGenerated, 5m), Service); thenaz monitor scheduled-query createan alert on it and watch it fire into an action group.
Interview / resume
- Talking points: "Walk me through a KQL query that finds the p95-latency regression by
service and 5-minute bin." / "How does
percentilescale to billions of rows — what's T-Digest costing you?" / "Why does high-cardinalitydcount(per userId) blow up cost, and what do you do instead?" / "Metric alert vs log (scheduled-query) alert — when each, and how do you stop it flapping?" / "How doesoperation_Idreconstruct a distributed trace across five services?" - Resume bullet: Built a miniature KQL query engine — the tabular operators
(
where/project/extend/summarize ... by bin()/join/top), the aggregations (count/sum/avg/min/max/dcount/nearest-rankpercentile), and a window-aggregation alert-rule evaluator — modeling Azure Monitor Logs, Log Analytics, and Application Insights alerting.
Phase 14 — Reliability, HA & Operational Troubleshooting
Difficulty: ⭐⭐⭐☆☆ (the arithmetic) → ⭐⭐⭐⭐⭐ (the judgment under a live incident)
Estimated Time: 1 week (12–18 hours)
Prerequisites: Phase 00 (control plane vs data plane; quantify the tradeoff), Phase 06
(networking — Private Endpoints and DNS are half the failure signatures), Phase 09 (API
Management — 429 / rate limits / Retry-After), Phase 10 (Service Bus / Event Grid retry
and dead-letter), and Phase 13 (Azure Monitor / KQL — how you see the failure signature in
the first place). No Azure subscription required for the lab.
Why This Phase Exists
Every prior phase built a mechanism. This phase builds the discipline that keeps all of them up — and the method that gets them back up at 2 a.m. The JD names this directly:
"Provide troubleshooting and operational support for Azure cloud services and infrastructure." … "Monitor, troubleshoot, and optimize cloud infrastructure to ensure high availability, security, and performance."
A senior engineer says "we have a 99.9% SLA." A principal can tell you what that buys
you (43.2 minutes of downtime a month), whether a four-hop chain can even deliver it
(Front Door → APIM → Function → DB multiplies to something below every hop), and what
it would cost to add a nine (zone-redundant replicas, an N+1 spare, the binomial math behind
it). Reliability at this level is arithmetic you do before the design review, not a hope
you express after the incident.
And when it does break — because it will — a principal does not flail. They read the failure
signature (429? 403? a DNS error? a dependency timeout?), walk a decision tree to a
likely cause, and reach for the right resilience primitive: a retry with exponential
backoff and jitter under a retry budget (so the retries don't amplify the outage), a
circuit breaker (so you stop hammering a dead dependency), or honoring the server's
Retry-After (so a throttled client stops fighting the throttle). This phase makes you
build those primitives so they stop being words in a Well-Architected checklist and become
mechanisms you can explain — and defend — line by line.
What "Principal-Level" Means Here
A senior engineer uses a retry library and quotes an SLA. A principal understands the reliability stack well enough to:
- Compose an SLA in their head. Serial hops multiply (
∏ aᵢ— more hops, lower SLA); redundant replicas combine as1 − ∏(1 − aᵢ); an N+k cluster's availability is a binomial survival probability. They can take "Front Door → APIM → Function → SQL, each at three to four nines" and tell you the composite and the monthly downtime budget — with numbers, before anyone builds it. - Spread for survival, not just speed. They know an availability zone is a physically separate datacenter (independent power, cooling, network) within a region, that zone- redundant services place replicas across zones so a datacenter fire is a non-event, and that paired regions give you a DR target with sequential update and recovery priority. "Make it HA" becomes a concrete placement decision.
- Make retries safe. They never retry without idempotency, never retry without
jitter (the thundering herd that re-creates the outage), and never retry without a
budget (the retry storm that turns a blip into an incident). They honor
429/Retry-Afterbecause the server is telling them exactly what to do. - Run a circuit breaker from first principles. Closed → Open (fail fast after a threshold) → Half-Open (one probe after a reset timeout) → Closed (recovered) or → Open (still broken). They can explain why each state exists and what failure it prevents.
- Triage by signature. They have a decision tree from failure signature to likely cause:
429→ throttling,403→ authz/RBAC/data-plane role, a DNS error → Private DNS / Private Endpoint,503→ dependency unavailable → circuit-break. Triage is a method, not a vibe.
Concepts
- Availability zones & region pairs. A zone is a physically separate datacenter (its own power, cooling, and network) inside a region; a region has three or more. Zone- redundant services replicate across zones automatically; zonal services pin to one zone (you place the redundancy). Paired regions are two regions in the same geography with sequential platform updates, recovery priority, and (for some services) built-in geo- replication — your DR target. Spreading replicas across zones is what makes a single- datacenter failure survivable.
- SLA composition math. A serial dependency chain is only as available as the product
of its hops:
A_chain = ∏ aᵢ— so every hop you add lowers the composite, and the result is below the worst hop. Redundant components combine the other way:A_redundant = 1 − ∏(1 − aᵢ)— the group is down only if all replicas are down, so redundancy drives availability up. N+k redundancy (need at leastnofn+knodes) is a binomial survival probability. From availability you get the downtime budget:(1 − A) × window. - Retry with exponential backoff + jitter, under a budget. Backoff spaces retries
(
min(cap, base · 2^attempt)); full jitter randomizes the wait (uniform(0, backoff)) so a fleet that failed together doesn't retry in lockstep; a retry budget caps total retries so they can't amplify an outage. HonoringRetry-After(on429/503) obeys the server's explicit instruction. Idempotency is the precondition that makes any of this safe to repeat. - Circuit breaker. A state machine — Closed (pass, count failures), Open (fail fast after the failure threshold), Half-Open (one probe after the reset timeout) — that stops a client from hammering a dependency that's already down, and gives it room to recover.
- Throttling, backpressure, and the triage method. Throttling (
429) and backpressure (downstream slowness flowing upstream as flow control) are the system telling you to slow down. The troubleshooting method turns a failure signature (HTTP status + symptom) into a likely cause and a first action via a decision tree — the muscle memory that turns a page into a plan.
Labs
Lab 01 — Resiliency Engine (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the reliability primitives a principal does in their head: SLA composition (serial_sla ∏, redundant_sla 1−∏(1−a), composite_sla with nested redundant hops, downtime_minutes_per_month, n_plus_k_sla via the binomial), retry backoff (backoff_delay, full_jitter with an injected seeded rng, a token-style RetryBudget, retry_after_seconds honoring the header), a CircuitBreaker (closed→open→half-open→closed across injected now), and a diagnose decision tree mapping failure signatures (429/403/503/DNS/…) to likely cause + first action |
| Concepts | serial vs redundant SLA composition; the downtime budget; N+k binomial; exponential backoff; full jitter; retry budgets; Retry-After; idempotency as a retry precondition; the circuit-breaker state machine; failure-signature triage |
| Steps | 1. SLA math (∏, 1−∏(1−a), composite with nested redundancy, downtime, N+k binomial); 2. backoff (min(cap, base·2^attempt)), full jitter (bounded, seeded rng), RetryBudget (token exhaustion), retry_after_seconds (header vs jitter); 3. CircuitBreaker state machine with injected time; 4. diagnose decision tree |
| How to Test | pytest test_lab.py -v — 40 tests: serial vs redundant math with exact numbers (three 0.999 ≈ 0.997002999; two 0.99 redundant = 0.9999); N+k binomial known value (2-of-3 @ 0.9 = 0.972); downtime (three nines = 43.2 min); backoff caps and doubling; full-jitter bounds with a seeded rng; RetryBudget exhaustion; Retry-After honored vs jitter fallback; the full circuit-breaker transitions across injected time incl. reopen-on-half-open-failure; diagnose mappings for 429/403/503/DNS |
| Talking Points | "Compose the SLA of a four-hop chain and tell me the monthly downtime." / "Why must a retry have jitter and a budget?" / "Walk me through the circuit breaker's three states and what each prevents." / "You get a 403 from a Function calling Storage — what do you check first?" |
| Resume bullet | Built a reliability engine — SLA composition (serial/redundant/N+k binomial + downtime budgets), retry with exponential backoff + full jitter under a retry budget, Retry-After honoring, a circuit-breaker state machine, and a failure-signature triage decision tree |
→ Lab folder: lab-01-resiliency-engine/
Integrated-Scenario Suggestions (carried through the whole track)
The phases compound. Keep these in mind as you build — each wires reliability into the larger platform:
- The composite-SLA design review. Take the platform you've built across the track —
Front Door → API Management (P09) → Function (P11) → SQL/Cosmos— and compute its serial composite SLA, its monthly downtime budget, and where adding a nine is cheapest (usually the single-zone hop). Then make the front door and database zone-redundant and recompute. This is the arithmetic behind a real availability target. (→ P09, P11, P00 cost models) - The retry-storm post-mortem. A dependency blips; every client retries; the synchronized
retries (no jitter, no budget) turn a 2-second blip into a 20-minute outage. Reconstruct it,
then fix it with full jitter + a retry budget + honoring
Retry-Afterfrom the throttled service (P09 rate limiting, P10 Service Bus throttling). (→ P09, P10) - The circuit-breaker in front of a flaky dependency. A Function calls a downstream API
that periodically dies. Without a breaker, every invocation waits for a timeout and piles
up; with one, it fails fast while Open and probes once on Half-Open. Size the
failure_thresholdandreset_timeout, and wire it to the dead-letter path (P10) for the messages it sheds. (→ P10, P11) - The DNS / Private Endpoint incident. "Name resolution failed" after a Private Endpoint
was added — the Private DNS zone isn't linked to the VNet, or the A record points at the
public IP. Walk the
diagnosetree, fix the zone link (P06), and add a synthetic DNS- resolution probe so the next one alarms before customers notice (P13). (→ P06, P13) - The error-budget burn alert. From the SLO (e.g. 99.9% → 43.2 min/month) compute the error budget, then write a KQL alert (P13) that fires when the budget burns too fast (multi-window burn rate). This is SRE error-budget thinking made concrete on top of the downtime math from this lab. (→ P13, P00 SLO framing)
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-minute orientation; read first
- WARMUP.md — the full primer; read slowly
- BROTHER-TALK.md — the candid version
Key Takeaways
- Serial chains multiply; redundancy combines the inverse.
A_serial = ∏ aᵢ(more hops, lower SLA — below every hop);A_redundant = 1 − ∏(1 − aᵢ)(more replicas, higher SLA). N+k is a binomial survival probability. From availability comes the downtime budget(1 − A) × window. This is arithmetic you do before the review, not after the incident. - Availability zones make a datacenter failure a non-event — if you spread replicas across them. Zone-redundant services do it for you; zonal services make you place it. Paired regions are your DR target. "Make it HA" is a placement decision with a number behind it.
- A retry without jitter and a budget is a loaded gun. Backoff spaces retries, jitter
de-synchronizes the herd, a budget caps the amplification,
Retry-Afterobeys the server, and idempotency is what makes any of it safe. Get one wrong and your retries cause the outage. - A circuit breaker stops you hammering a corpse. Closed (count) → Open (fail fast) → Half-Open (probe) → Closed/Open. Each state prevents a specific failure: the breaker buys the dependency room to recover and your callers a fast failure instead of a pile-up.
- Triage is a method, not a vibe. A failure signature (status + symptom) maps to a
likely cause and a first action:
429→ throttle/back-off,403→ authz/RBAC/data-plane role, DNS error → Private DNS/Endpoint,503→ dependency-unavailable/circuit-break. The decision tree is what keeps you calm at 2 a.m.
Deliverables Checklist
-
Lab 01 implemented; all 40 tests pass against
solution.pyand yourlab.py - You can compose a four-hop serial SLA and state its monthly downtime budget from memory
- You can explain why two 0.99 replicas give 0.9999 and three give 0.999999
- You can compute an N+1 cluster's availability with the binomial (2-of-3 @ 0.99)
- You can state the three reasons a retry needs backoff, jitter, and a budget — and why idempotency is the precondition
- You can draw the circuit-breaker state machine and name what each state prevents
-
You can take a failure signature (
429/403/503/DNS) and give the likely cause and first action without looking it up
Warmup Guide — Reliability, HA & Operational Troubleshooting
Zero-to-principal primer for Phase 14: what availability actually is and how you compose it across a real Azure chain (serial hops multiply, redundant replicas combine the inverse, N+k is a binomial), where availability zones and paired regions make a datacenter or region failure survivable, how to make retries safe with exponential backoff + jitter under a retry budget while honoring
Retry-After, how a circuit breaker stops you hammering a dead dependency, and the troubleshooting method that turns a failure signature into a likely cause. Every concept goes from what it is to why it exists to the mechanism under the hood (diagrams, tables, math, code) to production significance to the misconceptions that page people at 2 a.m.
Table of Contents
- Chapter 1: What Availability Actually Means
- Chapter 2: Availability Zones and Region Pairs
- Chapter 3: SLA Composition — Serial Chains Multiply
- Chapter 4: Redundancy and N+k — Combining the Inverse
- Chapter 5: Retry, Exponential Backoff, and Jitter
- Chapter 6: Retry Budgets, Idempotency, and Retry-After
- Chapter 7: The Circuit Breaker State Machine
- Chapter 8: Throttling, Backpressure, and the Triage Method
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: What Availability Actually Means
From zero. Availability is the probability that a system is up and serving correct
responses when you ask it. We write it as a number in [0, 1] (or a percentage), and we
casually count its nines: 0.99 is "two nines," 0.999 is "three nines," 0.9999 is "four nines."
Why it exists / what it buys. An availability is only meaningful as a downtime budget over a window. The arithmetic is trivial and you must be able to do it instantly:
$$ \text{downtime} = (1 - A) \times \text{window} $$
Over a 30-day month (43200 minutes), the table you should have memorized:
| Availability | Nines | Downtime / month | Downtime / year |
|---|---|---|---|
| 0.99 | two | 432 min (7.2 h) | ~3.65 days |
| 0.999 | three | 43.2 min | ~8.76 h |
| 0.9999 | four | 4.32 min | ~52.6 min |
| 0.99999 | five | 0.43 min (~26 s) | ~5.26 min |
Production significance. An SLA (Service Level Agreement) is the promise with a financial credit attached; an SLO (objective) is your internal target, usually stricter; an SLI (indicator) is the measurement (success rate, latency). The principal habit: when someone says "three nines," you immediately translate to "43 minutes a month — is that acceptable for this workload, and can the architecture even deliver it?" (Chapter 3 shows why a chain often can't.)
Misconception. "We have a 99.9% SLA" is not a property of your system — it's a property of each component's SLA composed across the path a request takes. A 99.9% database behind a 99.9% gateway behind a 99.9% function is not a 99.9% system (Chapter 3). And note the error budget framing: 43.2 minutes/month is a budget you spend on deploys, experiments, and incidents — not a failure to avoid at all costs. Spending it deliberately (SRE thinking) is healthier than guarding it religiously.
Chapter 2: Availability Zones and Region Pairs
From zero. Azure's physical hierarchy, bottom-up: a datacenter is a building full of racks. An availability zone (AZ) is one or more datacenters within a region that have independent power, cooling, and physical network — so a fire, a flooded transformer, or a network-gear failure in one zone does not take down another. A region is a set of datacenters in a metro area, and an enabled region has three or more zones. A region pair is two regions in the same geography (e.g. East US ↔ West US) with special platform behavior.
Geography (e.g. United States)
└── Region: East US ──────paired with──────► Region: West US (DR target)
├── Availability Zone 1 (datacenter(s): own power/cooling/network)
├── Availability Zone 2 (independent failure domain)
└── Availability Zone 3
a zone failure = ONE datacenter down; the other zones keep serving
Why it exists / the mechanism. Zones are independent failure domains inside a
region with low-latency links between them (typically < 2 ms round trip), so you get
fault isolation and synchronous replication. Two deployment styles:
- Zone-redundant — the service spreads replicas across zones for you (e.g. zone- redundant Storage, zone-redundant SQL, a Standard Load Balancer, zone-redundant App Gateway / Front Door). A zone outage is automatically absorbed.
- Zonal — you pin a resource to a specific zone (e.g. a VM in Zone 2). To get redundancy you place instances in different zones yourself and load-balance across them.
Region pairs add: sequential platform updates (Azure won't update both halves of a pair at once), recovery priority (a broad outage restores at least one region of each pair first), data residency within the geography, and for some services built-in geo-replication (e.g. GRS Storage replicates to the pair). The pair is your disaster-recovery target — a region dying (rare but real) is what region-pair DR is for, where zones are for the common datacenter failure.
Production significance. "Make it highly available" decomposes into a placement decision: single zone (cheapest, a datacenter failure is an outage) → zone-redundant (survives a datacenter failure, near-zero added latency, the default for production) → multi-region (survives a region failure, costs you cross-region latency, async replication, and a failover runbook). Each step up the ladder adds a nine and a cost; you pick the rung the SLO requires.
Misconception. "Multi-region first." No — zone redundancy is the high-leverage default: it removes the most common failure (a single datacenter) for almost no latency cost and little complexity. Multi-region is for region-level failures and strict RTO/RPO requirements, and it brings real cost: cross-region replication lag, conflict handling, and a failover process you must test. Most workloads need zone redundancy, not a second region.
Chapter 3: SLA Composition — Serial Chains Multiply
This is the chapter that separates "we have a 99.9% SLA" from understanding what your system can actually deliver.
From zero. A serial dependency chain is a path where every hop must be up for the
request to succeed: Front Door → API Management → Function → Database. If the hops fail
independently, the composite availability is the product:
$$ A_{\text{serial}} = \prod_{i=1}^{n} a_i $$
The mechanism / the consequence. Multiplying numbers below 1 always gives a smaller number, so every hop you add lowers the composite SLA, and the result is below the worst hop. Worked from the lab's worked example:
Front Door 0.9995 × APIM 0.9995 × Function 0.9995 × SQL 0.9999 = 0.998401
→ composite ≈ 99.84%, BELOW every hop
→ monthly downtime budget ≈ 69 minutes (vs 4.3 min for the best single hop)
Three "three nines" hops in series:
$$ 0.999 \times 0.999 \times 0.999 \approx 0.997002999 $$
— so a chain of three-nines components is barely two-and-a-half nines. The lab's
serial_sla is exactly this product, and test_serial_sla_multiplies_and_is_below_every_hop
pins the 0.997002999.
Production significance. This is the reliability arithmetic for a design review. Before
anyone builds the Front Door → APIM → Function → DB platform, you can say "the composite is
99.84%, that's ~69 minutes a month — if the SLO is 99.9% (43 min), we're already over budget
on paper, so we need redundancy on the weakest hop." Which hop? The one whose availability is
lowest and which isn't already redundant — usually the single-region/single-zone component.
Adding a nine to the weakest hop moves the composite the most.
Misconception. "All the components are 99.9%, so the system is 99.9%." No — serial composition makes the system worse than any component. The only way the composite equals a single hop's SLA is a one-hop system. The other misconception: that the hops are independent. If two hops share a failure domain (same zone, same dependency, same control plane), their failures correlate and the simple product over-estimates availability — correlated failure is why a "redundant" pair in the same zone isn't real redundancy (Chapter 4).
Chapter 4: Redundancy and N+k — Combining the Inverse
From zero. Redundancy is the opposite of a serial chain: instead of "all must be up,"
it's "at least one must be up." Put n replicas behind a load balancer; the group is down
only if every replica is down. Since the replicas fail independently, the unavailabilities
multiply:
$$ A_{\text{redundant}} = 1 - \prod_{i=1}^{n} (1 - a_i) $$
The mechanism / the magic. Each replica's unavailability (1 − a) is a small number;
multiplying small numbers makes them tiny; one minus tiny is close to one. Redundancy
drives availability up, fast:
one replica @ 0.99 → 0.99 (two nines)
two replicas @ 0.99 each → 1 − 0.01² = 0.9999 (FOUR nines)
three @ 0.99 each → 1 − 0.01³ = 0.999999 (SIX nines)
Each independent replica roughly doubles your nines. The lab's redundant_sla is exactly
1 − ∏(1 − aᵢ), and test_redundant_sla_raises_availability pins the 0.9999 and 0.999999.
N+k redundancy — the binomial. Real clusters need at least n of n+k nodes up to
serve (a quorum, a capacity floor). With each node independently up with probability p, the
cluster availability is the binomial survival probability:
$$ A_{N+k} = \sum_{i=n}^{N} \binom{N}{i}, p^{i} (1-p)^{N-i} \quad\text{where } N = n+k $$
The spare count k is total − required (N+1 = one spare). Worked: need 2 of 3 nodes,
each 0.9:
$$ \binom{3}{2}(0.9)^2(0.1) + \binom{3}{3}(0.9)^3 = 3(0.81)(0.1) + 0.729 = 0.243 + 0.729 = 0.972 $$
The lab's n_plus_k_sla sums these binomial terms, and test_n_plus_k_binomial_known_value
pins the 0.972. Two boundary identities the lab also proves: need-all (n = N) is just
the serial product pⁿ, and need-one (n = 1) is exactly redundant_sla of N replicas.
Composite chains with redundant hops. Real systems are serial chains of redundant hops: a
zone-redundant front door (replicas) → a single APIM → a zone-redundant database (replicas).
You collapse each redundant hop with redundant_sla first, then multiply the hops serially —
which is precisely the lab's composite_sla.
composite( [0.99, 0.99], apim 0.999, function 0.999, [0.999, 0.999] )
step 1: collapse redundant hops → 0.9999, 0.999, 0.999, 0.999999
step 2: serial product of the collapsed hops → the composite SLA
Production significance. This is how you buy a nine: find the weakest serial hop, make it redundant, recompute. The cost ladder is real — a second replica doubles that hop's bill, and correlated failure (same zone) silently breaks the independence the math assumes — so you place the replicas in different availability zones (Chapter 2) to keep the failures independent.
Misconception. "Two replicas means it's always up." No — it means it's down only when
both are down and the failures were independent. Put both in the same zone and a zone
outage takes both; the 1 − ∏(1 − a) formula silently assumed an independence you didn't
have. Redundancy without independent failure domains is theater.
Chapter 5: Retry, Exponential Backoff, and Jitter
From zero. A transient failure (a blip, a brief throttle, a momentary network drop) is one that succeeds if you simply try again. A retry is trying again. The naive version — retry immediately, in a tight loop — is dangerous: it hammers a struggling dependency exactly when it's least able to cope, and it does so from every client at once.
Exponential backoff — the mechanism. Space the retries out, doubling the wait each time:
$$ \text{delay}(n) = \min\big(\text{cap},; \text{base} \cdot 2^{n}\big) $$
where n is the 0-indexed attempt. The doubling gives a recovering dependency progressively
more room; the cap bounds the wait so a long outage doesn't push retries hours apart. The
lab's backoff_delay is exactly this, and test_backoff_doubles_then_caps walks
1 → 2 → 4 → 8 → 10(capped) → 10:
base=1s, cap=10s:
attempt 0: 1s attempt 3: 8s
attempt 1: 2s attempt 4: 16s → capped to 10s
attempt 2: 4s attempt 5+: stays 10s
Jitter — why pure backoff isn't enough. Picture a thousand clients that all hit the same
dependency, which blips. They all fail at the same instant, so they all retry at exactly
base later, then 2·base later — a synchronized thundering herd that re-creates the
overload on every retry wave. Jitter randomizes the delay to de-synchronize them. The robust
form is full jitter:
$$ \text{jitter}(n) = \text{uniform}\big(0,; \text{delay}(n)\big) $$
— a uniform random wait between 0 and the backoff ceiling, so the herd smears across the whole
interval instead of stacking on the boundary. The lab's full_jitter is exactly this, bounded
0 ≤ jitter ≤ delay(n), and — critically — it takes an injected rng (defaulting to a
seeded random.Random(0)) so the behavior is deterministic and testable.
test_full_jitter_within_bounds_seeded checks the bounds across many draws with a seeded rng.
Why a seeded, injected rng? Reliability code that calls un-seeded
randomis untestable — you can't assert anything about its output. Injecting the rng (and defaulting to a seeded one) is the pattern that lets you prove the bounds deterministically. Never put un-seeded randomness in logic you need to test.
Production significance. AWS's "Exponential Backoff and Jitter" paper showed full jitter both reduces total work and clears a backlog faster than backoff alone — counterintuitively, more randomness is more stable. Every Azure SDK's retry policy uses backoff + jitter; you should be able to explain why both knobs exist.
Misconception. "Backoff is enough." No — backoff alone keeps the herd synchronized (they just all wait the same exponentially-growing time). Jitter is what breaks the synchronization that turns a blip into a sustained outage. And the other misconception (Chapter 6): that you can retry forever — without a budget, retries amplify the very outage they're responding to.
Chapter 6: Retry Budgets, Idempotency, and Retry-After
From zero — the retry storm. Here's the failure mode backoff + jitter doesn't fix: a dependency is degraded, so every request fails, so every client retries (with backoff and jitter, like a good citizen) — but now the dependency is receiving its normal load plus all the retries. The extra load keeps it degraded, which causes more failures, which causes more retries. The retries have amplified the outage. This is a retry storm, and it's why a brief blip becomes a long incident.
The retry budget — the mechanism. Cap the total retries allowed in a window, as a budget of tokens. Each retry spends a token; when the budget is exhausted, clients stop retrying and fail fast. The budget bounds the amplification: retries can add at most the budget's worth of extra load, no matter how bad things get.
RetryBudget(cap=3):
retry → record() → used=1 (remaining 2) can_retry()=True
retry → record() → used=2 (remaining 1) can_retry()=True
retry → record() → used=3 (remaining 0) can_retry()=True
retry → can_retry()=False → STOP. Fail fast. Do not amplify the outage.
The lab's RetryBudget is exactly this token tracker (can_retry, record, remaining), and
test_retry_budget_exhaustion proves record() raises once the budget is spent. (Real systems
often express the budget as a ratio — "retries may be at most 10% of requests" — but the
token model is the kernel.)
Idempotency — the precondition. A retry is only safe if doing the operation twice has the
same effect as doing it once — that is idempotency. PUT a resource: idempotent (ARM
relies on this, P01). Append a charge to an account: not idempotent — retrying double-
charges. Before you retry anything that mutates state, you need idempotency, usually via an
idempotency key (dedup the duplicate) or a naturally-idempotent operation (upsert, set-to-
value). This is the same exactly-once-effect idea from P10's Service Bus duplicate detection:
at-least-once delivery + idempotent processing = exactly-once effect. No idempotency → retries
corrupt state → don't retry.
Retry-After — let the server tell you. When a server throttles you it returns 429 Too Many Requests (or sometimes 503) with a Retry-After header — the server telling you
exactly how long to wait. Honoring it is strictly better than guessing with backoff, because
the server knows when its quota window resets. The rule: if Retry-After is present, honor
it; otherwise fall back to client-side full-jitter backoff. The lab's retry_after_seconds
implements exactly this — a case-insensitive header lookup that, when present, returns the
header's seconds, and otherwise returns full_jitter(...). test_retry_after_honors_header and
test_retry_after_falls_back_to_jitter pin both paths.
Production significance. A throttled client that ignores Retry-After and retries on its
own schedule fights the throttle, stays throttled, and wastes the dependency's capacity. Azure
services (Storage, Cosmos DB, ARM, APIM, Entra) return 429 + Retry-After under throttling;
honoring it is the difference between a brief slowdown and a sustained 429 storm.
Misconception. "Retries make things more reliable." Only bounded, idempotent, jittered retries do. Unbounded retries make things less reliable by amplifying outages; non- idempotent retries make things incorrect by duplicating effects. Retry is a sharp tool with three safety catches — budget, jitter, idempotency — and you need all three.
Chapter 7: The Circuit Breaker State Machine
From zero. Even with backoff, jitter, and a budget, there's a failure mode left: a dependency that's fully down for a while. Every request still tries it, waits for a timeout (slow!), fails, and the failures pile up — consuming threads, connections, and latency budget on a dependency that cannot succeed right now. A circuit breaker detects this and fails fast: it stops sending requests to a dead dependency until there's reason to believe it recovered.
The mechanism — three states. It's a small state machine (the electrical-breaker analogy: trip on overload, reset after a cooldown):
failure_threshold consecutive failures
┌────────┐ ───────────────────────────────────► ┌──────┐
│ CLOSED │ │ OPEN │
│ (pass, │ ◄─────────────────────────────────────│(fail │
│ count) │ on_success in HALF_OPEN │ fast)│
└────────┘ └──────┘
▲ │
│ probe succeeds │ reset_timeout elapses
│ ▼
│ ┌───────────┐
└──────────────────────────────────────── │ HALF_OPEN │
probe fails → back to OPEN │ (1 probe)│
└───────────┘
- Closed (normal): requests pass; consecutive failures are counted. At
failure_thresholdconsecutive failures, trip → Open. (A success resets the count — the threshold is about a sustained failure, not occasional blips.) - Open (tripped): requests are rejected immediately — fail fast, don't even try. This
protects the dependency (no load) and the caller (no waiting on timeouts). After
reset_timeoutelapses, the next request is allowed as a probe and the state moves to Half-Open. - Half-Open (probing): exactly one trial request is allowed through. If it succeeds, the dependency is back → Closed. If it fails, it's still broken → Open (and the reset timer restarts). Half-Open is what prevents a stampede the instant the timeout expires: one probe decides, not the whole fleet.
The lab's CircuitBreaker implements this precisely with an injected now (so it's
deterministic — no wall clock). The tests walk every edge: closed → open on threshold,
open → half_open exactly at the reset boundary, half_open → closed on a successful probe,
and half_open → open (with the timer restarting) on a failed probe.
Production significance. A breaker turns "the dependency is down and now we're down because we're all blocked waiting on it" into "the dependency is down, we fail fast, shed to a fallback or dead-letter, and probe for recovery." It's the bulkhead that keeps one sick dependency from sinking the caller — combine it with a timeout (so a slow call doesn't hang) and a fallback (cache, default, dead-letter) and you have the resilience triad.
Misconception. "A breaker retries for me." No — a breaker is the opposite of a retry: a retry says "try again," a breaker says "stop trying for a while." They're complementary — retry handles transient failures (a blip), the breaker handles sustained ones (a dependency that's down) — but conflating them is a classic error. Another: that Half-Open lets everyone through. It lets exactly one probe through; the whole point is to test recovery without re-stampeding.
Chapter 8: Throttling, Backpressure, and the Triage Method
From zero. Throttling is a server deliberately rejecting requests (with 429) to
protect itself from overload — it's a feature, not a bug. Backpressure is the more general
idea: when a downstream stage can't keep up, that slowness propagates upstream as a signal to
slow down (a full queue, a blocked write, a 429). Both are the system telling you "too much,
slow down" — and the right response is to obey (back off, honor Retry-After, shed load),
not to push harder.
The triage method — from signature to cause. When something breaks, the principal doesn't
guess; they read the failure signature — the HTTP status and the symptom — and walk a
decision tree to a likely cause and a first action. This is the muscle memory the lab's
diagnose encodes. The common Azure signatures:
| Signature | Likely cause | First action |
|---|---|---|
429 | throttling / rate limit / quota | honor Retry-After, back off + jitter, spread load, check service & subscription limits |
403 | authz — RBAC / data-plane role / Key Vault / deny | check the role and scope, the data-plane role (Storage Blob Data Reader, not just Reader), Key Vault access policy/RBAC, deny assignments, Conditional Access |
401 | authn — bad/expired/missing token | check iss/aud/exp/scp claims, the Managed Identity token acquisition, clock skew |
404 | not found / routing / wrong instance | check URL/path, resource id, API version, whether a Private Endpoint resolves you to the wrong instance |
503 | dependency unavailable | circuit-break, fail fast, check its health/SLA and your composite SLA, shed load |
502 / 504 | gateway / upstream | check backend health probes, the pool, upstream timeouts (Front Door/APIM couldn't reach a healthy backend) |
| symptom: name resolution failed | DNS / Private Endpoint | check the Private DNS zone, the zone-to-VNet link, and that the A record points at the Private Endpoint NIC, not the public IP |
| symptom: timeout | dependency slow/stuck | circuit-break, check dependency health/SLA and connection pool, ensure retries use backoff + jitter |
| symptom: connection refused | connectivity | NSG/firewall, Private Endpoint, listener/port, effective routes |
The ordering matters. The lab checks symptom first — a DNS error is a network problem
regardless of any HTTP status that follows it, so {"http_status": 503, "symptom": "name resolution failed"} diagnoses as DNS, not as a 503. test_diagnose_symptom_beats_status
pins exactly this precedence.
Production significance. Two of these are the most common Azure incidents in this curriculum's world:
- The
403from a Function calling Storage. The Function has Reader on the storage account (a control-plane role) but not a data-plane role (Storage Blob Data Reader), so it can see the resource but not the bytes. Control plane vs data plane (P00) is the whole answer — anddiagnose({"http_status": 403})points you straight at the data-plane role. - The DNS error after a Private Endpoint. Adding a Private Endpoint (P06) changes name
resolution: the resource's public FQDN must now resolve to the private IP via a Private
DNS zone linked to the VNet. If the zone isn't linked, or the A record is wrong, you get
"name resolution failed" — and
diagnosesends you to the Private DNS zone link.
Misconception. "Throttling means scale up." Sometimes — but often it means you're hitting a
quota and the fix is to honor Retry-After, spread load, or request a limit increase, not to
throw more clients at a service that's already saying "too many." And "a 403 is an
authentication problem" — no, 403 is authz (you're authenticated but not permitted);
401 is authn. Mixing them up sends you debugging the wrong layer.
Lab Walkthrough Guidance
Lab 01 — Resiliency Engine, suggested order (matches the file top-to-bottom):
- SLA math (Ch. 3–4) —
_validate_avail(reject outside[0,1]and NaN), thenserial_sla(the product ∏),redundant_sla(1 − ∏(1 − a)),composite_sla(collapse each list-valued hop withredundant_sla, then serial-multiply),downtime_minutes_per_month((1 − A) × minutes), andn_plus_k_sla(sum the binomial terms withmath.comb). Pin the exact numbers: three 0.999 ≈0.997002999; two 0.99 redundant =0.9999; 2-of-3 @ 0.9 =0.972; three nines =43.2min. - Backoff & jitter (Ch. 5) —
backoff_delay=min(cap, base · 2^attempt);full_jitter=rng.uniform(0, backoff_delay(...))with the injected, seeded rng (test the bounds deterministically). ThenRetryBudget(can_retry/record/remaining, raise on overspend) andretry_after_seconds(case-insensitiveRetry-After→ honor it; elsefull_jitter). - Circuit breaker (Ch. 7) — the
CircuitBreakerstate machine with injectednow:allow_request(Closed→pass, Open→reject untilreset_timeoutthen →Half-Open + allow the probe, Half-Open→pass),on_success(clear failures, →Closed),on_failure(Half-Open→Open immediately; Closed→count, trip at threshold). Walk every transition including reopen-on- half-open-failure with a restarted timer. - Triage tree (Ch. 8) —
diagnose: symptom-first (DNS, timeout, connection refused, TLS), then status codes (429/403/401/404/503/502/504/other-5xx), then the unknown fallback. Validate the input is a mapping.
Run it red, make it green: pytest test_lab.py -v, then LAB_MODULE=solution pytest test_lab.py -v (40 tests), then python solution.py for the worked SLA chain, redundancy, N+k, the
backoff/jitter sequence, the budget exhausting, the breaker tripping and recovering, and a few
diagnose calls.
Success Criteria
You are ready for Phase 15 (the capstone) when you can, from memory:
- State the downtime budget for two/three/four nines over a month (432 min / 43.2 min / 4.32 min) and explain it as an error budget you spend, not a wall you defend.
- Define an availability zone vs a region pair, and say when you reach for zone redundancy vs multi-region — with the cost each adds.
- Compose a serial chain's SLA (
∏ aᵢ), explain why it's below every hop, and identify which hop to make redundant first. - Compose redundant replicas (
1 − ∏(1 − a)), show two 0.99 → 0.9999, and explain why same- zone replicas break the independence the formula assumes. - Compute an N+1 cluster's availability with the binomial (2-of-3 @ 0.99) and state the need- all and need-one boundary identities.
- State the three safety catches on a retry — backoff, jitter, budget — and why
idempotency is the precondition, and when to honor
Retry-After. - Draw the circuit-breaker state machine and name what each of the three states prevents.
- Take a failure signature (
429/403/503/DNS) and give the likely cause and first action, and explain why symptom beats status.
Interview Q&A
Q: Compose the SLA of Front Door → APIM → Function → SQL, each ~99.95–99.99%, and tell me
the monthly downtime.
It's a serial chain — every hop must be up — so the composite is the product of the hops:
roughly 0.9995 × 0.9995 × 0.9995 × 0.9999 ≈ 0.9984. That's below every hop (the "more
hops, lower SLA" law), about 99.84%, or ~69 minutes of downtime a month. If the target
is 99.9% (43 min), the chain is already over budget on paper, so I'd make the weakest single-
zone hop zone-redundant — redundant replicas combine as 1 − ∏(1 − a), which drives that
hop's availability up and lifts the whole product. Adding a nine to the weakest hop moves the
composite the most.
Q: Why does redundancy raise availability so fast — two 0.99 replicas to four nines?
Because a redundant group is down only when every replica is down, and (if failures are
independent) the unavailabilities multiply: 1 − (1 − 0.99)(1 − 0.99) = 1 − 0.01² = 0.9999.
Each independent replica roughly doubles the nines. The catch is independent — if both
replicas sit in the same availability zone, a zone outage takes both and the formula's
independence assumption is false. So I place replicas across different zones to keep the
failures independent; redundancy without independent failure domains is theater.
Q: A retry — what makes it safe, and how can it make things worse?
Three safety catches plus a precondition. Backoff (min(cap, base · 2^attempt)) spaces
retries so I don't hammer a recovering dependency. Jitter (uniform(0, backoff)) de-
synchronizes a fleet that failed together — without it they retry in lockstep and re-create the
overload (thundering herd). A retry budget caps total retries so they can't amplify the
outage into a retry storm. And the precondition is idempotency — retrying a non-idempotent
mutation double-applies it; I need an idempotency key or a naturally-idempotent operation first.
Get any of these wrong and the retries either cause the outage or corrupt state. And if the
server sends Retry-After (on a 429), I honor it instead of guessing — it knows when its
quota resets.
Q: Walk me through a circuit breaker's three states and what each prevents. Closed is normal: requests pass and I count consecutive failures; at the failure threshold I trip to Open. Open rejects requests immediately — fail fast — which prevents both piling up on timeouts on my side and hammering a dependency that's already down; after a reset timeout I allow one probe and move to Half-Open. Half-Open lets exactly one trial request through: success → Closed (recovered), failure → Open (still broken, restart the timer). The single probe is what prevents the whole fleet stampeding the instant the timeout expires. A breaker is the opposite of a retry — retry handles a transient blip, the breaker handles a sustained outage — and I pair it with a timeout and a fallback.
Q: A Function returns 403 calling Blob Storage. What's your first check?
403 is authz, not authn — the call is authenticated but not permitted. The classic cause
is a control-plane vs data-plane role mismatch: the Function's identity has Reader on the
storage account (control plane — it can see the resource) but not a data-plane role like
Storage Blob Data Reader (it can't read the bytes). So I check the data-plane RBAC
assignment first, then the scope, then any deny assignment or Conditional Access. If it were a
429 I'd think throttling and honor Retry-After; a 503, dependency-unavailable and circuit-
break; a "name resolution failed", Private DNS / Private Endpoint.
Q: After adding a Private Endpoint, clients get "name resolution failed." Why?
A Private Endpoint gives the resource a private IP in your VNet, but clients still use its
public FQDN — which by default resolves to the public IP. The fix is a Private DNS zone
(e.g. privatelink.blob.core.windows.net) with an A record pointing the FQDN at the Private
Endpoint's NIC, linked to the VNet so its resolver uses it. "Name resolution failed" almost
always means the zone isn't linked, the link's missing the VNet, or the A record is wrong/
pointing at the public IP. That's why my triage checks the symptom (DNS) before any HTTP
status — it's a network problem regardless of what status follows.
Q: How do you turn an SLO into an alert?
From the SLO I get the error budget: 99.9% over a month is (1 − 0.999) × 43200 ≈ 43.2
minutes. I don't alert on every failed request — I alert on the burn rate of the budget
(a multi-window, multi-burn-rate alert: page if I'd exhaust the month's budget in an hour at the
current rate, ticket if it's a slow burn). That's SRE error-budget thinking on top of the
downtime arithmetic — and it's why the downtime math in this lab is the foundation for the alert
rule in P13.
References
- Azure availability zones & regions (physical separation; zone-redundant vs zonal; region pairs) — https://learn.microsoft.com/azure/reliability/availability-zones-overview and https://learn.microsoft.com/azure/reliability/cross-region-replication-azure
- Azure Well-Architected Framework — Reliability pillar (the design principles and the SLO/ redundancy/failure-mode guidance) — https://learn.microsoft.com/azure/well-architected/reliability/
- Composite SLA / multiplying SLAs across a dependency chain (the serial-product math, with the Front Door → App → DB worked example) — https://learn.microsoft.com/azure/well-architected/reliability/metrics and https://learn.microsoft.com/azure/architecture/framework/resiliency/business-metrics
- Retry pattern and Circuit Breaker pattern (Azure Architecture Center / cloud design patterns) — https://learn.microsoft.com/azure/architecture/patterns/retry and https://learn.microsoft.com/azure/architecture/patterns/circuit-breaker
- Transient fault handling (backoff, jitter, idempotency, honoring
Retry-After) — https://learn.microsoft.com/azure/architecture/best-practices/transient-faults - Marc Brooker / Amazon — "Exponential Backoff and Jitter" (why full jitter beats backoff alone) — https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/
- Michael Nygard, Release It! (the circuit breaker, bulkhead, and timeout patterns — the origin of this whole vocabulary).
- Google SRE Book — Service Level Objectives and Handling Overload (error budgets, burn-rate alerting, throttling and load shedding) — https://sre.google/sre-book/service-level-objectives/ and https://sre.google/sre-book/handling-overload/
- RFC 9110 §10.2.3 / §15.5.x — HTTP semantics for
Retry-After,429,403,503— https://www.rfc-editor.org/rfc/rfc9110. - The track's own CHEATSHEET.md and GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 14: Reliability, HA & Operational Troubleshooting
Read this if: you can quote a 99.9% SLA but couldn't tell me what it buys (43 minutes a month), whether a four-hop chain can even deliver it, or why a retry without jitter and a budget is a loaded gun. This is the compressed tour the WARMUP derives slowly. Skim it, read the WARMUP, then build the engine.
0. The 30-second mental model
Reliability is arithmetic you do before the design review, plus a method for the 2 a.m.
incident. Serial hops multiply (∏ aᵢ — more hops, lower SLA, below every hop); redundant
replicas combine the inverse (1 − ∏(1 − aᵢ) — more replicas, higher SLA); N+k is a
binomial. Make retries safe with backoff + jitter + a budget (and idempotency as the
precondition), honor Retry-After, and put a circuit breaker in front of a flaky
dependency. When it breaks, read the signature (429? 403? DNS?) and walk the decision
tree to a cause. One sentence to tattoo: availability is a product across the path, downtime
is a budget you spend, and a retry is a sharp tool with three safety catches.
1. SLA composition, one breath
serial chain (all must be up): A = ∏ aᵢ → BELOW every hop
redundant replicas (any up): A = 1 − ∏(1 − aᵢ) → ABOVE the best replica
N+k (≥ n of n+k up): A = Σ C(N,i)·pⁱ·(1−p)^(N−i), i = n..N (binomial)
downtime budget: (1 − A) × window
composite chain of redundant hops: collapse each hop with redundant, then multiply
The principal move: compose the chain, find the weakest single-zone hop, make it zone- redundant, recompute. Adding a nine to the weakest hop moves the composite the most.
2. The downtime budget table (memorize it)
| Availability | Nines | Downtime / month (43,200 min) |
|---|---|---|
| 0.99 | two | 432 min (7.2 h) |
| 0.999 | three | 43.2 min |
| 0.9999 | four | 4.32 min |
| 0.99999 | five | ~26 s |
Front Door 0.9995 × APIM 0.9995 × Function 0.9995 × SQL 0.9999 ≈ 0.9984 → ~69 min/month,
below every hop. Three 0.999 in series ≈ 0.997 (barely 2.5 nines).
3. The numbers to tattoo on your arm
| Thing | Number / rule |
|---|---|
| Two 0.99 replicas | 0.9999 (each independent replica ~doubles the nines) |
| Three 0.99 replicas | 0.999999 |
| N+1 (2 of 3 @ 0.99) | 0.999702 |
| Region has | ≥ 3 availability zones (enabled regions) |
| Inter-zone latency | typically < 2 ms RTT (sync replication is fine) |
| Backoff | min(cap, base · 2^attempt), attempt 0-indexed |
| Full jitter | uniform(0, backoff) — bounds 0 ≤ j ≤ backoff |
| Throttled status | 429 (+ Retry-After); sometimes 503 |
| Retry precondition | idempotency (no idempotency → don't retry mutations) |
| Breaker states | closed → open → half-open → closed (one probe in half-open) |
4. The retry safety catches (all three, or don't retry)
backoff → space retries (min(cap, base·2^n)) [don't hammer a recovering dep]
jitter → uniform(0, backoff) [de-sync the thundering herd]
budget → cap total retries, fail fast when exhausted [don't amplify into a retry storm]
+ idempotency (the precondition) [retry twice == once, or corruption]
+ honor Retry-After on 429/503 [the server told you; obey it]
Backoff alone keeps the herd synchronized. Jitter breaks the sync. The budget bounds the amplification. Skip any and your retries cause the outage (or duplicate the side effect).
5. The circuit breaker in one picture
CLOSED pass requests, count consecutive failures
│ failure_threshold consecutive failures
▼
OPEN reject immediately (FAIL FAST) — protects dep AND caller
│ reset_timeout elapses → allow ONE probe
▼
HALF_OPEN one trial request: success → CLOSED (recovered)
failure → OPEN (still broken, restart timer)
A breaker is the opposite of a retry: retry = "try again" (transient blip); breaker = "STOP trying for a while" (sustained outage). Pair it with a timeout and a fallback (cache / dead- letter / default).
6. The triage decision tree (signature → cause → first action)
symptom: name resolution failed → DNS/Private Endpoint: Private DNS zone, VNet link, A record → PE NIC (not public IP)
symptom: timeout → dependency slow: circuit-break, check health/SLA + pool, ensure backoff+jitter
symptom: connection refused → connectivity: NSG/firewall, Private Endpoint, listener/port, effective routes
429 → throttling: honor Retry-After, back off + jitter, spread load, check limits/quota
403 → authz: RBAC role + SCOPE, DATA-PLANE role (Blob Data Reader ≠ Reader), Key Vault, deny assignment, Conditional Access
401 → authn: token iss/aud/exp/scp, Managed Identity acquisition, clock skew
404 → routing: URL/path/resource-id/API-version, or a Private Endpoint resolving you to the wrong instance
503 → dependency unavailable: circuit-break, fail fast, check health/SLA + composite SLA, shed load
502/504 → gateway/upstream: backend health probes, pool, upstream timeouts
Symptom beats status: a DNS error is a network problem regardless of any HTTP status after it.
7. az one-liners you'll actually type
# Which zones does a region expose? (place zonal resources deliberately)
az vm list-skus --location eastus --query "[?contains(locationInfo[0].zones, '1')].name" -o tsv | head
# Create a ZONE-REDUNDANT public IP + Standard LB (survives a datacenter failure)
az network public-ip create -g rg -n pip --sku Standard --zone 1 2 3
az network lb create -g rg -n lb --sku Standard # Standard LB is zone-redundant by default
# Zone-redundant App Service / Function plan
az appservice plan create -g rg -n plan --sku P1V3 --zone-redundant --number-of-workers 3
# Zone-redundant Azure SQL
az sql db update -g rg -s server -n db --zone-redundant true
# A region's PAIR (your DR target)
az account list-locations --query "[?name=='eastus'].metadata.pairedRegion[0].name" -o tsv
# Service Health / Resource Health during an incident
az rest --method get --url "https://management.azure.com/subscriptions/<sub>/providers/Microsoft.ResourceHealth/availabilityStatuses?api-version=2022-10-01"
# Tail a Front Door / APIM health probe + see 429/503 in Log Analytics (KQL, P13)
# AzureDiagnostics | where httpStatusCode_d in (429,503) | summarize count() by bin(TimeGenerated,5m)
# Diagnose a Private Endpoint DNS problem
nslookup mystorage.blob.core.windows.net # should resolve to a 10.x PRIVATE IP, not public
az network private-dns zone list -g rg -o table
az network private-dns link vnet list -g rg -z privatelink.blob.core.windows.net -o table
8. War story shapes you'll relive
- "All our components are 99.9% but we keep missing the SLA." → serial composition makes the system worse than any component. Four 99.9% hops ≈ 99.6%. Make the weakest hop redundant; stop pretending the system inherits a single hop's number.
- "A 2-second blip became a 20-minute outage." → a retry storm. Every client retried with no
jitter and no budget; the synchronized retries kept the dependency overloaded. Add full jitter
- a retry budget + honor
Retry-After.
- a retry budget + honor
- "We're 'redundant' but a single zone outage took us down." → both replicas were in the same availability zone. Redundancy without independent failure domains is theater. Spread across zones.
- "The Function can see the storage account but gets 403 reading blobs." → control-plane role (Reader) without a data-plane role (Storage Blob Data Reader). The P00 control/data plane split is the whole answer.
- "Name resolution failed right after we added a Private Endpoint." → the Private DNS zone isn't linked to the VNet, or the A record points at the public IP. The PE gave it a private IP; DNS still hands out the public one.
- "Half-open let the whole fleet through and re-killed the dependency." → your breaker allowed more than one probe in half-open. It must allow exactly one trial request.
9. Vocabulary that signals you've held the pager
- Availability zone — a physically separate datacenter (own power/cooling/network) in a region.
- Zone-redundant vs zonal — the platform spreads replicas for you vs you pin to one zone.
- Region pair — two regions with sequential updates + recovery priority; your DR target.
- Composite / serial SLA —
∏ aᵢ; the system is below every hop. - Redundant SLA —
1 − ∏(1 − aᵢ); the group is down only if all replicas are down. - N+k / binomial survival — need at least n of n+k nodes; the spare count is k.
- Downtime / error budget —
(1 − A) × window; a budget you spend, not a wall. - Exponential backoff + full jitter —
uniform(0, min(cap, base·2^n)). - Retry budget — a cap on total retries so they can't amplify an outage.
- Retry storm / thundering herd — synchronized retries that re-create the overload.
Retry-After— the server telling you exactly how long to back off (honor it).- Idempotency — twice == once; the precondition for any safe retry.
- Circuit breaker — closed→open→half-open; fail fast, then probe for recovery.
- Throttling / backpressure — the system saying "slow down"; obey, don't push.
- Failure signature — status + symptom; the input to the triage decision tree.
10. Beginner mistakes that mark you in interviews
- Quoting a 99.9% SLA without composing it across the path (the system is below every hop).
- Saying "redundant" without independent failure domains (same-zone replicas aren't redundant).
- Retrying with no jitter (thundering herd) or no budget (retry storm), or retrying a non- idempotent mutation (double effect).
- Ignoring
Retry-Afterand fighting the throttle on your own schedule. - Confusing a circuit breaker with a retry (breaker says stop, retry says again).
- Letting more than one probe through in half-open (re-stampede on recovery).
- Reading a
403as authentication (it's authz) — debugging the wrong layer. - Forgetting the control-plane vs data-plane role split behind a Storage
403. - Calling a Private-Endpoint DNS failure a "Storage outage" instead of a Private DNS zone link.
- Treating the error budget as a wall to defend instead of a budget to spend (no deploys, no velocity, paralysis).
11. How this phase pays off later
- Phase 15 (capstone) — the combined security/SLA/cost scorer uses this SLA composition
math directly; the platform you score is the
Front Door → APIM → Function → DBchain. - Phase 13 (Monitor/KQL) — the downtime/error-budget math here is the foundation for burn-
rate alert rules; you see the
429/503/DNS signatures in Log Analytics. - Phase 09 (APIM) and Phase 10 (Service Bus) —
429rate limiting and message throttling are whereRetry-After, backoff, and dead-letter (the breaker's fallback) actually live. - Phase 06 (networking) — half the failure signatures (DNS, Private Endpoint, connection refused) are network problems this phase teaches you to triage.
- Phase 00 (control/data plane) — the
403-data-plane-role and the "quantify the tradeoff" habit are this phase applied under a live incident.
Now read the WARMUP slowly, then build the resiliency engine. After this, "make it reliable" stops being a vibe and becomes a number you can compute and a tree you can walk.
👨🏻 Brother Talk — Phase 14, Off the Record
No slides, no STAR method. This is me, your brother, telling you the things people only say after the second coffee. Read it once now, and again the week before your interview — and again the night before your first real on-call shift.
Listen. This is the phase where you stop being someone who builds things and start being someone who can be trusted with things. Because here's the truth nobody puts on a job description: anyone can stand up a service that works on a good day. The reason principals get paid is the bad day — the 2 a.m. page, the cascading failure, the "everything is red and the CEO is in the channel" moment. And what separates the person who fixes it from the person who makes it worse is not raw intelligence. It's whether they have the arithmetic and the method in their bones, or whether they're improvising under stress. This phase is those two things. Burn them in.
Let me start with the single most useful party trick you'll ever learn, because it changes how
people see you in one meeting. Someone says "we have a 99.9% SLA." You say, calmly: "that's
forty-three minutes a month — and our chain has four hops, so the system is more like 99.84,
which is sixty-nine minutes. We're already over budget on paper. Which hop do we make redundant
first?" Watch the room. You just did, out loud, in five seconds, the thing everyone else
assumed was true and nobody actually computed. That's not magic — it's ∏ aᵢ and (1 − A) × 43200. But the effect is that you become the person who knows whether the promise is real.
The whole game in this phase is turning vague reliability talk into specific numbers, because
numbers are where calm comes from, and calm is what they're really hiring at the principal
level.
Now the retry stuff, because this is where good engineers blow up production trying to help.
Here's the thing that took me a real incident to internalize: a retry is not a safety
feature; it's a loaded gun pointed at your dependency. The intuition "if it failed, try again"
is right for one client and catastrophic for a thousand. A dependency blips for two seconds;
every client dutifully retries; and because they all failed at the same instant, they all retry
at the same instant, and now the dependency is getting its normal load plus a synchronized
retry wave, which keeps it down, which causes more retries. You didn't recover from the outage —
you fed it. The fix is three things and you need all three: jitter (so the herd doesn't
move in lockstep), a budget (so the retries can't pile up without limit), and
idempotency (so retrying doesn't double-charge somebody's credit card). And when the server
hands you a Retry-After, honor it — it's literally telling you when to come back, and
arguing with it just keeps you throttled. Most outages I've watched go from "blip" to "incident"
were a retry storm. Be the person who knows why.
Here's a mental model that'll serve you forever: a retry and a circuit breaker are opposites, and you need both. A retry says "try again" — it's for a transient failure, a blip, a thing that'll work the second time. A circuit breaker says "stop trying for a while" — it's for a sustained failure, a dependency that's actually down, where trying again just wastes a timeout and piles up. The junior reaches for retry for everything and turns a dead dependency into a self-inflicted DoS. The principal knows: blip → retry (with the three catches); outage → break the circuit, fail fast, fall back, and probe once for recovery. When you can say that distinction crisply in an interview — "retry is for transient, the breaker is for sustained, here's why each state of the breaker exists" — you've shown them you've actually held the pager, not just read the docs.
Let me give you the two incidents you will have, so they're not a surprise.
The 403 that isn't an auth problem. Your Function can see the storage account but gets
403 reading a blob. You'll burn an hour checking the token, the Managed Identity, the network —
all fine. The answer is the control-plane vs data-plane split from Phase 00: the identity has
Reader (it can see the resource exists) but not Storage Blob Data Reader (it can't read the
bytes). Two completely different RBAC worlds, and Azure's role names are designed to trick you.
Once you've been bitten by this, 403 on a data operation makes you instantly check the
data-plane role first. That instinct is worth a hundred docs.
The DNS failure that isn't a Storage outage. You add a Private Endpoint — good, secure, the right call — and suddenly "name resolution failed." You'll think Storage is down. It isn't. The Private Endpoint gave the resource a private IP, but DNS is still handing out the public one, because the Private DNS zone isn't linked to your VNet or the A record is wrong. This one fools entire teams. The tell is in the symptom — "name resolution failed" — which is why your triage checks the symptom before the status code. A DNS error is a network problem no matter what HTTP status comes after it.
And this is the deepest thing in the phase, so sit with it: triage is a decision tree, not an
inspiration. Under stress your IQ drops, your working memory shrinks, and the worst thing you
can do is "think hard." The best operators don't think harder at 2 a.m. — they think less,
because they've pre-compiled the tree. Signature 429? Throttling, honor Retry-After, check
limits. 403? Authz, data-plane role. DNS error? Private DNS zone link. 503? Dependency down,
circuit-break. You're not being clever; you're executing a runbook you wrote when you were
calm. That's why we build the diagnose function in the lab — to force the tree out of your gut
and into something explicit you can trust when your gut is panicking. The calm senior in the
incident channel isn't smarter than you. They just already wrote the tree.
One honest thing about the error budget, because the framing flips a career. Reliability beginners treat 100% as the goal and every minute of downtime as a personal failure — and that mindset paralyzes a team, because the only way to never go down is to never change anything. The principal framing is the opposite: that 43 minutes a month is a budget you get to spend on deploys, experiments, and yes, the occasional incident. If you're not spending your error budget, you're being too cautious and shipping too slowly. This is the sentence that marks you as someone who actually understands SRE and not just uptime: "we're under-spending our error budget, so we can afford to move faster here." Say that in a review and watch a director's eyebrow go up. You just reframed reliability from a fear into a resource.
Here's the career angle, brother to brother. Every other phase made you someone who can build a piece of the platform. This phase makes you the person they call when it breaks — and that person is different. That person gets trusted with the production keys, gets put on the escalation path, gets pulled into the architecture review specifically because they'll ask "and what's the composite SLA of that?" before anyone ships. The reliability person is the adult in the room. Not because they know more services, but because when everyone else is reacting, they have a number and a tree. Calm is a skill, and it's built from exactly the arithmetic and the method in this phase. You're not born with it. You compute your way into it.
Go build the resiliency engine. Compose a four-hop SLA and feel it drop below every hop. Watch
the retry budget exhaust and stop the storm. Trip the breaker and recover it through half-open.
Run a 403 and a DNS error through the tree. Then the next time the channel lights up red,
you'll be the one typing the number while everyone else is typing "any ideas?"
Then come find me in Phase 15 — that's the capstone, where everything you've built across the whole track gets composed into one platform and scored on security, SLA, and cost at the same time. This phase's arithmetic is half of that score.
— your brother 👨🏻
Lab 01 — Resiliency Engine
Phase: 14 — Reliability, HA & Operational Troubleshooting | Difficulty: ⭐⭐⭐☆☆ | Time: 3–5 hours
"We have a 99.9% SLA" is a sentence most engineers say and few can defend. Pull the cover off and reliability is two things: arithmetic you do before the design review, and a method you run during the incident. The arithmetic — serial hops multiply (
∏ aᵢ, so a four-hop chain lands below every hop), redundant replicas combine the inverse (1 − ∏(1 − aᵢ), so two 0.99 replicas make 0.9999), and N+k is a binomial survival probability — tells you whether your design can even deliver the number. The method — make retries safe with backoff + jitter under a retry budget, honorRetry-After, put a circuit breaker in front of a flaky dependency, and walk a decision tree from failure signature to likely cause — is what keeps you calm at 2 a.m. This lab builds all of it: the SLA composer, the retry primitives, the breaker state machine, and the triage tree.
What you build
- SLA composition —
serial_sla(avails)(the product∏ aᵢ),redundant_sla(avails)(1 − ∏(1 − aᵢ)),composite_sla(components)where each hop may be a single availability or a nested set of redundant replicas (collapse withredundant_sla, then serial- multiply),downtime_minutes_per_month(avail)((1 − A) × 43200), andn_plus_k_sla(p, n_required, total_nodes)— the binomial probability that at leastn_requiredoftotal_nodesare up. All validate availabilities into[0, 1]. - Retry backoff & jitter —
backoff_delay(attempt, base, cap)=min(cap, base · 2^attempt);full_jitter(attempt, base, cap, rng)=rng.uniform(0, backoff_delay(...))with an injected, seededrng(so the bounds are testable deterministically); a token-styleRetryBudget(cap)(can_retry()/record()/remaining()) that stops retries when the budget is exhausted; andretry_after_seconds(headers, ...)that honors aRetry-Afterheader when present and falls back to full-jitter backoff otherwise. - The circuit breaker —
CircuitBreaker(failure_threshold, reset_timeout)with statesclosed/open/half_open, driven byon_success(),on_failure(now), andallow_request(now)— all with injectednowso the machine is deterministic. Closed → Open afterfailure_thresholdconsecutive failures; Open fails fast untilreset_timeoutelapses, then → Half-Open (one probe); a Half-Open success → Closed, a failure → Open. - The triage decision tree —
diagnose(signature)maps a failure signature (anhttp_statusand/or asymptom) to a likely cause and a first action:429→ throttling,403→ authz/RBAC/data-plane role,503→ dependency-unavailable/circuit-break, a DNS symptom → Private DNS/Endpoint, and more — with symptom checked before status.
Key concepts
| Concept | What to understand |
|---|---|
| Serial SLA | A = ∏ aᵢ; every hop must be up, so the composite is below every hop (more hops, lower SLA) |
| Redundant SLA | A = 1 − ∏(1 − aᵢ); the group is down only if all replicas are down — each independent replica ~doubles the nines |
| Composite chain | a serial chain of hops where each hop may itself be a redundant replica set (collapse, then multiply) |
| Downtime budget | (1 − A) × window; three nines ≈ 43.2 min/month — an error budget you spend |
| N+k binomial | Σ C(N,i)·pⁱ·(1−p)^(N−i) for i = n_required..N; need-all = pⁿ, need-one = redundant SLA |
| Exponential backoff | min(cap, base · 2^attempt); spaces retries, the cap bounds the wait |
| Full jitter | uniform(0, backoff); de-synchronizes the thundering herd — needs a seeded, injected rng |
| Retry budget | a cap on total retries so they can't amplify a blip into a retry storm |
Retry-After | the server telling you exactly how long to back off (on 429/503) — honor it over guessing |
| Idempotency | twice == once; the precondition for any safe retry of a mutation |
| Circuit breaker | closed → open (fail fast) → half-open (one probe) → closed; the opposite of a retry |
| Triage tree | failure signature (status + symptom) → likely cause + first action; symptom beats status |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers |
solution.py | complete reference; python solution.py runs a worked example (4-hop SLA, redundancy, N+k, a backoff/jitter sequence, a budget exhausting, a breaker tripping then recovering through half-open, and a few diagnose calls) |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # worked example
Success criteria
- All 40 tests pass against your implementation.
- You can explain why
test_serial_sla_multiplies_and_is_below_every_hopasserts0.997002999for three 0.999 hops — and why the composite is below every hop (the "more hops, lower SLA" law that breaks "all components are 99.9% so the system is 99.9%"). - You can explain why
test_redundant_sla_raises_availabilitygives0.9999for two 0.99 replicas — and why that only holds if the replicas fail independently (different zones). - You can explain why
test_n_plus_k_binomial_known_valueis0.972for 2-of-3 @ 0.9, and why need-all reduces to the serial product and need-one to the redundant SLA. - You can explain why
test_full_jitter_within_bounds_seededuses a seeded rng — reliability logic that calls un-seededrandomis untestable; the bound0 ≤ jitter ≤ backoffis the whole point and you can only assert it deterministically. - You can explain why
test_retry_budget_exhaustionis the line that stops a retry storm: once the budget is spent, you fail fast instead of amplifying the outage. - You can explain why
test_breaker_half_open_failure_reopensmatters — Half-Open allows exactly one probe, and a failed probe re-opens with the timer restarted (no fleet stampede on recovery). - You can explain why
test_diagnose_symptom_beats_statusexists — a DNS error is a network problem regardless of any HTTP status that follows it.
How this maps to real Azure (Well-Architected Reliability + the resilience patterns)
| The lab | Real Azure |
|---|---|
serial_sla | the composite SLA of a request path — Azure's own guidance multiplies the SLAs of Front Door → App → DB; the system SLA is below every component |
redundant_sla | zone-redundant / multi-instance services — a load balancer in front of N replicas; the group is up unless all replicas are down |
composite_sla | a real chain of redundant hops: zone-redundant Front Door → APIM → Function → zone-redundant SQL |
downtime_minutes_per_month | the downtime budget behind an SLA percentage; the foundation of an SRE error budget and burn-rate alerting (P13) |
n_plus_k_sla | N+1 / N+k capacity redundancy — a scale set or AKS node pool with spare nodes; the binomial is the survival probability |
| availability zone (concept) | a physically separate datacenter (own power/cooling/network) in a region; an enabled region has ≥ 3 |
| region pair (concept) | two regions with sequential updates + recovery priority; your DR target and (for GRS) the geo-replication destination |
backoff_delay / full_jitter | every Azure SDK's retry policy — exponential backoff with jitter (the default transient-fault handling) |
RetryBudget | a retry budget / retry-storm guardrail (Polly's circuit/bulkhead, SDK retry caps) so retries can't amplify an outage |
retry_after_seconds | honoring the Retry-After header Azure services return with 429 (Storage, Cosmos DB, ARM, APIM, Entra throttling) |
CircuitBreaker | the Circuit Breaker cloud design pattern (Polly in .NET, Resilience4j elsewhere) — fail fast, then probe |
diagnose | the operator's triage runbook: 429 → throttle, 403 → RBAC/data-plane role, DNS → Private DNS zone, 503 → dependency-unavailable |
What the miniature leaves out (and why it's fine): real reliability engineering also
involves correlated failures (shared zones, control planes, dependencies) that the simple
independence-assuming products over-estimate, load shedding and admission control, health
probes and automatic failover, chaos testing, RTO/RPO and failover runbooks, and burn-rate
alert windows. None of that changes the kernels this lab isolates — (1) availability
composes as a product across a serial path and an inverse-product across redundancy, (2) a
safe retry is backoff + jitter + a budget + idempotency + honoring Retry-After, (3) a
circuit breaker is a three-state machine that fails fast and probes, and (4) triage is a
decision tree from signature to cause. Those are exactly what interviewers probe and incidents
turn on.
Extensions (build these in your own subscription)
- Correlated failure: extend
composite_slato take a correlation factor between replicas (e.g. same-zone replicas share a failure), so the math stops assuming independence — and watch how much a shared zone costs you in real availability. - Burn-rate alerting: from
downtime_minutes_per_month, compute a multi-window error-budget burn rate and emit a KQL alert rule (P13) that pages on a fast burn and tickets on a slow one — SRE-style multi-burn-rate alerting on top of this lab's downtime math. - Decorrelated jitter: add the AWS "decorrelated jitter" variant
(
sleep = min(cap, uniform(base, prev·3))) alongside full jitter and compare their bounds and spread with a seeded rng. - Ratio-based retry budget: replace the token budget with a ratio budget ("retries may be at most 10% of requests in the last minute"), the form most production systems actually use.
- A real composite SLA: take the platform you've built across the track, look up each
service's published SLA, and compute the real composite — then make the weakest hop
zone-redundant (
az ... --zone-redundant) and recompute the number you'd put in the design doc. - Wire
diagnoseto Azure Monitor (P13): feed realAzureDiagnosticsrows (status codes + exception messages) throughdiagnoseto auto-suggest a first action in an alert's annotation.
Interview / resume
- Talking points: "Compose the SLA of a four-hop chain and tell me the monthly downtime." /
"Why must a retry have jitter and a budget — and why is idempotency the precondition?" /
"Walk me through the circuit breaker's three states and what each prevents." / "You get a
403from a Function calling Storage — what do you check first, and why isn't it authentication?" - Resume bullet: Built a reliability engine — SLA composition (serial-product, redundant
inverse-product, and N+k binomial survival, with monthly downtime budgets), retry with
exponential backoff + full jitter under a token retry budget,
Retry-Afterhonoring, a circuit-breaker state machine (closed/open/half-open with injected time), and a failure-signature triage decision tree — modeling the Well-Architected Reliability pillar and the cloud resilience patterns.
Phase 15 — Principal Architecture, FinOps & Capstone
Difficulty: ⭐⭐⭐⭐⭐ (the synthesis) | Estimated Time: 1–2 weeks (15–25 hours) Prerequisites: every prior phase — this is where they converge
Why This Phase Exists
Fourteen phases each built one engine: ARM's deployment graph (P01), Terraform's plan engine (P02), the OAuth2/OIDC/JWT flows (P03), the RBAC + Policy evaluator (P04), the landing-zone inheritance resolver (P05), the NSG + route engine (P06), the OCI registry + AKS scheduler (P07), the CI/CD DAG + OIDC federation (P08), the API gateway (P09), the Service Bus broker + Event Grid retry (P10), the Functions scale controller + Durable replay (P11), Key Vault envelope encryption + Managed Identity (P12), the KQL engine (P13), and the reliability primitives — SLA composition, retry, circuit breaker (P14).
Each was a mechanism. A senior engineer knows the mechanisms. A principal composes them into a platform and defends the composition with numbers — which is a different skill, and the one this phase builds. The capstone is the moment the fourteen engines stop being fourteen manuals and become one system you can architect, score, and admit deployments into.
Two truths organise this phase, both the natural conclusion of Phase 00's "make correct tradeoffs on one control plane":
- The Well-Architected review is a repeatable method, not an opinion. Given any design, you score it against the five pillars — Reliability, Security, Cost Optimization, Operational Excellence, Performance Efficiency — from the evidence (the composed SLA, the security posture, the bill), name the pillar you trade, and write the ADR. The lab makes the method executable.
- FinOps and DR are arithmetic you do before the review. "Make it cheaper" and "survive a region loss" are not vibes; they are levers with numbers — right-sizing, reservations, scale-to-zero, lifecycle tiers, egress avoidance; and RPO/RTO, active-passive vs active-active, region pairs. A principal quantifies both.
Skip this phase and you have fourteen impressive components and no platform — exactly the failure mode that keeps strong engineers stuck below principal: they can build any one thing and cannot compose, score, and defend the whole.
What "Capstone" Means Here
The capstone is the integration lab: a Platform model that takes a proposed
Deployment and runs compact re-implementations of the prior phases' checks —
identity (P03), authorization (P04), policy (P04), security posture (P12), SLA (P14),
cost (P00) — then admits or rejects it and emits a Well-Architected scorecard and
a generated ADR. It is small by design (each check is a few dozen lines) but real:
the deny-wins ordering, the confused-deputy aud check, the serial-vs-redundant SLA,
the $-shaped bill are all correct. Run by a human, this function is a principal
design review.
Concepts
- The Well-Architected review as a method. The five pillars as a scoring function; how to score a design 0..100 per pillar from evidence; how the same two designs flip winners when the workload's weights change (P00's lesson, now applied to a whole platform); producing the ADR.
- The Architecture Decision Record (ADR). Context (the forces and numbers), Decision (one sentence), Alternatives (the dial each turned wrong for our numbers), Consequences (what to monitor, what would reverse it). The durable, auditable artifact a review produces.
- FinOps — the cost levers, quantified. Right-sizing, reservations / savings plans, autoscale + scale-to-zero, storage lifecycle tiers, egress avoidance, dev/test shutdown — each with the arithmetic that says how much it saves and what it costs in latency or operability.
- Multi-region & DR. Active-passive vs active-active; RPO (data-loss window) and RTO (time-to-recover); Azure region pairs; failover via Front Door health probes; and the cost of each tier of redundancy (the SLA gain vs the doubled bill).
- The reference architecture. The secure event-driven serverless platform that composes the whole track: landing zone (P05) → identity, JWT, RBAC (P03–P04) → private networking (P06) → API gateway (P09) → events, Service Bus / Event Grid (P10) → serverless, Functions / Durable (P11) → Key Vault + Managed Identity (P12) → observability (P13) → reliability (P14). The admission gate is its control point.
Labs
Lab 01 — Platform Capstone: the Admission Gate (flagship, implemented)
| Field | Value |
|---|---|
| Goal | Build the platform admission gate: given a proposed Deployment (identity token, the action + scope it wants, the resource's policy properties, its network exposure, Managed-Identity vs stored secret, an availability chain, a cost model), run the prior phases' checks and admit or reject it — rejecting on invalid token (401), unauthorized (403), or any policy deny (403) — then score it on the five Well-Architected pillars and emit a generated ADR. |
| Concepts | composing identity (P03), RBAC deny-wins (P04), Azure Policy effects (P04), security posture (P12), SLA composition + cost (P00/P14) into one fail-closed decision; the Well-Architected review as a function; the ADR as the artifact. |
| Steps | 1. Deployment/AvailabilityLink/CostLine with validation; 2. check_identity (iss/aud/exp/nbf, no sig); 3. check_authorization (deny-wins RBAC, segment-aware scope); 4. check_policy → violations + has_deny_violation; 5. check_security_posture (rewards MI/private/KV/CMK); 6. composite_sla / monthly_cost / downtime; 7. well_architected_score (five pillars); 8. admit — the gate + scorecard + ADR. |
| How to Test | pytest test_lab.py -v — identity pass/fail (expired, wrong aud, nbf, leeway); authorization deny-wins + scope; policy violations (public PII, no HTTPS) + clean pass + audit; posture rewards MI/private and penalizes secret/public; SLA + cost math; admit rejects on each gate and admits the good case with a populated scorecard + non-empty ADR; well_architected_score returns all five pillars; determinism. |
| Talking Points | "Walk me through what your platform does between a deploy request and a created resource." "Why is the reject a 401 here and a 403 there?" "Compose the SLA of this chain — show me the number, then tell me which pillar to trade." "Where does a stored secret cost you in the score, and why?" |
| Resume bullet | Built an end-to-end Azure platform admission gate composing JWT claim validation, RBAC deny-wins, Azure Policy deny effects, SLA composition, and cost modeling into one fail-closed decision with a Well-Architected scorecard and generated ADR. |
→ Lab folder: lab-01-platform-capstone/
How This Phase Integrates Every Prior Phase
The capstone is the place every engine plugs in. The admission gate is the control-plane request path of Phase 00 — authenticate → authorize → validate (policy) → score — made concrete, with each step a distilled prior phase:
| Phase | Engine built there | How the capstone uses it |
|---|---|---|
| P00 | WAF tradeoff + SLA/cost modeler | the five-pillar scorer; composite_sla, monthly_cost, the ADR habit |
| P01 | ARM deployment graph + idempotent PUT | the request the gate fronts: an idempotent control-plane write |
| P02 | Terraform plan/diff engine | the IaC the deploy request comes from; what-if is the gate's dry run |
| P03 | OAuth2/OIDC/JWT validator | check_identity — iss/aud/exp/nbf, the confused-deputy aud guard |
| P04 | RBAC + Policy evaluator | check_authorization (deny-wins) + check_policy (deny/audit effects) |
| P05 | landing-zone inheritance resolver | the management-group scope the policies + RBAC are assigned at |
| P06 | NSG + route engine, Private Link | the public_network posture flag; private endpoints for PII |
| P07 | OCI registry + AKS scheduler | the container image the serverless/AKS workload deploys |
| P08 | CI/CD DAG + OIDC federation | the pipeline that calls the gate; OIDC = no stored secret |
| P09 | API gateway (JWT scope + rate limit) | the edge that verifies the signature before the gate sees claims |
| P10 | Service Bus broker + Event Grid retry | the async tier in the availability chain; at-least-once + DLQ |
| P11 | Functions scale controller + Durable | the compute the deployment provisions; scale-to-zero (FinOps) |
| P12 | Key Vault + Managed Identity | check_security_posture — MI vs secret, Key Vault, CMK |
| P13 | KQL engine + alert rules | the observability the scorecard's findings feed; DLQ-depth alerts |
| P14 | SLA composition + retry + circuit breaker | composite_sla, the redundant-link math, the reliability pillar |
The integrated scenario the whole track has pointed at (Phase 00 named it) is the
secure event-driven serverless platform: Front Door → APIM → Functions/Durable →
Service Bus/Event Grid → Cosmos via Private Endpoint, secrets in Key Vault via Managed
Identity, all under a landing-zone policy baseline, observed by Azure Monitor, made
reliable by SLA-composed redundancy. The capstone's admit() is the gate that lets a
deployment into that platform only if it clears identity, authorization, and policy
— and scores it so the reviewer can defend the call.
Deliverables Checklist
-
Lab 01 implemented; all tests pass against
solution.pyand yourlab.py. - You can draw the admission path (identity → authz → policy → score) and name the HTTP-shaped status each gate returns on failure (401 / 403 / 403).
- You can compose the reference architecture's SLA chain in your head and say which pillar is weakest and what you'd trade to fix it.
- You can list the FinOps levers and, for each, the number that decides whether it's worth pulling (the staleness cost, the commitment term, the egress GB).
- You can state RPO and RTO for active-passive vs active-active and the cost gap.
- You can write the one-paragraph ADR the gate generates, by hand, for a real design — and say what would reverse the decision.
Guides in This Phase
- HITCHHIKERS-GUIDE.md — the 30-second model of a principal review, the numbers, the war stories; read first.
- WARMUP.md — the full zero-to-principal primer on the review method, FinOps, DR, and the reference architecture; read slowly.
- BROTHER-TALK.md — the candid, off-the-record version: what being the principal in the room actually feels like.
Key Takeaways
- The job is composition: fourteen engines become one platform, and the principal skill is admitting deployments into it correctly and defending the design with numbers.
- The Well-Architected review is a repeatable method, not a taste: score each pillar from evidence, name the trade, write the ADR.
- FinOps and DR are arithmetic — the cost levers and the RPO/RTO tiers each have a number; "cheaper" and "more available" are not free and you quantify the price.
- The gate fails closed. Identity, authorization, and policy are hard rejects in that order; the scorecard is advice. Deny by default is the security posture the whole track defends, and the capstone is where it all comes together.
Warmup Guide — Principal Architecture, FinOps & the Capstone
Zero-to-principal primer for Phase 15: the Well-Architected review as a repeatable method, the Architecture Decision Record that makes a call durable, the FinOps levers with their arithmetic, multi-region and DR design with RPO/RTO, and the secure event-driven serverless reference architecture that composes the entire track. This is the synthesis chapter — every engine you built plugs in here.
Table of Contents
- Chapter 1: The Principal's Job Is Composition
- Chapter 2: The Well-Architected Review as a Method
- Chapter 3: Scoring a Design (Five Pillars, From Evidence)
- Chapter 4: The Architecture Decision Record
- Chapter 5: FinOps — The Cost Levers, Quantified
- Chapter 6: Reliability Math — SLA Composition
- Chapter 7: Multi-Region & Disaster Recovery — RPO, RTO, the Spend
- Chapter 8: The Reference Architecture — The Whole Track in One Diagram
- Chapter 9: The Admission Gate — A Review You Can Run
- Lab Walkthrough Guidance
- Success Criteria
- Interview Q&A
- References
Chapter 1: The Principal's Job Is Composition
From zero. You have spent fourteen phases building engines: a JWT validator, an RBAC evaluator, a Service Bus broker, a Functions scale controller. Each is a unit. A senior engineer is fluent in units. The thing that separates a principal is not a fifteenth, bigger unit — it is the ability to compose the units into a platform, score the composition against conflicting requirements, and defend it in a room full of people whose job is to find the hole.
Why is composition the hard part? Because units don't add — they interact. Three 99.9% services in a request path do not give you 99.9%; they give you 99.7% (the math is in Chapter 6). A Managed Identity is a security win and an operability win and removes a cost (no secret-rotation tooling). A Private Endpoint buys you security and costs you DNS complexity that becomes the 2 a.m. incident. None of that is visible when you look at one unit. The principal sees the seams — exactly the lesson Phase 00 planted, now cashed in.
So this phase is not "learn a new Azure service." It is "learn to hold the whole platform at once, score it, and write down why." The lab is the executable form of that skill: a function that takes a deployment, runs every prior phase's check, and returns admit or reject, with a scorecard and an ADR.
Chapter 2: The Well-Architected Review as a Method
The Azure Well-Architected Framework (WAF) is five pillars:
| Pillar | The question it asks | The engine that answers it |
|---|---|---|
| Reliability | does it stay up, and recover? | SLA composition, retry, circuit breaker (P14) |
| Security | is identity the perimeter, secrets safe, data private? | JWT/RBAC/Policy (P03–P04), Key Vault/MI (P12) |
| Cost Optimization | is every dollar buying something the workload needs? | the bill model + FinOps levers (P00, Ch. 5) |
| Operational Excellence | can a small on-call run, deploy, and debug it? | CI/CD, observability, runbooks (P08, P13) |
| Performance Efficiency | does it meet the latency/throughput SLO at scale? | sizing, autoscale, caching (P09, P11) |
The amateur treats WAF as a checklist to recite. The principal treats it as a method — a repeatable procedure you run on any design:
1. WORKLOAD FACTS → RPS, payload, p99 SLO, availability target, data residency,
compliance regime, team size. (No numbers = no review.)
2. TWO PLANES → control plane (tenant/MG/sub, RBAC, policy, IaC, secrets) AND
data plane (network, compute, events, storage, egress).
3. QUANTIFY → compose the SLA, estimate $/month, size instances/partitions,
set the retry budget.
4. SCORE & TRADE → score each pillar from the evidence; name the pillar you trade
and the number that justifies it.
5. FAIL IT → blast radius, throttling limits, DR/region failover, rollback.
6. WRITE THE ADR → the decision, the alternatives, what would reverse it.
The method is fractal: you run it on the whole platform, and you run it again on each
component. It is also the same loop the lab automates — steps 1–4 are
check_identity/check_authorization/check_policy/well_architected_score, and
step 6 is the generated ADR.
Chapter 3: Scoring a Design (Five Pillars, From Evidence)
"Score it" sounds subjective. The principal move is to make each pillar a function of evidence you already computed, so the score is defensible and reproducible. The lab does exactly this:
- Reliability ← the composed SLA. Convert availability to nines: \( \text{nines} = -\log_{10}(1 - A) \). 99% → 2 nines, 99.9% → 3, 99.99% → 4. Map nines to 0..100 (five nines = 100). A 99.89% serial chain is ~2.96 nines → ~59/100 — and that low score is correct: three nines is a real ceiling, and the reviewer should see it.
- Security ← the posture score. Reward Managed Identity (no secret), private networking, Key Vault, customer-managed keys; penalize public exposure and stored secrets. The combination of public + stored secret is the worst case — one leaked config file becomes a breach.
- Cost ← the bill, against a documented reference. Cheaper is better, but the score makes the tradeoff visible: a multi-region active-active design scores low on cost and high on reliability, and the reviewer chooses with eyes open.
- Operational Excellence ← rewards the choices that reduce what you must run: Managed Identity (no rotation tooling), no stored secret (nothing to leak), Key Vault (centralized audited secrets), private networking (smaller surface to watch).
- Performance ← rewards redundancy (headroom under load) and a private fast-path (no cold public hop).
The teaching point — the same one from Phase 00's tradeoff modeler — is that the same two designs flip winners when the weights change. A payments workload weights reliability and security; an internal batch tool weights cost. "Best architecture" is a category error; "best for these requirements" is engineering. The scorecard makes the weights explicit so the argument is about the workload, not about taste.
Chapter 4: The Architecture Decision Record
A principal's decisions outlive their memory of them and must survive their absence. The ADR is the lightweight artifact that makes a decision reviewable, teachable, and reversible-on-purpose. The template you reuse forever:
# ADR-NNN: <short title>
Status: proposed | accepted | superseded by ADR-MMM
Date: YYYY-MM-DD
## Context
What forces are in play? The workload facts and the numbers (the five pillars).
## Decision
What we chose, in one sentence.
## Alternatives Considered
Each option, and the pillar it traded the wrong way for *our* numbers.
## Consequences
What gets better, what gets worse, what we must now monitor, and what would
make us revisit this.
The discipline the ADR enforces is exactly the review method: name the pillar,
quantify both ends, choose, and write down what would change your mind. An
architecture review (an interview's final round, and your day job) is a room
pressure-testing your ADRs. The lab's admit() generates a one-paragraph ADR on
every decision — admit or reject — so the platform's choices are auditable by
construction. The Consequences line the generator emits ("strongest on security,
weakest on reliability — the pillar to revisit") is the single most important
sentence: it tells the next engineer where the design is fragile.
Chapter 5: FinOps — The Cost Levers, Quantified
FinOps is treating cloud spend as an engineering discipline: every dollar is attributed, and every optimization is a lever with a number. You cannot be the "cost optimization" pillar's owner without the arithmetic. The levers, with the math:
1. Right-sizing. Match instance size to actual utilization. If a workload uses 20%
of a D8s (8 vCPU), it belongs on a D2s (2 vCPU). Saving:
\( \text{save} = (\text{vCPU}{old} - \text{vCPU}{new}) \times \text{hours} \times \text{price} \).
The number that decides it: the p95 utilization, not the average (you size to peak,
not mean, or you die under load).
2. Reservations / Savings Plans. Commit to 1 or 3 years for ~30–60% off on-demand. The decision is a break-even: a 3-year reservation at 55% off pays back if the workload runs more than \( (1 - 0.45) \) of the term — i.e. almost always for steady-state baseline load. The lever's cost is flexibility: you've committed. Rule: reserve the baseline, burst on on-demand/spot.
3. Autoscale + scale-to-zero. Consumption-plan Functions scale to zero when idle — you pay per execution, not per hour. For a spiky workload at 5% duty cycle, that is a ~20× saving over an always-on VM. The cost is cold-start latency; the number that decides it: does the p99 SLO tolerate a ~1–3s cold start? If yes, Consumption; if no, pay for Premium/Flex pre-warming.
4. Storage lifecycle tiers. Hot → Cool → Archive. Hot blob ~\$0.018/GB-month; Archive ~\$0.001/GB-month — ~18× cheaper. A lifecycle policy that moves blobs to Archive after 90 days on a 100 TB cold dataset saves \( 100{,}000 \times (0.018 - 0.001) = \$1{,}700/\text{month} \). The cost: Archive has hours of rehydration latency, so only tier data you won't read soon.
5. Egress avoidance. Inbound is free; egress is ~\$0.087/GB and it ambushes multi-region/active-active designs (every cross-region replication and every user-download is billed). Keep compute next to data; cache at the edge (Front Door); do not chat across regions in the hot path. Egress is the term that dominates a multi-region bill that nobody estimated.
6. Dev/test shutdown. Non-prod that runs 24×7 is paying for 128 idle hours a week. Auto-shutdown nights and weekends: \( 1 - \frac{40}{168} \approx 76% \) saving on dev/test compute. The cheapest lever of all, and the one teams forget.
The principal's habit from Phase 00 carries straight in: find the unused headroom and spend it on cost. Most "expensive" platforms are paying for reliability or freshness no one asked for. Attribute the bill (tag every resource with an owner), make it a map, then pull the lever that moves the dominant term.
Chapter 6: Reliability Math — SLA Composition
Availability composes two ways, and conflating them is a classic interview tell.
Serial dependencies multiply. If a request must traverse every component and any one being down fails the request:
$$ A_{\text{serial}} = \prod_i A_i $$
Three 99.9% services in the path:
$$ 0.999^3 = 0.997002999 \approx 99.7% $$
— roughly three times the downtime of one of them. This is why "add another microservice in the hot path" is never free, and why the reference architecture's serial chain (Front Door → APIM → Functions → Cosmos) lands below its weakest link.
Redundant copies combine. The whole is down only if every copy is down, so the un-availabilities multiply:
$$ A_{\text{redundant}} = 1 - \prod_i (1 - A_i) $$
Two 99% replicas across availability zones:
$$ 1 - (1 - 0.99)^2 = 1 - 0.0001 = 0.9999 = 99.99% $$
— two cheap copies beat one expensive one. This is the math behind availability zones and active-active multi-region.
Nines convert to minutes. The number that makes an SLA real:
$$ \text{downtime}_{\text{min/month}} = (1 - A) \times 43{,}200 $$
| Availability | Downtime / month | What it takes |
|---|---|---|
| 99% (two nines) | ~432 min (~7.2 h) | single instance, best effort |
| 99.9% (three) | ~43 min | zone-aware, health probes |
| 99.99% (four) | ~4.3 min | zone-redundant + fast failover |
| 99.999% (five) | ~26 s | multi-region active-active, automated everything |
Each extra nine is a different architecture, not a config flag — and roughly an
order of magnitude more cost. The lab's composite_sla and
downtime_minutes_per_month are this table, executable.
Chapter 7: Multi-Region & Disaster Recovery — RPO, RTO, the Spend
Two numbers define a DR posture, and a principal states both before designing:
- RPO (Recovery Point Objective) — how much data you can afford to lose, in time. RPO = 0 means synchronous replication (every write acknowledged in two regions before returning); RPO = 5 min means async replication with a 5-minute window of in-flight writes you'd lose on a sudden failure.
- RTO (Recovery Time Objective) — how long you can be down during failover. RTO = seconds means automated health-probe failover (Front Door); RTO = hours means a human runbook and IaC re-provisioning.
The DR tiers, cheapest to most expensive:
| Tier | RPO | RTO | How | Cost shape |
|---|---|---|---|---|
| Backup/restore | hours | hours | restore from geo-redundant backup | cheapest; storage only |
| Active-passive (warm) | minutes | minutes | secondary warm, async geo-replication, Front Door failover | secondary infra + cross-region egress |
| Active-active | ~0 | seconds | both regions serve, sync/quorum replication | ~2× compute + heavy egress + complexity |
The spend climbs with both lower RPO and lower RTO, and the egress term (Chapter 5) dominates the active-active bill. Azure region pairs (e.g. East US / West US) give platform-coordinated updates and, for some services, geo-replication — but pairing is not failover; you still design the failover. And the principal's non-negotiable: test the failover. An untested DR plan is a hope, not a control — run the game day, fail the primary on purpose, measure the real RPO/RTO against the target.
The decision, as always, is a tradeoff with a number: what does an hour of downtime, or a 5-minute data-loss window, cost this business? A payments ledger buys active-active; an internal reporting tool buys backup/restore and sleeps fine.
Chapter 8: The Reference Architecture — The Whole Track in One Diagram
This is the design every phase has been pointing at. It is the canonical secure, event-driven, serverless platform, and the capstone's admission gate is its control point.
┌─────────── CONTROL PLANE (who/what may exist) ───────────┐
│ Landing zone (P05): MG hierarchy, policy baseline │
│ Identity (P03): Entra ID, OAuth2/OIDC, JWT │
│ AuthZ (P04): RBAC (deny wins) + Azure Policy (deny) │
│ IaC (P01/P02) deployed via CI/CD OIDC federation (P08) │
└───────────────────────────┬──────────────────────────────┘
│ (the admission gate: this lab)
internet ▼
│ ┌──────────────────────── DATA PLANE (the request path) ─────────────┐
▼ │ │
┌─────────┐ │ ┌──────────┐ ┌────────────┐ ┌────────────────┐ ┌──────────┐ │
│ Front │──▶│ │ APIM │──▶│ Functions /│──▶│ Service Bus / │──▶│ Cosmos / │ │
│ Door │ │ │ (JWT: │ │ Durable │ │ Event Grid │ │ Storage │ │
│ (WAF, │ │ │ aud/iss/ │ │ (P11) │ │ (P10: peek-lock│ │ via │ │
│ global) │ │ │ scp, │ │ │ │ DLQ, retry) │ │ Private │ │
│ (P06/09)│ │ │ rate-lim)│ │ │ │ │ │ Endpoint │ │
└─────────┘ │ └──────────┘ └─────┬──────┘ └────────────────┘ └────┬─────┘ │
│ │ Managed Identity (P12), no secret │ │
│ ▼ │ │
│ ┌──────────┐ secrets/keys (P12) │ │
│ │ Key Vault│◀──────────────────────────────┘ │
│ │ (RBAC, │ │
│ │ purge │ Observability (P13): App Insights, │
│ │ protect) │ Log Analytics, KQL, alerts │
│ └──────────┘ Reliability (P14): SLA, retry, CB │
└─────────────────────────────────────────────────────────────────────┘
Reading it as a principal:
- Identity is the perimeter. Front Door's WAF and APIM's JWT validation
(
aud/iss/scp) gate every request before compute runs. Compute authenticates to data and Key Vault with a Managed Identity — there is no stored secret anywhere in the picture. - No public PaaS for PII. Cosmos and Key Vault sit behind Private Endpoints;
the landing-zone policy baseline denies public PaaS for classified data — the
exact rule the lab's
check_policyenforces. - Async is at-least-once with a safety net. Service Bus peek-lock + DLQ + Event Grid retry give at-least-once delivery; consumers are idempotent for exactly-once-effect. There is no "exactly-once delivery" claim — a principal never makes one.
- Quantify it. Serial SLA (Ch. 6): Front Door 99.99 × APIM 99.95 × Functions 99.95 × Cosmos 99.99 (zone-redundant) ≈ 99.88% — below a 99.95% target, so you either add regional redundancy on the weak link or relax the SLO. Show the math, then decide — and write the ADR.
Every box is an engine you built. The capstone is where they stop being boxes and become a platform you can admit deployments into.
Chapter 9: The Admission Gate — A Review You Can Run
The lab's admit() is the Well-Architected review method (Ch. 2) made executable. A
deploy request arrives; the gate runs the steps in order and fails closed:
admit(deployment, platform_config):
1. check_identity → 401 if iss/aud/exp invalid (authenticate)
2. check_authorization → 403 if RBAC denies or deny-wins (authorize)
3. check_policy → 403 if any `deny` violation (validate)
4. score → composite_sla, security_posture, monthly_cost,
well_architected_score (advise)
5. ADR → generate the one-paragraph record (record)
The order is not arbitrary: authentication before authorization (you cannot be
"unauthorized" before we know who you are — that is why a bad-token and
unauthorized request returns 401, not 403). The first three steps are gates (hard
reject); the last two are advice (the scorecard and ADR the reviewer reads). A low
security score does not reject — a deny policy does. That distinction — gate vs
advice — is the heart of platform engineering: enforce the non-negotiables as code,
advise on the rest, and let teams self-serve inside the guardrails.
Lab Walkthrough Guidance
Lab 01 — Platform Capstone: the Admission Gate, suggested order (file top-to-bottom):
- Data model —
AvailabilityLink(validate[0,1], non-empty),CostLine(reject negatives),Deployment(validate required fields, normalize tuples). check_identity— iss, then aud (string or list — the confused-deputy guard), thennbf <= now+leeway, thenexp > now-leeway. Fail closed; raise only on malformed args, returnFalseon a merely-bad token.check_authorization— deny-wins first (any in-scope deny matches →False), then default-deny over role assignments. Segment-awarescope_contains(therg/rg2trap) and*-wildcard action matching.check_policy— walk the condition tree (field equals/notEquals/in/exists under allOf/anyOf/not); collect"<effect>: <name>"violations;has_deny_violationis the reject test.check_security_posture— the weighted rules (MI 30, no-secret 25, private 25, Key Vault 10, CMK 10); build the findings; warn on the public+secret combo.- SLA + cost —
composite_sla(redundant within a link, serial across links),downtime_minutes_per_month,monthly_cost. well_architected_score— derive each pillar from the engines; clamp; addoverall.admit— the gate: run 1→2→3 in order, fail closed with the right status, then build the scorecard and generate the ADR. Test determinism.
Success Criteria
You are ready to call yourself a principal-capable architect when you can, from memory:
- Run the WAF review method on a cold design prompt in five steps and produce a stage-by-stage scorecard.
- Compose a serial-and-redundant SLA chain in your head and convert the result to downtime minutes/month (99.9% → 43; 99.99% → 4.3).
- Name the six FinOps levers and, for each, the single number that decides whether to pull it.
- State RPO and RTO for the three DR tiers and which workload buys which.
- Draw the secure event-driven serverless reference architecture from memory, naming the control-plane and data-plane components and which phase built each.
- Explain why the admission gate returns 401-before-403 and why a low security score
is advice but a
denypolicy is a gate. - Write — and defend — the one-paragraph ADR the gate generates.
Interview Q&A
Q: "Design our secure event-driven platform." Where do you start? Not with a service. I start with workload facts — RPS, p99 SLO, availability target, data residency, compliance — because "secure" and "event-driven" are not requirements until they have numbers. Then I split into two planes: control (landing zone, RBAC, policy baseline, IaC + OIDC pipeline, Key Vault) and data (Front Door → APIM → Functions → Service Bus/Event Grid → Cosmos via Private Endpoint). Then I quantify: compose the SLA chain, estimate the monthly bill, set the retry budget. Then I score each pillar and name the one I trade — and write the ADR. The reference architecture is the default; the workload's numbers tell me where to deviate.
Q: Your SLA target is 99.95% and your serial chain composes to 99.88%. What do you do? First, I show the math so everyone sees the gap is real — three nines is the ceiling of a serial chain of four-nines parts. Then I have three honest options: (1) add redundancy on the weakest link (the one contributing most un-availability) — zone or region redundancy turns its term from \(A\) into \(1-(1-A)^2\); (2) remove a serial hop from the request path if one isn't load-bearing; or (3) relax the SLO if the business cost of the gap is lower than the cost of closing it. I pick with the number — what an extra nine costs vs what the downtime costs — and write the ADR. What I do not do is quote 99.95% and hope.
Q: A team wants exactly-once delivery across the whole platform. Your response? "Exactly-once delivery" doesn't exist across a network boundary — what exists is exactly-once-effect: at-least-once delivery (Service Bus retry, Event Grid backoff) plus an idempotent consumer (dedup key, upsert). So "everywhere" is a bigger operability bill than they think, re-earned at every external sink. I'd ask the cost of a duplicate and the cost of a loss per stream, because most don't need it — an analytics event tolerates at-least-once; a balance transfer does not. It's a per-data- product dial, sized by what an error costs, not a platform-wide default that taxes throughput and operability for streams that never needed it.
Q: "Make it cheaper." How do you make that concrete? Attribute first — tag every resource with an owner so the bill is a map, not a number. Then the levers are mechanical and each has a number: right-size to p95 utilization; reserve the steady-state baseline and burst on spot; scale Functions to zero for spiky load (if the p99 SLO tolerates cold start); lifecycle-tier cold storage to Archive (~18× cheaper); kill cross-region egress in the hot path; auto-shutdown non-prod nights and weekends (~76% off dev/test). Crucially I look for unused headroom — reliability or freshness no one asked for — and spend it on cost. Most "expensive" platforms are over-provisioned for a requirement that was never written down.
Q: What is the difference between a senior and a principal architect, in one sentence? A senior builds any one of the components correctly; a principal composes them into a platform, scores the composition against the workload's numbers, defends it in an ADR, and is the person the org trusts to admit what gets deployed into it.
Q: Walk me through what your admission gate does between a deploy request and a
created resource.
It runs the control-plane request path as code, fail-closed, in order: authenticate the
token (iss/aud/exp — 401 if invalid), authorize the action against RBAC with deny-wins
(403 if denied), validate the resource against the policy baseline (403 on any deny
effect — e.g. public PaaS for PII). Only if all three pass does it score the deployment
(composed SLA, security posture, monthly bill, the five pillars) and emit a generated
ADR recording the decision and the pillar to watch. The gates are non-negotiable; the
scorecard is advice. That is platform engineering: enforce the floor as code, advise on
the rest, let teams self-serve inside the guardrails.
References
- Azure Well-Architected Framework — Microsoft Learn,
learn.microsoft.com/azure/well-architected/— the five pillars, the design principles, and the review tooling this phase automates. - Cloud Adoption Framework (CAF) — Microsoft Learn,
learn.microsoft.com/azure/cloud-adoption-framework/— landing zones, governance, and the operating model the platform lives in (P05). - FinOps Foundation —
finops.org— the discipline, the Framework (Inform → Optimize → Operate), and the cost-lever vocabulary in Chapter 5. - Azure Architecture Center —
learn.microsoft.com/azure/architecture/— the reference architectures (the event-driven serverless one mirrors Chapter 8) and the reliability/DR design guides (region pairs, RPO/RTO). - Azure service SLAs —
azure.microsoft.com/support/legal/sla/— the per-service availability numbers you compose in Chapter 6. - Michael Nygard, Documenting Architecture Decisions (2011) — the ADR pattern.
- The track's system-design/README.md (the four worked designs the capstone composes) and CHEATSHEET.md / GLOSSARY.md.
🛸 Hitchhiker's Guide — Phase 15: Principal Architecture, FinOps & Capstone
Read this if: you've built all fourteen engines and "now architect the whole platform" still feels like a different job. It is — it's composition. This is the compressed tour the WARMUP derives slowly. Skim it, then read the WARMUP, then build the gate.
0. The 30-second mental model
You are the person who, when a design lands on the table, runs the same five-step review every time — workload facts → two planes → quantify → score & trade → fail it — and writes the ADR. And you are the gate: the deploy request that wants into your platform clears identity, authorization, and policy or it doesn't ship. One sentence to tattoo: a principal doesn't build a fifteenth engine — they compose the fourteen, score the composition with numbers, and admit what gets deployed.
1. The review, one breath
1. WORKLOAD FACTS (RPS, p99 SLO, availability target, residency, compliance, team size)
2. TWO PLANES (control: MG/RBAC/policy/IaC/secrets · data: net/compute/events/storage)
3. QUANTIFY (compose SLA ∏Aᵢ & 1−∏(1−Aᵢ) · $/month · sizing · retry budget)
4. SCORE & TRADE (five pillars from evidence · name the pillar you trade · the number)
5. FAIL IT (blast radius · throttling · DR failover · rollback)
→ WRITE THE ADR
No numbers in step 1 = no review. Skip step 2's control plane = the #1 panel anti-pattern.
2. The five pillars (score everything on these)
| Pillar | The question | Scored from |
|---|---|---|
| Reliability | stays up & recovers? | composite SLA → nines |
| Security | identity-perimeter, secrets safe, private? | posture (MI/private/KV/CMK) |
| Cost | every dollar buys something needed? | the bill vs reference |
| Operational Excellence | can a small on-call run it? | less to rotate/leak/watch |
| Performance | meets the SLO at scale? | redundancy + private fast-path |
Same two designs flip winners when the weights change. "Best architecture" is a category error.
3. The numbers you'll actually use
| Thing | Number |
|---|---|
| Three serial 99.9% | 99.7% (∏ — ~3× the downtime) |
| Two redundant 99% | 99.99% (1−∏(1−A)) |
| 99.9% downtime | ~43 min/month; 99.99% → ~4.3; 99.999% → ~26 s |
| Nines | \( -\log_{10}(1-A) \): 99%→2, 99.9%→3, 99.99%→4 |
| Internet egress | ~\$0.087/GB (the multi-region ambush) |
| Hot vs Archive blob | ~\$0.018 vs ~\$0.001 /GB-month (~18×) |
| Reservation discount | ~30–60% off (commit the baseline) |
| Spot discount | up to ~90%, 2-min reclaim |
| Dev/test auto-shutdown | ~76% off (nights+weekends) |
| Scale-to-zero | ~20× off a spiky always-on VM (cold-start cost) |
4. The FinOps levers (and the number that decides each)
right-size → p95 utilization (NOT the average)
reservations/savings → break-even on the commitment term
autoscale/scale-to-0 → does p99 SLO tolerate the cold start?
storage lifecycle → when will you next read it? (Archive = hours to rehydrate)
egress avoidance → cross-region GB in the hot path
dev/test shutdown → idle hours per week
The principal habit from P00: find unused headroom, spend it on cost. Most "expensive" platforms over-provision a requirement nobody wrote down.
5. DR in one table (state RPO + RTO before you design)
backup/restore RPO hours RTO hours cheapest, storage only
active-passive RPO min RTO min warm 2nd region, async repl, FD failover
active-active RPO ~0 RTO sec both serve, ~2× compute + heavy egress
RPO = data you can lose. RTO = time you can be down. Test the failover — an untested DR plan is a hope. Region pairing ≠ failover; you still design it.
6. The reference architecture (the whole track)
Front Door (WAF) → APIM (JWT aud/iss/scp, rate-limit) → Functions/Durable
→ Service Bus/Event Grid (peek-lock, DLQ, retry) → Cosmos via Private Endpoint
secrets → Key Vault (RBAC, purge protect) · auth → Managed Identity (NO secret)
under: landing-zone policy baseline · observed by Azure Monitor/KQL · SLA-composed redundancy
Serial SLA ≈ 99.88% — below a 99.95% target. Add redundancy on the weak link or relax the SLO; show the math, then decide. No "exactly-once delivery" claim, ever.
7. The admission gate (what the lab builds)
admit(): identity(401) → authorization(403, deny-wins) → policy(403, deny) → SCORE → ADR
- Auth before authz: bad-token-AND-unauthorized = 401, not 403.
- Gate vs advice: a
denypolicy rejects; a low security score doesn't — it's reviewer advice. - Enforce the floor as code, advise on the rest, let teams self-serve inside the guardrails. That's platform engineering.
8. War story shapes you'll relive
- "Our SLA says 99.95% but we're down 40 min/month." → nobody composed the chain; four-nines parts in series give three nines. Show ∏Aᵢ; add redundancy on the weak link.
- "The bill doubled when we went multi-region." → egress. Nobody estimated the cross-region replication + user-download GB at \$0.087. Cache at the edge, keep compute by data.
- "We failed over and lost two hours of orders." → RPO was async and nobody stated it. The DR plan was never game-day'd.
- "A leaked app-settings file became a breach." → a stored secret + public endpoint. Managed Identity + Private Endpoint would have meant nothing to leak.
- "Owner can't read the blob." → control plane ≠ data plane;
Ownerhas noDataActions. The oldest confusion in the track, still biting in prod.
9. Vocabulary that signals you've run the review
- Composition — units interact, they don't add; the principal sees the seams.
- Gate vs advice — non-negotiables as
deny; everything else scored, not blocked. - Fail closed / deny by default — the security posture of every gate.
- Confused deputy — a token for service B accepted at service A (wrong
aud). - RPO / RTO — data-loss window vs time-to-recover; both, before designing DR.
- Exactly-once-effect — at-least-once + idempotent consumer; the only one that exists.
- Self-service with guardrails — the only scalable answer to the governance dial.
- The ADR — the durable record of a decision and what would reverse it.
10. Beginner mistakes that mark you in the final round
- Designing only the data plane; forgetting identity/RBAC/policy/landing zone.
- Quoting an SLA without composing the dependency chain.
- Claiming "exactly-once delivery" (it doesn't exist across a network boundary).
- "We'll use Kubernetes" with no workload numbers to justify it over Functions/ACA.
- Public PaaS for PII; secrets in app settings instead of Key Vault + Managed Identity.
- No rollback story, no DR test, no cost estimate — and no ADR.
- Treating the security score as a gate (it's advice) or a
denypolicy as advice (it's a gate). Know which is which.
11. How this phase pays off
This is the final interview round and your day job: a room hands you a vague design, and you run the five-step review out loud, put numbers on every pillar, name the trade, and write the ADR — calmly, because you did the arithmetic they were too anxious to do. The lab is that review, executable. Build it, then read BROTHER-TALK and go be the principal in the room.
👨🏻 Brother Talk — Phase 15, Off the Record
No slides, no STAR method. This is me, your brother, telling you what being the principal in the room actually feels like — now that you've built the whole track and you're standing in front of the last door. Read it once now, and again the night before the final round.
Listen. You made it. Fourteen phases, fourteen engines, every one of them tested and green. And here's the thing nobody tells you at the finish line: this phase is the easiest one to build and the hardest one to become. The lab is small — a few hundred lines that wire your old engines together. But the judgment it represents — that's the thing that took me years, and I want to save you some of them.
So let me tell you the truth about what changes when you become the principal.
You stop being the person with the answer and become the person with the method. At senior, your value is knowing things — how Service Bus peek-lock works, how to debug a Private Endpoint DNS failure. At principal, half the time you don't know the answer to the specific question, and it doesn't matter, because you have something better: a repeatable way to get to the answer. Someone asks "should we go multi-region?" and you don't reach for an opinion — you reach for the method. What's the RPO? The RTO? What does an hour of downtime cost? Now the answer derives itself. The method is the WARMUP's five steps, and it's worth more than any fact, because facts expire and the method doesn't.
The scariest moment is when everyone goes quiet and looks at you. It happens in every real architecture review and every final-round interview. The design is on the whiteboard, the room has argued itself in circles, and then it gets quiet, and the faces turn to you, because you're the principal and this is the part that's your job. Here's what I learned: calm comes from arithmetic. When I was junior, that silence terrified me. Now I love it, because I know what to do — I compose the SLA out loud, I put the bill on the board, I name the pillar we're trading and the number that decides it. I'm not smarter than the room. I just did the math they were too anxious to do. That's the whole move. "Peak is 4× average, we're sized to 6×, here's the headroom, here's what sheds load first if I'm wrong" — that sentence is leadership, and it's learnable, and you just spent fifteen phases learning it.
Saying "no" is most of the job, and you have to make it cost you nothing. The
admission gate you're about to build — admit(), the thing that rejects a deploy on a
bad token or a policy violation — that's you, all day. Teams will bring you designs
with public PaaS for PII, with secrets in app settings, with a serial SLA chain that
can't hit its own target. And you have to say no. The junior version of no is a fight
— "you're wrong, do it my way." The principal version is a gate plus an ADR: "this
fails the no-public-PaaS-for-PII policy; here's the one-line reason; here's what passes
instead." You're not the bottleneck saying no — you're the guardrail that makes the
safe path the easy path. That reframe is everything. Enforce the floor as code,
advise on the rest, and let teams self-serve inside the guardrails. Do that and
you're not the cop, you're the reason the org ships fast and safely.
Write the ADR even when nobody asks. Especially then. I know I told you this in Phase 00. I'm telling you again because it's the single highest-leverage habit in this whole role and people still skip it. When you make a real call — admit this, reject that, single-region not multi — write the one-paragraph record: context, decision, alternatives, what would change my mind. Six months later when someone reopens it, you don't defend it from memory and ego — you point at the doc: "here were the numbers; have they changed?" You become the person whose decisions are legible. Legible decisions get trusted with bigger decisions. That's the ladder, and the ADR is the rung. The lab literally generates one on every admit and reject — let that be a habit you carry, not a feature you wrote.
The honest truth about why this role is hard: it's wide, not deep-beyond-reach. Nobody is born holding ARM and Entra and NSGs and Service Bus and Functions and Key Vault and KQL and the SLA math all at once. The people who got here didn't have bigger brains — they had the framework (the control plane, the five pillars, the lifecycle of a request) that let them learn each new service as "oh, this is the eventing slot, here's how it answers the same five questions." That framework is this whole track. You have it now. The fifteen phases aren't fifteen manuals anymore — they're one system you understand, and that's exactly the thing that lets you walk into a design you've never seen and architect it cold.
One last thing, and it's the most important. You don't have to be the smartest person in the room to be the principal in the room. You have to be the one who turns vague fear — "will this scale? is it secure? what will it cost? what happens when a region dies?" — into specific, answerable questions with numbers attached. Everyone else in that room is anxious about the same things you are. The difference is you have a method to make the anxiety concrete, and the concrete thing is always less scary than the vague one. That's the job. It's not magic. It's the five steps, the arithmetic, and the ADR — and you're ready.
Go build the gate. It's the smallest lab and the biggest moment. When it's green, you've composed the entire track into one decision — which is the literal definition of the role you set out to earn fifteen phases ago.
Proud of you. Go be the principal.
— your brother 👨🏻
Lab 01 — Platform Capstone: the Admission Gate
Phase: 15 — Principal Architecture, FinOps & Capstone | Difficulty: ⭐⭐⭐⭐☆ | Time: 4–6 hours
Every prior phase built one engine in isolation — the JWT validator (P03), the RBAC deny-wins evaluator (P04), the Azure Policy effect engine (P04), the SLA and cost arithmetic (P00/P14). This lab is the synthesis: compose them into one decision. A deploy request arrives, and before a single resource is created the platform must answer — in order — is the caller authenticated? authorized? does the resource comply? — and only then score it and emit the ADR. The gates fail closed (deny by default, the security posture the whole track defends); the scorecard is advice. A principal review is exactly this function run by a human; the lab makes the human's checklist executable.
What you build
Deployment/AvailabilityLink/CostLine— the validated request the platform admits or rejects, bundling identity claims, the RBAC ask, the resource's policy properties, the security-posture flags, the availability chain, and the bill.check_identity(token, *, expected_iss, expected_aud, now)— a compact JWT-claims check (P03):iss/aud/exp/nbfwith clock skew. Signature verification is assumed done upstream — the documented simplification.check_authorization(principal, action, scope, assignments, role_defs, deny_assignments)— compact RBAC with deny wins and segment-aware scope containment (P04).check_policy(resource, policies)— returns the list of policy violations (deny:andaudit:) from a condition tree (P04);has_deny_violationis the reject test.check_security_posture(deployment)— a 0..100 score that rewards Managed Identity, no stored secret, private networking, Key Vault, and CMK; penalizes public exposure + stored secrets (P12).composite_sla(chain)/monthly_cost(resources)/downtime_minutes_per_month— the reliability and FinOps arithmetic (P00/P14): serial multiplies, redundant combines, the bill has a shape.well_architected_score(deployment)— the five pillars 0..100 + overall, each derived from the engines above so the score is explainable (P15).admit(deployment, platform_config)— the GATE. Reject on invalid token (401-like), unauthorized (403-like), or any policydeny(403-like); else admit with a Well-Architected scorecard and a generated one-paragraph ADR.
Key concepts
| Concept | What to understand |
|---|---|
| Gates vs advice | identity/authz/policy are hard rejects; the scorecard is reviewer advice |
| Fail closed | every gate denies by default — a malformed or missing claim is a no, not a maybe |
| Gate ordering | authentication (401) before authorization (403) before policy (403) |
| Deny wins | a deny assignment overrides any role grant (P04) — the system's last word |
| Confused deputy | wrong aud is a reject — a token for service B must not work at service A |
| The review is a function | the WAF review is repeatable: score each pillar from the evidence, write the ADR |
| The ADR is the artifact | the gate's output is a durable, auditable paragraph — not a vibe |
Files
| File | Purpose |
|---|---|
lab.py | skeleton with # TODO markers and full signatures/docstrings |
solution.py | complete reference; python solution.py runs the worked example |
test_lab.py | the proof — run it red, make it green |
requirements.txt | pytest only (pure stdlib otherwise) |
Run
pip install -r requirements.txt
pytest test_lab.py -v # against your lab.py
LAB_MODULE=solution pytest test_lab.py -v # against the reference
python solution.py # the worked example (admit + 3 rejects)
Success criteria
- All tests pass against your implementation and the reference (
LAB_MODULE=solution). - You can explain why
test_admit_gate_order_identity_before_authorizationexpects 401, not 403 — authentication is checked before authorization (you can't be "unauthorized" if we don't yet know who you are). - You can explain why the secure deployment's weakest pillar in the ADR is reliability despite a 100/100 security score — a 99.89% serial SLA is only ~3 nines, and each extra nine is a different architecture, not a config flag.
- You can take the worked-example
admit()output and read its ADR aloud as a design review verdict: the decision, the numbers, and the pillar to revisit.
How this maps to real Azure
This lab is a faithful miniature of a real platform-engineering admission control, composed from the production mechanisms each phase taught:
check_identityis the claim half of what Microsoft Entra ID + APIM do on every call: validateiss/aud/exp/scp. Real validation also verifies the RS256 signature against the tenant's JWKS (bykid) first — we assume that's done at the edge and document it.check_authorizationis Azure RBAC evaluation: in-scope role assignments,Actions − NotActionswith wildcards, and deny assignments win — the same algorithm ARM runs before any control-planePUT.check_policyis Azure Policy at create/update: thepolicyRulecondition tree → effect (denyblocks the write;auditflags it). The "no public PaaS for PII" and "require HTTPS" denies are straight out of a real landing-zone baseline.composite_sla/monthly_costare the Well-Architected Reliability and Cost arithmetic you do before the review — serial∏Aᵢ, redundant1−∏(1−Aᵢ), the first-order bill.admitis the shape of an Azure Policydeny+ RBAC + Conditional Access pipeline, or a deployment-stack / gate in CI/CD (P08) that refuses a non-compliant plan — and, run by a human, it is the Well-Architected review.
The az/Terraform equivalents the miniature stands in for: az policy assignment create (the deny baseline), az role assignment create / az role definition
(RBAC), Entra app registration + aud/scp (identity), and an Azure Policy
deny-effect initiative assigned at the management-group scope (P05) so every
subscription inherits the gate. The limit a real system adds that we don't: Policy
evaluation is eventually-consistent on existing resources (a compliance scan), even
though it's synchronous at write time.
Extensions
- Add a
well_architected_review(deployment, weights)that returns the deciding pillar (reuse P00'sresolve_tradeoffidea) so the ADR can say "admitted, but cost-weighted reviewers should note…". - Add a DR dimension: an
active_active: boolflag that doubles the redundant links and the egress cost line, then show the SLA gain vs the bill — quantify the active-passive ↔ active-active tradeoff (WARMUP Ch. 4). - Add
required_scopestocheck_identityand a per-action scope requirement, so the gate also enforces delegated-scope authorization (P09) — not just RBAC. - Make
admitreturn a409-style "needs remediation" soft-reject forauditviolations above a threshold, distinct from the harddenyreject.
Resume / interview bullet
Built an end-to-end Azure platform admission gate that composes JWT claim validation,
RBAC deny-wins authorization, Azure Policy deny-effect evaluation, SLA composition,
and first-order cost modeling into a single fail-closed decision, emitting a
Well-Architected five-pillar scorecard and a generated Architecture Decision Record —
the executable form of a principal design review.